diff --git a/packages/web/app/globals.css b/packages/web/app/globals.css index 21be735e..5ee9fb7b 100644 --- a/packages/web/app/globals.css +++ b/packages/web/app/globals.css @@ -327,17 +327,32 @@ body { padding: 0.5rem; display: flex; flex-direction: column; + gap: 0.75rem; +} + +.ow-nav-group { + display: grid; gap: 0.34rem; } +.ow-nav-label { + margin: 0; + font-size: 0.66rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #64748b; + padding-left: 0.22rem; +} + .ow-nav-item { border: 1px solid #dbe3f4; border-radius: 0.72rem; background: #fff; color: #334155; - font-size: 0.76rem; + font-size: 0.8rem; font-weight: 700; - padding: 0.54rem 0.48rem; + padding: 0.58rem 0.5rem; text-align: left; } @@ -350,7 +365,7 @@ body { .ow-app-nav-footer { margin-top: auto; border-top: 1px solid #dbe3f4; - padding-top: 0.46rem; + padding-top: 0.58rem; display: grid; gap: 0.34rem; } @@ -374,6 +389,104 @@ body { gap: 0.16rem; } +.ow-pane-head-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.6rem; +} + +.ow-pane-block { + border: 1px solid #e3e9f7; + border-radius: 0.86rem; + background: #fbfcff; + padding: 0.62rem; + display: grid; + gap: 0.55rem; +} + +.ow-btn-compact { + font-size: 0.78rem; + padding: 0.54rem 0.72rem; +} + +.ow-filter-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.5rem; +} + +.ow-select { + border-radius: 0.78rem; + border: 1px solid #d9e1f2; + background: #fff; + color: #334155; + padding: 0.76rem 0.8rem; + font-size: 0.86rem; + font-weight: 600; +} + +.ow-select:focus-visible { + outline: none; + border-color: rgba(27, 41, 255, 0.55); + box-shadow: 0 0 0 0.22rem rgba(27, 41, 255, 0.16); +} + +.ow-overview-grid { + display: grid; + gap: 0.5rem; +} + +@media (min-width: 700px) { + .ow-overview-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.ow-overview-card { + border: 1px solid #e3e9f7; + border-radius: 0.86rem; + background: #fbfcff; + padding: 0.64rem; + display: grid; + gap: 0.22rem; +} + +.ow-overview-label { + margin: 0; + font-size: 0.7rem; + font-weight: 700; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.ow-overview-value { + margin: 0; + font-size: 0.94rem; + font-weight: 700; + color: #1e293b; +} + +.ow-overview-value.is-ready { + color: #0f9f66; +} + +.ow-overview-value.is-starting { + color: #1e3a8a; +} + +.ow-overview-value.is-attention { + color: #b45309; +} + +.ow-howto-copy { + margin: 0; + color: #64748b; + font-size: 0.75rem; + line-height: 1.4; +} + .ow-empty-detail { border: 1px dashed #d1ddef; border-radius: 0.82rem; @@ -402,6 +515,14 @@ body { max-width: 11rem; text-align: right; } + + .ow-filter-row { + grid-template-columns: 1fr; + } + + .ow-select { + width: 100%; + } } .ow-caption { diff --git a/packages/web/components/cloud-control.tsx b/packages/web/components/cloud-control.tsx index 476657ac..e8b55381 100644 --- a/packages/web/components/cloud-control.tsx +++ b/packages/web/components/cloud-control.tsx @@ -5,6 +5,7 @@ import { FormEvent, useEffect, useState } from "react"; type Step = 1 | 2; type AuthMode = "sign-in" | "sign-up"; type ShellView = "workers" | "billing"; +type WorkerStatusBucket = "ready" | "starting" | "attention" | "other"; type AuthUser = { id: string; @@ -243,11 +244,32 @@ function getWorkersList(payload: unknown): WorkerListItem[] { return rows; } +function getWorkerStatusMeta(status: string): { label: string; bucket: WorkerStatusBucket } { + const normalized = status.trim().toLowerCase(); + + if (normalized === "healthy" || normalized === "ready") { + return { label: "Ready", bucket: "ready" }; + } + + if (normalized === "provisioning" || normalized === "starting") { + return { label: "Starting", bucket: "starting" }; + } + + if (normalized === "failed" || normalized === "suspended") { + return { label: "Needs attention", bucket: "attention" }; + } + + return { label: "Unknown", bucket: "other" }; +} + function getWorkerStatusCopy(status: string): string { - switch (status) { + const normalized = status.trim().toLowerCase(); + switch (normalized) { case "provisioning": + case "starting": return "Starting... This may take a minute."; case "healthy": + case "ready": return "Ready to connect."; case "failed": return "Worker failed to start."; @@ -550,6 +572,9 @@ export function CloudControlPanel() { const [events, setEvents] = useState([]); const [copiedField, setCopiedField] = useState(null); const [tokenFetchedForWorkerId, setTokenFetchedForWorkerId] = useState(null); + const [workerQuery, setWorkerQuery] = useState(""); + const [workerStatusFilter, setWorkerStatusFilter] = useState("all"); + const [showLaunchForm, setShowLaunchForm] = useState(false); const selectedWorker = workers.find((item) => item.workerId === workerLookupId) ?? null; const activeWorker: WorkerLaunch | null = @@ -570,6 +595,27 @@ export function CloudControlPanel() { activeWorker?.workerName ?? null, ); + const filteredWorkers = workers.filter((item) => { + const query = workerQuery.trim().toLowerCase(); + const matchesQuery = + !query || + item.workerName.toLowerCase().includes(query) || + item.workerId.toLowerCase().includes(query); + + if (!matchesQuery) { + return false; + } + + if (workerStatusFilter === "all") { + return true; + } + + return getWorkerStatusMeta(item.status).bucket === workerStatusFilter; + }); + + const selectedWorkerStatus = activeWorker?.status ?? selectedWorker?.status ?? "unknown"; + const selectedStatusMeta = getWorkerStatusMeta(selectedWorkerStatus); + function appendEvent(level: EventLevel, label: string, detail: string) { setEvents((current) => { const next: LaunchEvent[] = [ @@ -819,6 +865,16 @@ export function CloudControlPanel() { setStep(1); }, [worker, user, checkoutUrl, paymentReturned]); + useEffect(() => { + if (step !== 2) { + return; + } + + if (workers.length === 0) { + setShowLaunchForm(true); + } + }, [step, workers.length]); + useEffect(() => { if (!user || !worker) { return; @@ -957,6 +1013,9 @@ export function CloudControlPanel() { setLaunchBusy(false); setStep(1); setShellView("workers"); + setWorkerQuery(""); + setWorkerStatusFilter("all"); + setShowLaunchForm(false); setAuthMode("sign-in"); setPassword(""); setAuthInfo("Sign in to launch and manage cloud workers."); @@ -1024,6 +1083,7 @@ export function CloudControlPanel() { setWorkerLookupId(parsedWorker.workerId); setPaymentReturned(false); setCheckoutUrl(null); + setShowLaunchForm(false); if (resolvedWorker.status === "provisioning") { setLaunchStatus("Provisioning started. This can take a few minutes, and we will keep checking automatically."); @@ -1317,22 +1377,26 @@ export function CloudControlPanel() { {step === 2 ? (