/sites is the fleet-wide site list that works across every hosting provider you have enabled. It exists because the main dashboard is server-first — every card is a server — and Pressable or standalone custom sites have no server to hang a card off of.
Adding Standalone Sites (+ Add Site)
A + Add Site button in the header toolbar opens the onboarding modal:
- Supports standalone WordPress sites on WP Engine, Kinsta, or any unmanaged host where you do not have server/API access.
- Operators download the companion plugin (
/companion/download) or install Clockwork Renegade from WordPress.org, activate it in WordPress, and copy the base64 256-bit Connection Key (from Clockwork → Connection for Renegade or Tools → Clockwork Control for Companion). - Pasting the key into the modal verifies the HMAC handshake and enrolls the site under the
customprovider. A failed handshake never writes the secret or Connection Key back into session old-input or the Alpine form — only the domain (via@js()) is safe to echo.
What you see
Paginated (50/page), searchable by domain, filterable by hosting provider with a live count on each filter chip — but only for providers whose module is currently enabled. Run just GridPane + Vultr? You’ll only ever see All / GridPane as filter options, never SpinupWP or Pressable tabs, even if old sites.hosting_provider rows for a since-disabled panel still exist in the database (the “All” tab still counts and shows those rows — enablement only hides the filter/tab UI, not real infrastructure). With one or zero hosting-provider modules enabled, the tab bar doesn’t render at all — there’s no meaningful choice to offer. See App\Services\HostingProvider\HostingProviderRegistry::all(), which is already enablement-gated via ModuleRegistry and is what SitesController::index() builds tabs/counts/the ?provider= filter from. Each row shows:
Search filters instantly as you type — no need to hit Enter. It’s a client-side filter over the currently-loaded page’s rows (matching against a lowercased domain + server name string baked into each row’s data-search attribute), with a small clear (×) button that appears once you’ve typed something. Since it only filters what’s already on the page, finding a match outside the visible 50 still needs a real server-side query (Enter, or the search icon). The same instant-filter pattern is also on Features → Review queue (bans) and Features → Contact form testing (forms).
- Domain (with WordPress or Globe icon)
- Provider — the server’s name for a SpinupWP site, a Pressable pill otherwise (
data-tooltip="Host: Pressable"ordata-tooltip="Server: [name]") - Uptime status pill (
data-tooltip="Uptime Online",Site Down, orIn Maintenance) - SSL certificate status pill (
data-tooltip="SSL Valid",SSL Expiring Soon, orSSL Expired) - Companion plugin indicator pill (
data-tooltip="Companion Plugin Active") - Care-plan indicator pill (
data-tooltip="Care Plan Active") - A crescent-moon Inactive pill when
site.is_inactiveis set (data-tooltip="Site Inactive"). See Features → Inactive sites for what setting that flag does — it doesn’t hide the row here, only from Issues/nav/alerts.
Every status pill uses the centralized Reference → Tooltip system with instant, top-positioned USWDS floating tooltips and caret indicators on hover.
Same visual language as the existing per-server sites-tab list, just fleet-wide and provider-agnostic.
List view vs. Visual grid view
A List / Grid toggle sits top-right of the toolbar. The choice is remembered per-browser (localStorage['clockwork_sites_view'], Alpine.js state), not per-user server-side, so it doesn’t follow you across devices.
- List is the row layout described above.
- Grid renders one card per site (
sites-grid, responsive 1–6 columns) with a homepage screenshot thumbnail, a health-colored accent bar (green/yellow/red fromSite::healthColor()— red onuptime_state === 'down'or a red SSL state, yellow on unknown uptime or a yellow SSL state, green otherwise), the domain, a Pressable/server-name badge, and a warning icon when health isn’t green. AnInactivebadge overlays the thumbnail for sites withis_inactiveset.
Both views share the same instant-filter search and the same server-side pagination/search fallback described above — the client-side filter script matches rows and cards in parallel and the AJAX search response swaps both #sites-list-card and #sites-grid-container at once, so switching views mid-search doesn’t lose your query.
Screenshots
Grid-view thumbnails come from Site::screenshotUrl(): if a locally cached screenshot exists (screenshot_path on the public disk) it’s served directly, otherwise the view falls back live to Automattic’s free mShots renderer (https://s0.wp.com/mshots/v1/...) so a card never shows blank while waiting on a first capture. A broken image (onerror) swaps in a placeholder globe icon with the domain underneath.
Caching is populated two ways:
- On site creation —
Site::booted()dispatchesCaptureSiteScreenshotJobautomatically for every new site (skipped under tests). - On a schedule —
clockwork:capture-site-screenshotsruns daily at 04:45, queued and backgrounded (Schedule::command(...)->dailyAt('04:45')->withoutOverlapping(30)->onOneServer()->runInBackground()), picking up to--limit=50sites at a time, prioritizing sites with no screenshot or one older than 7 days.--site=<domain-or-id>targets one site,--forcere-captures regardless of age,--syncruns inline instead of queueing (useful for manual backfills).SiteScreenshotService::capture()fetches from mShots, rejects mShots’ known “still generating” placeholder image (by a hardcoded MD5) and any response under 100 bytes, and only then writesscreenshots/{id}.jpgto thepublicdisk and stampsscreenshot_captured_at.
Why a separate page instead of extending the dashboard
The dashboard’s whole layout is “one card per server, health-sorted.” Pressable has nothing to sort by at that level — no CPU/memory/disk, no SSH status. Retrofitting the dashboard to also show a flat site list would have muddied a page that’s deliberately about server health. A dedicated list keeps both pages doing one job well.
Install Companion, provider-aware
The per-site Settings tab’s “Install Companion” button routes through SitesController::installCompanion(), which dispatches via $site->host()->companionInstaller() — the HostingProvider contract, not a direct Site::isPressable() branch — so it works the same way regardless of which of the 6 hosting-provider modules (SpinupWP, Pressable, Cloudways, Kinsta, WP Engine, GridPane) the site is on. All installers converge on the same result shape (companion_installed, companion_version, error detail on failure).
companionInstaller() is nullable by contract: a provider client in View-Only mode (GridPane/Cloudways/Kinsta/WP Engine default to this until an operator confirms live write access under /settings/integrations; SpinupWP/Pressable default to write-enabled) returns no installer at all, since installing Companion is a write operation. The button itself checks supports(HostingProvider::CAP_COMPANION) and doesn’t render for a View-Only site — it shows an explanatory “Install unavailable (View-Only)” note instead. The controller also guards the null case directly (422, not a crash) as a backstop for any other caller that skips the capability check.
Reorderable Settings tab cards
The per-site Settings tab (/sites/{site}?tab=settings) lays out up to 10 cards — cert, cloudflare, security, server_tools, care_plan, uptime, status, companion, forms, gatekeeper — in an order you can change per site. A card that doesn’t apply to a site isn’t rendered (for example forms appears only on care-plan sites with the contact-forms module enabled), but it keeps its slot in the saved order:
- Drag a card by its grip handle (HTML5 drag-and-drop), or use the up/down arrow buttons on each card on keyboard or touch.
- Each change is saved right away, without a page reload, via
PATCH /sites/{site}/settings-layout(layout[]of card keys, validated againstSite::DEFAULT_SETTINGS_LAYOUT) into the nullable JSON columnsites.settings_layout. A toast confirms the save. - Reset default layout sends
reset=1, which nulls the column so the site goes back to the default order. - Cards are moved as existing DOM nodes, not re-rendered, so open forms, modals (probe subscribers, cert editor, Gatekeeper override editor), and their event listeners keep working after a move.
Site::resolvedSettingsLayout()drops unknown keys from a saved layout and appends any card added since it was saved, so a new card never disappears from a site with a custom order. Add new cards toDEFAULT_SETTINGS_LAYOUTor the endpoint will reject their key.
The order is saved per site, not per user — everyone sees the same layout for a given site.
Site-detail pages are null-server-safe
Site::server is null for every Pressable site — and for a SpinupWP site whose server_id was deliberately cleared before archiving/decommissioning. Anywhere the site-detail UI used to assume a server exists — most visibly dashboard/site/header.blade.php’s server-name backlink — now shows a “← Sites” link plus a Pressable pill instead. If you’re extending a site-detail Blade partial and it reads $site->server->..., guard it: that will throw a 500 otherwise. SitesController::archive() safely falls back to sites.index when there is no server.
What’s NOT here
- No provider-specific bulk actions. Bulk Companion install, bulk care-plan toggle, etc. are per-site-page actions, not batchable from this list yet.
- No server-health equivalent for Pressable. There’s genuinely nothing to show — no CPU/memory/disk metrics exist for a Pressable site the way they do for a droplet.
See Concepts → Server, Site, Care plan, Hosting tier for the hosting_provider model this page is built on, and Integrations → Pressable for what is and isn’t reachable per site.
