diff --git a/crates/outer/src/http/console.rs b/crates/outer/src/http/console.rs index 4aa8e44..4a2e1af 100644 --- a/crates/outer/src/http/console.rs +++ b/crates/outer/src/http/console.rs @@ -20,6 +20,9 @@ //! in-memory log ring merged with persisted lines (same lines as MCP `app_logs`). //! - `GET /api/console/apps/:slug/errors?status=`: grouped runtime errors //! plus the trailing-hour proxy 5xx (same rows as MCP `app_errors`). +//! - `GET /api/console/apps/:slug/secrets`: the app's secret names plus +//! `updated_at` (same rows as MCP `list_secrets`; values never leave the +//! server — they are injected as Worker env vars at deploy time). //! - `POST /api/console/sign-out`: deletes the session, clears the cookie. //! //! Rules: session cookie only (an `Authorization` header is ignored, never @@ -58,6 +61,7 @@ pub fn router(state: super::AppState) -> Router { .route("/api/console/apps/{slug}/metrics", get(api_metrics)) .route("/api/console/apps/{slug}/logs", get(api_logs)) .route("/api/console/apps/{slug}/errors", get(api_errors)) + .route("/api/console/apps/{slug}/secrets", get(api_secrets)) .route("/api/console/sign-out", post(api_sign_out)) .with_state(state) } @@ -439,6 +443,46 @@ async fn api_errors( "s5xx_last_hour": s5xx, })) } +/// `GET /api/console/apps/:slug/secrets`: the app's secret names plus +/// `updated_at`, ordered by name. Values never leave the server. The caller +/// MUST own the app (see `owned_app`): anything else answers 404 exactly +/// like a missing app. +async fn api_secrets( + State(state): State, + headers: HeaderMap, + Path(slug): Path, +) -> Response { + let mcp = &state.mcp; + let account = match guard(&mcp.auth, &headers).await { + Ok(account) => account, + Err(res) => return *res, + }; + let app = match owned_app(mcp, &account, &slug).await { + Ok(Some(app)) => app, + Ok(None) => return not_found(), + Err(_) => { + return nostore_json( + StatusCode::INTERNAL_SERVER_ERROR, + "unavailable", + "could not load secrets right now", + ); + } + }; + match crate::secrets::list(&mcp.pool, &app.id).await { + Ok(secrets) => ok_json(json!({ + "secrets": secrets.iter().map(|s| json!({ + "name": s.name, + "updated_at": s.updated_at, + })).collect::>(), + })), + Err(_) => nostore_json( + StatusCode::INTERNAL_SERVER_ERROR, + "unavailable", + "could not load secrets right now", + ), + } +} + /// `GET /api/console/apps/:slug/actions`: the app's agent action log, /// newest first. `before` is the opaque `:` cursor of the /// last row on the previous page; `limit` defaults to 20 and clamps to @@ -1722,4 +1766,85 @@ mod tests { "bad_status" ); } + #[tokio::test] + async fn secrets_need_session_and_same_origin() { + let (app, pool, _, _) = test_app().await; + let now = crate::auth::now_ms(); + seed_app(&pool, "ana@example.com", "groceries", now).await; + // No cookie: 401, even same-origin. + let (status, _, _) = send( + &app, + same_origin(Request::get("/api/console/apps/groceries/secrets")) + .body(Body::empty()) + .unwrap(), + ) + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + // Valid session but cross-site: 403. + let session = login(&pool, "ana@example.com").await; + let (status, _, _) = send( + &app, + Request::get("/api/console/apps/groceries/secrets") + .header("cookie", session.clone()) + .header("sec-fetch-site", "cross-site") + .body(Body::empty()) + .unwrap(), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn secrets_are_owner_scoped_and_names_only() { + let (app, pool, _, _) = test_app().await; + let now = crate::auth::now_ms(); + seed_app(&pool, "ana@example.com", "groceries", now).await; + seed_app(&pool, "ben@example.com", "benblog", now).await; + let key = crate::secrets::SecretsKey::test_key(); + for name in ["STRIPE_KEY", "API_TOKEN"] { + let enc = crate::secrets::encrypt(&key, "app-groceries", name, b"shh"); + let capped = crate::secrets::set(&pool, "app-groceries", name, &enc, now) + .await + .unwrap(); + assert!(!capped); + } + let session = login(&pool, "ana@example.com").await; + let (status, headers, body) = send( + &app, + authed("/api/console/apps/groceries/secrets", &session), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(headers["cache-control"], "no-store"); + let body: serde_json::Value = body.parse().unwrap(); + let names: Vec<&str> = body["secrets"] + .as_array() + .unwrap() + .iter() + .map(|s| s["name"].as_str().unwrap()) + .collect(); + assert_eq!(names, vec!["API_TOKEN", "STRIPE_KEY"], "ordered by name"); + for secret in body["secrets"].as_array().unwrap() { + assert_eq!( + secret.as_object().unwrap().len(), + 2, + "name + updated_at only, never a value: {secret}" + ); + assert_eq!(secret["updated_at"], now); + } + let raw = body.to_string(); + assert!(!raw.contains("shh"), "no plaintext value in the response"); + // A foreign slug answers exactly like a missing one. + let (foreign_status, _, foreign_body) = + send(&app, authed("/api/console/apps/benblog/secrets", &session)).await; + let (missing_status, _, missing) = + send(&app, authed("/api/console/apps/nope/secrets", &session)).await; + assert_eq!(foreign_status, StatusCode::NOT_FOUND); + assert_eq!(missing_status, StatusCode::NOT_FOUND); + assert_eq!(foreign_body, missing); + assert_eq!( + foreign_body.parse::().unwrap()["error"], + "not_found" + ); + } } diff --git a/web/pages/console/_islands/api.ts b/web/pages/console/_islands/api.ts index 5e61129..0e8c84c 100644 --- a/web/pages/console/_islands/api.ts +++ b/web/pages/console/_islands/api.ts @@ -104,6 +104,16 @@ export interface ActionsPage { actions: ActionEntry[]; next: string | null; } + +export interface SecretEntry { + name: string; + updated_at: number; +} + +export interface SecretsSummary { + secrets: SecretEntry[]; +} + export function shortSha(sha: string): string { return sha.slice(0, 7); } diff --git a/web/pages/console/_islands/app.tsx b/web/pages/console/_islands/app.tsx deleted file mode 100644 index 765f04c..0000000 --- a/web/pages/console/_islands/app.tsx +++ /dev/null @@ -1,766 +0,0 @@ -// The `/console/app?app=` page: header with status, URL and -// visibility, metrics (requests, visitors, errors, latency, top paths), -// the agent action log, the last 20 deploys, and recent logs with a refresh -// button. The slug rides the query string (one static shell for every app). -// Metrics and actions load independently from their own endpoints so one -// slow panel never blocks the other. -import { atom } from "ilha"; -import { watch } from "ilha"; - -import { - api, - shortSha, - when, - type ActionEntry, - type ActionsPage, - type AppDetail, - type ErrorsSummary, - type LogLine, - type MetricsSummary, -} from "./api.ts"; - -type State = - | { kind: "loading" } - | { kind: "error"; message: string } - | { kind: "ready"; app: AppDetail; logs: LogLine[]; logsError: string | null }; -type MetricsPanel = - | { kind: "loading" } - | { kind: "error"; message: string } - | { kind: "ready"; hours: number; summary: MetricsSummary }; - -type ActionsPanel = - | { kind: "loading" } - | { kind: "error"; message: string } - | { - kind: "ready"; - actions: ActionEntry[]; - next: string | null; - loadingMore: boolean; - moreError: string | null; - }; - -type ErrorsPanel = - | { kind: "loading" } - | { kind: "error"; message: string } - | { kind: "ready"; summary: ErrorsSummary }; - -const METRICS_RANGES = [ - { hours: 24, label: "24 h" }, - { hours: 168, label: "7 d" }, - { hours: 720, label: "30 d" }, -] as const; - -function badge(status: string): string { - switch (status) { - case "success": - case "running": - return "badge badge-success"; - case "building": - case "deploying": - case "reviewing": - case "queued": - return "badge badge-info"; - case "failed": - case "blocked": - return "badge badge-error"; - case "sleeping": - return "badge badge-neutral"; - default: - return "badge badge-ghost"; - } -} - -function outcomeBadge(outcome: string): string { - switch (outcome) { - case "success": - case "ok": - return "badge badge-success"; - case "failed": - case "error": - return "badge badge-error"; - case "blocked": - return "badge badge-warning"; - case "building": - return "badge badge-neutral"; - default: - return "badge badge-ghost"; - } -} - -function timeAgo(ms: number): string { - const diff = Date.now() - ms; - if (diff < 60_000) { - return "just now"; - } - if (diff < 3_600_000) { - return `${Math.floor(diff / 60_000)}m ago`; - } - if (diff < 86_400_000) { - return `${Math.floor(diff / 3_600_000)}h ago`; - } - return `${Math.floor(diff / 86_400_000)}d ago`; -} - -function axisLabel(ms: number): string { - return new Date(ms).toLocaleString(undefined, { - month: "short", - day: "numeric", - hour: "numeric", - }); -} - -async function loadLogs(slug: string): Promise { - const data = await api<{ lines: LogLine[] }>( - `/api/console/apps/${encodeURIComponent(slug)}/logs?limit=100`, - ); - return data.lines; -} - -async function loadMetrics(slug: string, hours: number): Promise { - return api( - `/api/console/apps/${encodeURIComponent(slug)}/metrics?hours=${hours}`, - ); -} - -async function loadActions(slug: string, before: string | null): Promise { - const cursor = before === null ? "" : `&before=${encodeURIComponent(before)}`; - return api( - `/api/console/apps/${encodeURIComponent(slug)}/actions?limit=20${cursor}`, - ); -} - -async function loadErrors(slug: string): Promise { - return api( - `/api/console/apps/${encodeURIComponent(slug)}/errors`, - ); -} - -function MetricsTiles({ summary }: { summary: MetricsSummary }) { - const errorRate = - summary.requests > 0 - ? `${((100 * summary.s5xx) / summary.requests).toFixed(1)}%` - : "—"; - const tiles: Array<{ label: string; value: string; sub: string; hint?: string }> = [ - { label: "Requests", value: summary.requests.toLocaleString(), sub: "in range" }, - { - label: "Visitors", - value: summary.visitors.toLocaleString(), - sub: "daily unique visitors", - hint: "Summed daily uniques over the range: a visitor active on N days counts N times.", - }, - { - label: "Error rate", - value: errorRate, - sub: `${summary.s5xx.toLocaleString()} server errors`, - }, - { label: "p50 latency", value: `${summary.p50_ms} ms`, sub: "response headers" }, - { label: "p95 latency", value: `${summary.p95_ms} ms`, sub: "response headers" }, - ]; - return ( -
- {tiles.map((tile) => ( -
-
-

{tile.label}

-

{tile.value}

- {tile.hint ? ( -

- - {tile.sub} - -

- ) : ( -

{tile.sub}

- )} -
-
- ))} -
- ); -} - -function RequestsChart({ summary }: { summary: MetricsSummary }) { - const buckets = summary.buckets; - // Fixed viewBox: bars share the width whatever the range (24 h–30 d), - // so axis type keeps one size and dense ranges merge into an area. - const width = 720; - const height = 140; - const plot = 110; - const step = width / buckets.length; - const bar = Math.max(step - 0.5, 0.5); - const max = Math.max(1, ...buckets.map((bucket) => bucket.requests)); - const first = buckets[0]; - const last = buckets[buckets.length - 1]; - return ( -
- - {buckets.map((bucket, i) => { - if (bucket.requests === 0) { - return null; - } - const ok = Math.max(0, bucket.requests - bucket.s4xx - bucket.s5xx); - const hOk = (ok / max) * plot; - const h4 = (bucket.s4xx / max) * plot; - const h5 = (bucket.s5xx / max) * plot; - const x = i * step; - return ( - - - {`${when(bucket.hour)}: ${bucket.requests} requests, ${bucket.s4xx} 4xx, ${bucket.s5xx} 5xx`} - - - - 0 ? 0.5 : 0)} - class="fill-error" - /> - - ); - })} - {first && last && ( - - - {axisLabel(first.hour)} - - - {axisLabel(last.hour)} - - - )} - -
- - requests - - - 4xx - - - 5xx - -
-
- ); -} - -function MetricsSection({ - slug, - panel, - onRange, -}: { - slug: string; - panel: MetricsPanel; - onRange: (hours: number) => void; -}) { - const active = panel.kind === "ready" ? panel.hours : 24; - return ( -
-
-

Metrics

-
- {METRICS_RANGES.map((range) => ( - - ))} -
-
- {panel.kind === "loading" && ( -
- -
- )} - {panel.kind === "error" && ( - - )} - {panel.kind === "ready" && - (panel.summary.requests === 0 ? ( -
- -
- No traffic yet in this range. -
-
- ) : ( -
- - -
- - - - - - - - - - {panel.summary.top_paths.map((row) => ( - - - - - - ))} - -
PathRequests5xx
{row.path}{row.requests.toLocaleString()}{row.s5xx.toLocaleString()}
-
-
- ))} -
- ); -} - -function ActionsSection({ - panel, - onMore, -}: { - panel: ActionsPanel; - onMore: () => void; -}) { - return ( -
-

Agent actions

- {panel.kind === "loading" && ( -
- -
- )} - {panel.kind === "error" && ( - - )} - {panel.kind === "ready" && - (panel.actions.length === 0 ? ( -
- No agent actions yet. -
- ) : ( -
-
- - - - - - - - - - - - - {panel.actions.map((action) => ( - - - - - - - - - ))} - -
TimeClientToolSummaryOutcomeDuration
{timeAgo(action.at)}{action.by}{action.tool} - {action.summary} - - {action.outcome} - {action.error_code && ( - - {action.error_code} - - )} - {`${(action.duration_ms / 1000).toFixed(1)}s`}
-
- {panel.moreError && ( - - )} - {panel.next && ( - - )} -
- ))} -
- ); -} - -function ErrorsSection({ panel }: { panel: ErrorsPanel }) { - return ( -
-

Errors

- {panel.kind === "loading" && ( -
- -
- )} - {panel.kind === "error" && ( - - )} - {panel.kind === "ready" && - (panel.summary.issues.length === 0 ? ( -
- No errors recorded. - {panel.summary.s5xx_last_hour > 0 && - ` ${panel.summary.s5xx_last_hour} proxy 5xx in the last hour, with no grouped issue.`} -
- ) : ( -
-

- {panel.summary.s5xx_last_hour.toLocaleString()} proxy 5xx in the - last hour. -

-
- - - - - - - - - - - {panel.summary.issues.map((issue) => ( - - - - - - - ))} - -
Last seenErrorSeenStatus
{timeAgo(issue.last_seen)} - {issue.message} - {issue.frame && ( - <> -
- {issue.frame} - - )} -
{issue.count.toLocaleString()}× - {issue.status} -
-
-
- ))} -
- ); -} - -export function AppView() { - const state = atom({ kind: "loading" }); - const metrics = atom({ kind: "loading" }); - const actions = atom({ kind: "loading" }); - const errors = atom({ kind: "loading" }); - const slug = - typeof window === "undefined" - ? null - : new URLSearchParams(window.location.search).get("app"); - - async function refresh(slug: string): Promise { - try { - const logs = await loadLogs(slug); - const current = state(); - if (current.kind === "ready") { - state.set({ ...current, logs, logsError: null }); - } - } catch (err: unknown) { - const current = state(); - if (current.kind === "ready") { - state.set({ ...current, logsError: String(err) }); - } - } - } - - function pickRange(slug: string, hours: number): void { - const current = metrics(); - if (current.kind === "ready" && current.hours === hours) { - return; - } - metrics.set({ kind: "loading" }); - loadMetrics(slug, hours) - .then((summary) => { - metrics.set({ kind: "ready", hours, summary }); - }) - .catch((err: unknown) => { - metrics.set({ kind: "error", message: String(err) }); - }); - } - - function loadMore(slug: string): void { - const current = actions(); - if (current.kind !== "ready" || current.next === null || current.loadingMore) { - return; - } - const before = current.next; - actions.set({ ...current, loadingMore: true, moreError: null }); - loadActions(slug, before) - .then((page) => { - const latest = actions(); - if (latest.kind !== "ready") { - return; - } - actions.set({ - ...latest, - actions: [...latest.actions, ...page.actions], - next: page.next, - loadingMore: false, - }); - }) - .catch((err: unknown) => { - const latest = actions(); - if (latest.kind !== "ready") { - return; - } - actions.set({ ...latest, loadingMore: false, moreError: String(err) }); - }); - } - - watch.once(() => { - if (typeof window === "undefined" || !slug) { - return; - } - const name = slug; - api(`/api/console/apps/${encodeURIComponent(name)}`) - .then(async (app) => { - const logs = await loadLogs(app.slug).catch(() => null); - state.set({ - kind: "ready", - app, - logs: logs ?? [], - logsError: logs === null ? "Couldn't load logs." : null, - }); - loadMetrics(app.slug, 24) - .then((summary) => { - metrics.set({ kind: "ready", hours: 24, summary }); - }) - .catch((err: unknown) => { - metrics.set({ kind: "error", message: String(err) }); - }); - loadActions(app.slug, null) - .then((page) => { - actions.set({ - kind: "ready", - actions: page.actions, - next: page.next, - loadingMore: false, - moreError: null, - }); - }) - .catch((err: unknown) => { - actions.set({ kind: "error", message: String(err) }); - }); - loadErrors(app.slug) - .then((summary) => { - errors.set({ kind: "ready", summary }); - }) - .catch((err: unknown) => { - errors.set({ kind: "error", message: String(err) }); - }); - }) - .catch((err: unknown) => { - state.set({ kind: "error", message: String(err) }); - metrics.set({ kind: "error", message: String(err) }); - actions.set({ kind: "error", message: String(err) }); - errors.set({ kind: "error", message: String(err) }); - }); - }); - const current = state(); - if (current.kind === "loading") { - if (typeof window !== "undefined" && !slug) { - return ( - - ); - } - return ( -
- -
- ); - } - if (current.kind === "error") { - return ( -
- - - Back to your apps - -
- ); - } - const { app, logs, logsError } = current; - return ( -
- - ← Your apps - -
-

{app.slug}

- {app.status} -
-
-
-
URL
-
- - {app.url} - -
-
-
-
Visibility
-
{app.visibility}
-
-
-
Last request
-
{when(app.last_request_at)}
-
-
- - pickRange(app.slug, hours)} - /> - - loadMore(app.slug)} /> - -
-

Deploys

- {app.deploys.length === 0 ? ( -
- No deploys yet. -
- ) : ( -
- - - - - - - - - - - - {app.deploys.map((deploy) => ( - - - - - - - - ))} - -
CommitStatusDurationFindingsCreated
{shortSha(deploy.sha)} - {deploy.status} - - {deploy.duration_ms == null - ? "—" - : `${(deploy.duration_ms / 1000).toFixed(1)}s`} - {deploy.findings_count}{when(deploy.created_at)}
-
- )} - {app.deploys.some((deploy) => deploy.message) && ( -
    - {app.deploys - .filter((deploy) => deploy.message) - .map((deploy) => ( - - ))} -
- )} -
- -
-
-

Recent logs

- -
- {logsError && ( - - )} - {logs.length === 0 ? ( -
- No log lines yet. -
- ) : ( -
-            {logs.map((entry) => (
-              "}>
-                {entry.line}
-              
-            ))}
-          
- )} -
- - -
- ); -} diff --git a/web/pages/console/_islands/apps.tsx b/web/pages/console/_islands/apps.tsx deleted file mode 100644 index 7a89dff..0000000 --- a/web/pages/console/_islands/apps.tsx +++ /dev/null @@ -1,130 +0,0 @@ -// The `/console` apps list: the account's apps as daisyUI cards with a -// status badge, URL link, last deploy and last request. 401s leave through -// the sign-in flow (see api.ts). -import { atom } from "ilha"; -import { watch } from "ilha"; - -import { api, shortSha, when, type ConsoleApp } from "./api.ts"; - -type State = - | { kind: "loading" } - | { kind: "error"; message: string } - | { kind: "ready"; apps: ConsoleApp[]; email: string }; - -function badge(status: string): string { - switch (status) { - case "running": - return "badge badge-success"; - case "sleeping": - return "badge badge-neutral"; - case "building": - return "badge badge-info"; - case "failed": - return "badge badge-error"; - default: - return "badge badge-ghost"; - } -} - -async function signOut(): Promise { - await fetch("/api/console/sign-out", { method: "POST" }); - globalThis.location.assign("/console/sign-in"); -} - -export function AppsList() { - const state = atom({ kind: "loading" }); - watch.once(() => { - if (typeof window === "undefined") { - return; - } - api<{ apps: ConsoleApp[] }>("/api/console/apps") - .then(async (apps) => { - // The apps endpoint carries no email; one /me call labels the page. - const me = await api<{ email: string }>("/api/console/me"); - state.set({ kind: "ready", apps: apps.apps, email: me.email }); - }) - .catch((err: unknown) => { - state.set({ kind: "error", message: String(err) }); - }); - }); - - const current = state(); - if (current.kind === "loading") { - return ( -
- -
- ); - } - if (current.kind === "error") { - return ( - - ); - } - return ( -
-

- Signed in as {current.email}{" "} - -

- {current.apps.length === 0 ? ( -
- No apps yet. Ask your agent to create one over MCP. -
- ) : ( -
    - {current.apps.map((app) => ( -
  • -
    -
    -

    {app.slug}

    - {app.status} -
    - - {app.url} - -
    -
    -
    Visibility
    -
    {app.visibility}
    -
    -
    -
    Last deploy
    -
    - {app.last_deploy - ? `${shortSha(app.last_deploy.sha)} · ${app.last_deploy.status}` - : "none yet"} -
    -
    -
    -
    Last request
    -
    {when(app.last_request_at)}
    -
    -
    - -
    -
  • - ))} -
- )} -
- ); -} diff --git a/web/pages/console/_islands/common.tsx b/web/pages/console/_islands/common.tsx new file mode 100644 index 0000000..9d6f61a --- /dev/null +++ b/web/pages/console/_islands/common.tsx @@ -0,0 +1,127 @@ +import { atom } from "ilha"; + +import type { ActionEntry } from "./api.ts"; +import { CheckIcon, CopyIcon } from "./icons.tsx"; + +export const VIEWS = [ + { id: "overview", label: "Overview" }, + { id: "metrics", label: "Metrics" }, + { id: "actions", label: "Agent actions" }, + { id: "deploys", label: "Deploys" }, + { id: "logs", label: "Logs" }, + { id: "errors", label: "Errors" }, + { id: "secrets", label: "Secrets" }, + { id: "settings", label: "Settings" }, +] as const; + +export type ViewId = (typeof VIEWS)[number]["id"]; + +export const RANGES = [ + { hours: 24, label: "24h", title: "Last 24 hours" }, + { hours: 168, label: "7d", title: "Last 7 days" }, + { hours: 720, label: "30d", title: "Last 30 days" }, +] as const; + +export interface Route { + app: string | null; + view: ViewId; + range: number; +} + +export function toViewId(raw: string | null): ViewId { + return VIEWS.some((v) => v.id === raw) ? (raw as ViewId) : "overview"; +} + +export function toRange(raw: string | null): number { + const hours = Number(raw); + return RANGES.some((r) => r.hours === hours) ? hours : 24; +} + +export function readRoute(): Route { + if (typeof window === "undefined") { + return { app: null, view: "overview", range: 24 }; + } + const q = new URLSearchParams(window.location.search); + return { + app: q.get("app"), + view: toViewId(q.get("t")), + range: toRange(q.get("r")), + }; +} + +export function routeUrl(route: Route): string { + const q = new URLSearchParams(); + if (route.app) { + q.set("app", route.app); + } + q.set("t", route.view); + q.set("r", String(route.range)); + return `/console?${q.toString()}`; +} + +// Relative times with the absolute time on hover (Noite's convention). +export function timeAgo(ms: number): string { + const diff = Date.now() - ms; + if (diff < 60_000) { + return "just now"; + } + if (diff < 3_600_000) { + return `${Math.floor(diff / 60_000)}m ago`; + } + if (diff < 86_400_000) { + return `${Math.floor(diff / 3_600_000)}h ago`; + } + return `${Math.floor(diff / 86_400_000)}d ago`; +} + +export type Panel = + | { kind: "loading" } + | { kind: "error"; message: string } + | { kind: "ready"; value: T }; + +export type { ActionEntry }; + + +export interface ActionsState { + actions: ActionEntry[]; + next: string | null; + loadingMore: boolean; + moreError: string | null; +} + +export type ActionsPanel = + | { kind: "loading" } + | { kind: "error"; message: string } + | { kind: "ready"; state: ActionsState }; + +// Copy button (Noite's $lib/ui/copy-button, per-instance feedback). +export function CopyButton({ value, label }: { value: string; label: string }) { + const copied = atom(false); + return ( + + ); +} diff --git a/web/pages/console/_islands/console.tsx b/web/pages/console/_islands/console.tsx new file mode 100644 index 0000000..0f84768 --- /dev/null +++ b/web/pages/console/_islands/console.tsx @@ -0,0 +1,877 @@ +// Console shell: rail (app picker) + sidebar (app views) + main panel. +// Ported from Noite's apps/[id].tsx + $lib/app-detail/{shell,panel}.tsx — +// tabs ride `?t=`, the range rides `?r=` (24/168/720 h), the URL carries +// `?app=` too (one static shell for every runtime slug), components +// remount per app id, and every panel shows skeletons, empty states, +// relative times with absolute titles, and copy buttons. Noite's SSE +// stream-hub is NOT ported: the visible view polls every ~10 s instead. +import { atom } from "ilha"; +import { watch } from "ilha"; + +import { + api, + type ActionsPage, + type AppDetail, + type ConsoleApp, + type ErrorsSummary, + type LogLine, + type MetricsSummary, + type SecretsSummary, +} from "./api.ts"; +import { + ActionsIcon, + CloseIcon, + CollapseIcon, + DatabaseIcon, + DeploysIcon, + DocsIcon, + ErrorsIcon, + LogsIcon, + MenuIcon, + MetricsIcon, + OuterMark, + OverviewIcon, + PlusIcon, + SecretsIcon, + SettingsIcon, + SignOutIcon, +} from "./icons.tsx"; +import { + ActionsView, + DeploysView, + ErrorsView, + LogsView, + MetricsView, + OverviewView, + SecretsView, + SettingsView, +} from "./views.tsx"; + +import { + CopyButton, + RANGES, + VIEWS, + readRoute, + routeUrl, + type ActionsPanel, + type Panel, + type Route, + type ViewId, +} from "./common.tsx"; + +// --- Shared bits ------------------------------------------------------------- + +const TINTS = 8; + +function tintIndex(slug: string): number { + let h = 0; + for (let i = 0; i < slug.length; i++) { + h = (h * 31 + slug.charCodeAt(i)) >>> 0; + } + return h % TINTS; +} + +function monogram(slug: string): string { + const clean = slug.replace(/[^a-z0-9]/gi, ""); + return (clean.slice(0, 1) || "?").toUpperCase(); +} + +async function loadActionsPage( + slug: string, + before: string | null, +): Promise { + const cursor = before === null ? "" : `&before=${encodeURIComponent(before)}`; + return api( + `/api/console/apps/${encodeURIComponent(slug)}/actions?limit=20${cursor}`, + ); +} + +// --- Shell ------------------------------------------------------------------- + +interface Ui { + collapsed: boolean; + drawer: boolean; + createOpen: boolean; + accountOpen: boolean; + expanded: string[]; +} + +const VIEW_TITLES: Record = { + overview: "Overview", + metrics: "Metrics", + actions: "Agent actions", + deploys: "Deploys", + logs: "Logs", + errors: "Errors", + secrets: "Secrets", + settings: "Settings", +}; + +export function Console() { + const route = atom(readRoute()); + const apps = atom>({ kind: "loading" }); + const email = atom(""); + const detail = atom>({ kind: "loading" }); + const metrics = atom>({ + kind: "loading", + }); + const actions = atom({ kind: "loading" }); + const errors = atom>({ kind: "loading" }); + const logs = atom>({ kind: "loading" }); + const secrets = atom>({ kind: "loading" }); + const ui = atom({ + collapsed: false, + drawer: false, + createOpen: false, + accountOpen: false, + expanded: [], + }); + + // One generation per navigation: late responses from the previous app + // never repaint the new one (Noite keys its cards per app id instead). + let epoch = 0; + + function setTitle(r: Route, app: AppDetail | null): void { + document.title = + app && r.app + ? `${r.app} · ${VIEW_TITLES[r.view]} · Outer console` + : "Console · Outer"; + } + + function loadersFor(view: ViewId): Array<"metrics" | "actions" | "errors" | "logs" | "secrets"> { + switch (view) { + case "overview": + return ["metrics", "actions", "errors"]; + case "metrics": + return ["metrics"]; + case "actions": + return ["actions"]; + case "deploys": + return []; + case "logs": + return ["logs"]; + case "errors": + return ["errors"]; + case "secrets": + return ["secrets"]; + case "settings": + return []; + } + } + + async function loadViewData( + slug: string, + view: ViewId, + range: number, + myEpoch: number, + quiet: boolean, + ): Promise { + const fresh = () => epoch === myEpoch; + const put = (a: { set: (p: Panel) => void }, p: Panel): void => { + if (fresh()) { + a.set(p); + } + }; + const jobs: Array> = []; + if (loadersFor(view).includes("metrics")) { + if (!quiet) { + metrics.set({ kind: "loading" }); + } + jobs.push( + api( + `/api/console/apps/${encodeURIComponent(slug)}/metrics?hours=${range}`, + ) + .then((summary) => { + put(metrics, { kind: "ready", value: { hours: range, summary } }); + }) + .catch((err: unknown) => { + if (!quiet) { + put(metrics, { kind: "error", message: String(err) }); + } + }), + ); + } + if (loadersFor(view).includes("actions")) { + if (!quiet) { + actions.set({ kind: "loading" }); + } + jobs.push( + loadActionsPage(slug, null) + .then((page) => { + if (fresh()) { + actions.set({ + kind: "ready", + state: { + actions: page.actions, + next: page.next, + loadingMore: false, + moreError: null, + }, + }); + } + }) + .catch((err: unknown) => { + if (!quiet) { + put(actions, { kind: "error", message: String(err) }); + } + }), + ); + } + if (loadersFor(view).includes("errors") || (quiet && view !== "errors")) { + if (!quiet && loadersFor(view).includes("errors")) { + errors.set({ kind: "loading" }); + } + jobs.push( + api( + `/api/console/apps/${encodeURIComponent(slug)}/errors`, + ) + .then((summary) => { + put(errors, { kind: "ready", value: summary }); + }) + .catch((err: unknown) => { + if (!quiet && loadersFor(view).includes("errors")) { + put(errors, { kind: "error", message: String(err) }); + } + }), + ); + } + if (loadersFor(view).includes("logs")) { + if (!quiet) { + logs.set({ kind: "loading" }); + } + jobs.push( + api<{ lines: LogLine[] }>( + `/api/console/apps/${encodeURIComponent(slug)}/logs?limit=100`, + ) + .then((data) => { + put(logs, { kind: "ready", value: data.lines }); + }) + .catch((err: unknown) => { + if (!quiet) { + put(logs, { kind: "error", message: String(err) }); + } + }), + ); + } + if (loadersFor(view).includes("secrets")) { + if (!quiet) { + secrets.set({ kind: "loading" }); + } + jobs.push( + api( + `/api/console/apps/${encodeURIComponent(slug)}/secrets`, + ) + .then((summary) => { + put(secrets, { kind: "ready", value: summary }); + }) + .catch((err: unknown) => { + if (!quiet) { + put(secrets, { kind: "error", message: String(err) }); + } + }), + ); + } + await Promise.all(jobs); + } + + async function enter(r: Route, myEpoch: number): Promise { + if (!r.app) { + return; + } + const fresh = () => epoch === myEpoch; + detail.set({ kind: "loading" }); + metrics.set({ kind: "loading" }); + actions.set({ kind: "loading" }); + errors.set({ kind: "loading" }); + logs.set({ kind: "loading" }); + secrets.set({ kind: "loading" }); + try { + const app = await api( + `/api/console/apps/${encodeURIComponent(r.app)}`, + ); + if (!fresh()) { + return; + } + detail.set({ kind: "ready", value: app }); + setTitle(r, app); + await loadViewData(r.app, r.view, r.range, myEpoch, false); + } catch (err: unknown) { + if (fresh()) { + const message = String(err); + detail.set({ kind: "error", message }); + setTitle(r, null); + } + } + } + + function navigate(next: Partial, replace = false): void { + const prev = route(); + const r: Route = { ...prev, ...next }; + const url = routeUrl(r); + if (replace) { + window.history.replaceState(null, "", url); + } else { + window.history.pushState(null, "", url); + } + route.set(r); + const current = ui(); + ui.set({ ...current, drawer: false, createOpen: false }); + epoch += 1; + if (r.app !== prev.app) { + void enter(r, epoch); + return; + } + if (r.app) { + void loadViewData(r.app, r.view, r.range, epoch, false); + } + const d = detail(); + if (d.kind === "ready") { + setTitle(r, d.value); + } + } + + function loadMoreActions(): void { + const current = actions(); + const r = route(); + if (current.kind !== "ready" || !current.state.next || !r.app) { + return; + } + const slug = r.app; + const before = current.state.next; + actions.set({ + kind: "ready", + state: { ...current.state, loadingMore: true, moreError: null }, + }); + loadActionsPage(slug, before) + .then((page) => { + const latest = actions(); + if (latest.kind !== "ready") { + return; + } + actions.set({ + kind: "ready", + state: { + actions: [...latest.state.actions, ...page.actions], + next: page.next, + loadingMore: false, + moreError: null, + }, + }); + }) + .catch((err: unknown) => { + const latest = actions(); + if (latest.kind !== "ready") { + return; + } + actions.set({ + kind: "ready", + state: { ...latest.state, loadingMore: false, moreError: String(err) }, + }); + }); + } + + watch.once(() => { + if (typeof window === "undefined") { + return; + } + // The island prerenders at build time (no window): re-sync the route + // from the live URL on client boot, never trusting the restored atom. + route.set(readRoute()); + api<{ apps: ConsoleApp[] }>("/api/console/apps") + .then(async (data) => { + apps.set({ kind: "ready", value: data.apps }); + try { + const me = await api<{ email: string }>("/api/console/me"); + email.set(me.email); + } catch { + email.set(""); + } + const r = route(); + if (!r.app) { + if (data.apps.length > 0) { + const first = data.apps[0].slug; + const next: Route = { ...r, app: first }; + window.history.replaceState(null, "", routeUrl(next)); + route.set(next); + epoch += 1; + await enter(next, epoch); + } + return; + } + if (!data.apps.some((a) => a.slug === r.app)) { + detail.set({ + kind: "error", + message: "request failed: 404", + }); + return; + } + epoch += 1; + await enter(r, epoch); + }) + .catch((err: unknown) => { + apps.set({ kind: "error", message: String(err) }); + detail.set({ kind: "error", message: String(err) }); + }); + window.addEventListener("popstate", () => { + const r = readRoute(); + route.set(r); + if (!r.app) { + return; + } + const known = apps(); + if (known.kind === "ready" && !known.value.some((a) => a.slug === r.app)) { + detail.set({ kind: "error", message: "request failed: 404" }); + return; + } + epoch += 1; + void enter(r, epoch); + }); + // Noite's live feed, polling edition: refresh the visible view only. + window.setInterval(() => { + const r = route(); + if (!r.app || document.hidden) { + return; + } + void loadViewData(r.app, r.view, r.range, epoch, true); + }, 10_000); + }); + + const r = route(); + const appsPanel = apps(); + const detailPanel = detail(); + + if (appsPanel.kind === "loading") { + return ( +
+
+
+
+
+ ); + } + if (appsPanel.kind === "error") { + return ( + + ); + } + const list = appsPanel.value; + if (list.length === 0) { + return ( +
+ +

No apps yet

+

+ Apps are created by your agent over MCP at{" "} + https://outer.now/mcp — there is nothing to click here + yet. +

+
+ ); + } + const active = + list.find((a) => a.slug === r.app) ?? + (detailPanel.kind === "ready" ? detailPanel.value : null); + const errorsPanel = errors(); + const openErrors = + errorsPanel.kind === "ready" + ? errorsPanel.value.issues.filter((i) => i.status === "open").length + : 0; + + const patchUi = (patch: Partial) => ui.set({ ...ui(), ...patch }); + + return ( +
+ + + + {active && ( + + )} + {ui().drawer && active && ( + + )} +

{active ? VIEW_TITLES[r.view] : "Console"}

+ {active && (r.view === "overview" || r.view === "metrics") && ( +
+ {RANGES.map((opt) => ( + + ))} +
+ )} + {active && ui().collapsed && ( + + )} +
+
+ {!active && ( +
+

Select an app from the rail.

+
+ )} + {active && detailPanel.kind === "loading" && ( +
+
+
+
+
+ )} + {active && detailPanel.kind === "error" && ( + + )} + {active && detailPanel.kind === "ready" && ( + { + const cur = ui().expanded; + patchUi({ + expanded: cur.includes(id) + ? cur.filter((x) => x !== id) + : [...cur, id], + }); + }} + onRange={(hours) => navigate({ r: hours } as Partial)} + onLoadMore={loadMoreActions} + onRefreshLogs={() => { + const slug = route().app; + if (!slug) { + return; + } + logs.set({ kind: "loading" }); + api<{ lines: LogLine[] }>( + `/api/console/apps/${encodeURIComponent(slug)}/logs?limit=100`, + ) + .then((data) => { + logs.set({ kind: "ready", value: data.lines }); + }) + .catch((err: unknown) => { + logs.set({ kind: "error", message: String(err) }); + }); + }} + /> + )} +
+ +
+ ); +} + +function ViewIcon({ id }: { id: ViewId }) { + switch (id) { + case "overview": + return ; + case "metrics": + return ; + case "actions": + return ; + case "deploys": + return ; + case "logs": + return ; + case "errors": + return ; + case "secrets": + return ; + case "settings": + return ; + } +} + +function ViewBody(props: { + route: Route; + app: AppDetail; + metrics: Panel<{ hours: number; summary: MetricsSummary }>; + actions: ActionsPanel; + errors: Panel; + logs: Panel; + secrets: Panel; + expanded: string[]; + onToggleExpand: (id: string) => void; + onRange: (hours: number) => void; + onLoadMore: () => void; + onRefreshLogs: () => void; +}) { + switch (props.route.view) { + case "overview": + return ( + navigate({ view })} + /> + ); + case "metrics": + return ; + case "actions": + return ; + case "deploys": + return ( + + ); + case "logs": + return ; + case "errors": + return ; + case "secrets": + return ; + case "settings": + return ; + } +} diff --git a/web/pages/console/_islands/icons.tsx b/web/pages/console/_islands/icons.tsx new file mode 100644 index 0000000..9fc575b --- /dev/null +++ b/web/pages/console/_islands/icons.tsx @@ -0,0 +1,211 @@ +// Thin-stroke icon set for the console shell. +// Ported from Noite's $lib/ui/icons (1.5px outline SVGs, currentColor): +// narrowed to the console's set, no icon font, no CDN — CSP is self-only. +// Every icon is a 24×24 stroke glyph; pass `size` for pixel height/width. +export function Icon({ + size = 18, + children, + label, +}: { + size?: number; + children?: unknown; + label?: string; +}) { + return ( + + {children} + + ); +} + +export function OuterMark({ size = 22 }: { size?: number }) { + return ( + + + + + + ); +} + +export function OverviewIcon({ size = 18 }: { size?: number }) { + return ( + + + + + + + ); +} + +export function MetricsIcon({ size = 18 }: { size?: number }) { + return ( + + + + + ); +} + +export function ActionsIcon({ size = 18 }: { size?: number }) { + return ( + + + + ); +} + +export function DeploysIcon({ size = 18 }: { size?: number }) { + return ( + + + + + + + ); +} + +export function LogsIcon({ size = 18 }: { size?: number }) { + return ( + + + + + ); +} + +export function ErrorsIcon({ size = 18 }: { size?: number }) { + return ( + + + + + + ); +} + +export function SecretsIcon({ size = 18 }: { size?: number }) { + return ( + + + + + + + ); +} + +export function SettingsIcon({ size = 18 }: { size?: number }) { + return ( + + + + + ); +} + +export function CollapseIcon({ size = 18 }: { size?: number }) { + return ( + + + + + + ); +} + +export function MenuIcon({ size = 18 }: { size?: number }) { + return ( + + + + + + ); +} + +export function DocsIcon({ size = 18 }: { size?: number }) { + return ( + + + + + ); +} + +export function SignOutIcon({ size = 18 }: { size?: number }) { + return ( + + + + + + ); +} + +export function CopyIcon({ size = 14 }: { size?: number }) { + return ( + + + + + ); +} + +export function CheckIcon({ size = 14 }: { size?: number }) { + return ( + + + + ); +} + +export function PlusIcon({ size = 18 }: { size?: number }) { + return ( + + + + + ); +} + +export function CloseIcon({ size = 18 }: { size?: number }) { + return ( + + + + + ); +} + +export function ChevronDownIcon({ size = 14 }: { size?: number }) { + return ( + + + + ); +} + +export function DatabaseIcon({ size = 18 }: { size?: number }) { + return ( + + + + + + ); +} diff --git a/web/pages/console/_islands/views.tsx b/web/pages/console/_islands/views.tsx new file mode 100644 index 0000000..4948a7d --- /dev/null +++ b/web/pages/console/_islands/views.tsx @@ -0,0 +1,1129 @@ +// Console views: one panel per sidebar item. +// Data shapes ported from Noite's $lib/app-detail/{metrics,deploys,errors, +// events,logs}.tsx; interaction details follow Noite (loading skeletons, +// empty states, relative times with absolute titles on hover, copy buttons +// for shas/URLs, keyboard-focusable rows). Charts are monochrome inline SVG: +// near-black series, light-gray comparison series, dashed vertical +// gridlines, minimal axes, muted tick labels. No `style=` attributes — the +// console CSP only hashes inline scripts/styles, so all sizing rides on +// SVG attributes. +import { + shortSha, + when, + type AppDetail, + type ConsoleDeploy, + type ErrorsSummary, + type LogLine, + type MetricsBucket, + type MetricsSummary, + type SecretsSummary, +} from "./api.ts"; +import { + CopyButton, + timeAgo, + type ActionsPanel, + type Panel, + type ViewId, +} from "./common.tsx"; +import { + ActionsIcon, + ChevronDownIcon, + ErrorsIcon, + MetricsIcon, + OverviewIcon, +} from "./icons.tsx"; + +// --- Small pieces ------------------------------------------------------------ + +function Loading({ label }: { label: string }) { + return ( +
+
+
+
+ ); +} + +function Err({ message }: { message: string }) { + return ( + + ); +} + +function Empty({ children }: { children?: unknown }) { + return ( +
+

{children}

+
+ ); +} + +function compact(n: number): string { + if (n >= 1000) { + const v = (n / 1000).toFixed(1).replace(/\.0$/, ""); + return `${v}k`; + } + return `${n}`; +} + +function statusClass(status: string): string { + if (status === "success" || status === "running" || status === "ok") { + return "is-ok"; + } + if ( + status === "building" || + status === "deploying" || + status === "reviewing" || + status === "queued" + ) { + return "is-info"; + } + if (status === "failed" || status === "error" || status === "blocked") { + return "is-bad"; + } + if (status === "sleeping") { + return "is-muted"; + } + return "is-neutral"; +} + +function outcomeClass(outcome: string): string { + if (outcome === "success" || outcome === "ok") { + return "is-ok"; + } + if (outcome === "failed" || outcome === "error") { + return "is-bad"; + } + if (outcome === "blocked") { + return "is-warn"; + } + return "is-neutral"; +} + +function axisLabel(ms: number, hours: number): string { + const d = new Date(ms); + if (hours <= 24) { + return d.toLocaleString(undefined, { hour: "numeric" }); + } + return d.toLocaleString(undefined, { month: "short", day: "numeric" }); +} + +function bucketTitle(bucket: MetricsBucket, hours: number): string { + const label = new Date(bucket.hour).toLocaleString(undefined, + hours <= 24 + ? { hour: "numeric" } + : { month: "short", day: "numeric", hour: "numeric" }, + ); + return `${label}: ${bucket.requests} requests, ${bucket.s4xx} 4xx, ${bucket.s5xx} 5xx`; +} + +// Second-half vs first-half percentage change (the stat-card delta pills). +function halfChange(buckets: MetricsBucket[]): string | null { + if (buckets.length < 2) { + return null; + } + const mid = Math.floor(buckets.length / 2); + const first = buckets + .slice(0, mid) + .reduce((a, b) => a + b.requests, 0); + const second = buckets.slice(mid).reduce((a, b) => a + b.requests, 0); + if (first === 0) { + return second > 0 ? "new" : null; + } + const pct = Math.round(((second - first) / first) * 100); + return `${pct >= 0 ? "+" : ""}${pct}%`; +} + +// Error rate: exact 0% only when there are no 5xx; sub-1% rates keep two +// decimals so e.g. 6 / 28,845 reads 0.02% instead of 0.0%. +function fmtRate(s5xx: number, requests: number): string { + if (requests <= 0) { + return "—"; + } + if (s5xx <= 0) { + return "0%"; + } + const pct = (100 * s5xx) / requests; + if (pct >= 1) { + return `${pct.toFixed(1)}%`; + } + if (pct >= 0.005) { + return `${pct.toFixed(2)}%`; + } + return "<0.01%"; +} + +// Y-axis ticks with a round ceiling (0, 10, 20, … like the reference). +function niceTicks(max: number, count = 5): number[] { + if (max <= 0) { + return [0]; + } + const raw = max / (count - 1); + const mag = 10 ** Math.floor(Math.log10(raw)); + const norm = raw / mag; + const step = (norm >= 5 ? 10 : norm >= 2 ? 5 : norm >= 1 ? 2 : 1) * mag; + const top = Math.ceil(max / step) * step; + const ticks: number[] = []; + for (let v = 0; v <= top + step / 2; v += step) { + ticks.push(Math.round((v / step)) * step); + } + return ticks; +} + +// --- Stat cards -------------------------------------------------------------- + +function StatCard({ + icon, + label, + value, + pill, + sub, +}: { + icon?: unknown; + label: string; + value: string; + pill?: string | null; + sub?: string; +}) { + return ( +
+

+ {icon} + {label} +

+

+ {value} + {pill != null && pill !== "" && ( + {pill} + )} +

+ {sub &&

{sub}

} +
+ ); +} + +// --- Charts ------------------------------------------------------------------ +// RequestsLine: dark requests line over a light-gray errors line, dashed +// vertical gridlines, muted end ticks. Bars: Noite's metrics.tsx BarChart +// (fixed viewBox, per-bar , axis end labels). + +function RequestsLine({ + buckets, + hours, +}: { + buckets: MetricsBucket[]; + hours: number; +}) { + const width = 720; + const height = 240; + const padL = 44; + const padB = 26; + const padT = 14; + const plotW = width - padL - 8; + const plotH = height - padT - padB; + const max = Math.max(1, ...buckets.map((b) => b.requests)); + const yTicks = niceTicks(max); + const top = Math.max(1, yTicks[yTicks.length - 1]); + const n = buckets.length; + const x = (i: number) => + n === 1 ? padL + plotW / 2 : padL + (i * plotW) / (n - 1); + const y = (v: number) => padT + plotH * (1 - v / top); + const line = (pick: (b: MetricsBucket) => number) => + buckets.map((b, i) => `${x(i).toFixed(1)},${y(pick(b)).toFixed(1)}`).join(" "); + const area = + `${padL},${(padT + plotH).toFixed(1)} ` + + line((b) => b.requests) + + ` ${x(n - 1).toFixed(1)},${(padT + plotH).toFixed(1)}`; + const guides = [0, 1, 2, 3, 4, 5]; + const first = buckets[0]; + const last = buckets[n - 1]; + return ( + <figure class="chart-block"> + <svg + viewBox={`0 0 ${width} ${height}`} + width="100%" + role="img" + aria-label="Requests over time with errors for comparison" + > + {yTicks.map((t) => ( + <g key={t}> + <line + x1={padL} + y1={y(t)} + x2={padL + plotW} + y2={y(t)} + stroke="currentColor" + stroke-width="1" + stroke-dasharray="3 4" + opacity="0.25" + /> + <text + x={padL - 6} + y={y(t) + 4} + font-size="11" + fill="currentColor" + opacity="0.55" + text-anchor="end" + > + {compact(t)} + </text> + </g> + ))} + {guides.map((t) => { + const gx = padL + (t * plotW) / 5; + return ( + <line + key={t} + x1={gx} + y1={padT} + x2={gx} + y2={padT + plotH} + stroke="currentColor" + stroke-width="1" + stroke-dasharray="3 4" + opacity="0.25" + /> + ); + })} + <polygon points={area} fill="currentColor" opacity="0.05" /> + <polyline + points={line((b) => b.s4xx + b.s5xx)} + fill="none" + stroke="currentColor" + stroke-width="1.5" + opacity="0.3" + /> + <polyline + points={line((b) => b.requests)} + fill="none" + stroke="currentColor" + stroke-width="2" + /> + {first && last && ( + <g font-size="11" fill="currentColor" opacity="0.55"> + <text x={padL} y={height - 8}> + {axisLabel(first.hour, hours)} + </text> + <text x={padL + plotW} y={height - 8} text-anchor="end"> + {axisLabel(last.hour, hours)} + </text> + </g> + )} + </svg> + </figure> + ); +} + +function Bars({ + buckets, + hours, +}: { + buckets: MetricsBucket[]; + hours: number; +}) { + // 30 d of hourly buckets would render sub-pixel: roll up to days so + // bars stay readable. Fewer than ~200 buckets keep hourly bars. + let bars = buckets; + let grouped = false; + if (buckets.length > 200) { + const days: MetricsBucket[] = []; + for (let i = 0; i < buckets.length; i += 24) { + const slice = buckets.slice(i, i + 24); + days.push({ + hour: slice[0].hour, + requests: slice.reduce((a, b) => a + b.requests, 0), + s4xx: slice.reduce((a, b) => a + b.s4xx, 0), + s5xx: slice.reduce((a, b) => a + b.s5xx, 0), + }); + } + bars = days; + grouped = true; + } + const width = 720; + const height = 200; + const padL = 44; + const padB = 26; + const padT = 14; + const plotW = width - padL - 8; + const plotH = height - padT - padB; + const max = Math.max(1, ...bars.map((b) => b.requests)); + const yTicks = niceTicks(max); + const top = Math.max(1, yTicks[yTicks.length - 1]); + const y = (v: number) => padT + plotH * (1 - v / top); + const step = plotW / Math.max(1, bars.length); + // Thin near-black bars like the reference (2–3 px at card width). + const bar = Math.min(3, Math.max(2, step * 0.55)); + const barX = (i: number) => padL + i * step + (step - bar) / 2; + const titleFor = (b: MetricsBucket): string => + grouped + ? `${axisLabel(b.hour, 168)}: ${b.requests} requests, ${b.s4xx} 4xx, ${b.s5xx} 5xx` + : bucketTitle(b, hours); + const first = bars[0]; + const last = bars[bars.length - 1]; + return ( + <figure class="chart-block"> + <svg + viewBox={`0 0 ${width} ${height}`} + width="100%" + role="img" + aria-label={grouped ? "Daily requests as bars" : "Hourly requests as bars"} + > + {yTicks.map((t) => ( + <g key={t}> + <line + x1={padL} + y1={y(t)} + x2={padL + plotW} + y2={y(t)} + stroke="currentColor" + stroke-width="1" + stroke-dasharray="3 4" + opacity="0.25" + /> + <text + x={padL - 6} + y={y(t) + 4} + font-size="11" + fill="currentColor" + opacity="0.55" + text-anchor="end" + > + {compact(t)} + </text> + </g> + ))} + {bars.map((b, i) => { + const h = Math.max(1.5, (b.requests / top) * (plotH - 2)); + return ( + <g key={b.hour}> + <rect + x={barX(i)} + y={padT + plotH - h} + width={bar} + height={h} + fill="currentColor" + /> + <rect + x={padL + i * step} + y={padT} + width={step} + height={plotH} + fill="transparent" + > + <title>{titleFor(b)} + + + ); + })} + + + + ); +} + +// --- Overview ------------------------------------------------------------------ + +export function OverviewView({ + app, + metrics, + actions, + errors, + onRange, + onView, +}: { + app: AppDetail; + metrics: Panel<{ hours: number; summary: MetricsSummary }>; + actions: ActionsPanel; + errors: Panel; + onRange: (hours: number) => void; + onView: (view: ViewId) => void; +}) { + return ( +
+ {metrics.kind === "loading" && } + {metrics.kind === "error" && } + {metrics.kind === "ready" && ( + + )} +
+
+
+

Latest deploy

+ +
+ {app.deploys.length === 0 ? ( +

+ No deploys yet. +

+ ) : ( + + )} +
+
+
+

Latest agent actions

+ +
+ {actions.kind === "loading" && ( +

+ Loading actions… +

+ )} + {actions.kind === "error" && ( + + )} + {actions.kind === "ready" && + (actions.state.actions.length === 0 ? ( +

+ No agent actions yet. +

+ ) : ( +
    + {actions.state.actions.slice(0, 5).map((a) => ( +
  • + + {a.outcome} + + + {a.summary || a.tool} + + + {timeAgo(a.at)} + +
  • + ))} +
+ ))} +
+
+ {errors.kind === "ready" && errors.value.issues.length > 0 && ( +
+
+

Open errors

+ +
+
    + {errors.value.issues + .filter((i) => i.status === "open") + .slice(0, 3) + .map((i) => ( +
  • + + {i.message} + + + {i.count.toLocaleString()}× ·{" "} + {timeAgo(i.last_seen)} + +
  • + ))} +
+
+ )} +
+ ); +} + +function OverviewReady({ + summary, + hours, + onRange, +}: { + summary: MetricsSummary; + hours: number; + onRange: (hours: number) => void; +}) { + const errorRate = fmtRate(summary.s5xx, summary.requests); + return ( +
+
+ } + label="Requests" + value={summary.requests.toLocaleString()} + pill={halfChange(summary.buckets)} + sub="in range" + /> + } + label="Visitors" + value={summary.visitors.toLocaleString()} + sub="daily uniques, summed" + /> + } + label="Error rate" + value={errorRate} + pill={summary.s5xx > 0 ? `${summary.s5xx.toLocaleString()} 5xx` : null} + sub="server errors share" + /> + } + label="p95 latency" + value={`${summary.p95_ms} ms`} + pill={`p50 ${summary.p50_ms} ms`} + sub="response headers" + /> +
+
+
+

Requests

+
+ + + + +
+
+ {summary.buckets.length === 0 ? ( +

+ No traffic in this range. +

+ ) : ( + + )} +
+
+ ); +} + +// --- Metrics ------------------------------------------------------------------- + +export function MetricsView({ + metrics, + onRange, +}: { + metrics: Panel<{ hours: number; summary: MetricsSummary }>; + onRange: (hours: number) => void; +}) { + if (metrics.kind === "loading") { + return ; + } + if (metrics.kind === "error") { + return ; + } + const { summary, hours } = metrics.value; + const errorRate = fmtRate(summary.s5xx, summary.requests); + return ( +
+
+ } + label="Requests" + value={summary.requests.toLocaleString()} + pill={halfChange(summary.buckets)} + sub="in range" + /> + } + label="Visitors" + value={summary.visitors.toLocaleString()} + sub="daily uniques, summed" + /> + } + label="Error rate" + value={errorRate} + pill={summary.s5xx > 0 ? `${summary.s5xx.toLocaleString()} 5xx` : null} + sub={`${summary.s4xx.toLocaleString()} client errors (4xx)`} + /> + } + label="p95 latency" + value={`${summary.p95_ms} ms`} + pill={`p50 ${summary.p50_ms} ms`} + sub="response headers" + /> +
+
200 ? "Daily requests" : "Hourly requests"}> +
+

{summary.buckets.length > 200 ? "Daily requests" : "Hourly requests"}

+
+ {summary.buckets.length === 0 ? ( +

+ No traffic in this range. +

+ ) : ( + + )} +
+
+
+

Top paths

+
+ {summary.top_paths.length === 0 ? ( +

+ No paths recorded in this range. +

+ ) : ( +
+ + + + + + + + + + {summary.top_paths.map((p) => ( + + + + + + ))} + +
PathRequests5xx
+ {p.path} + {p.requests.toLocaleString()}{p.s5xx.toLocaleString()}
+
+ )} +
+
+ ); +} + +// --- Actions ------------------------------------------------------------------- +// Ported from Noite's events.tsx: newest-first feed, cursor pagination. + +export function ActionsView({ + panel, + onMore, +}: { + panel: ActionsPanel; + onMore: () => void; +}) { + if (panel.kind === "loading") { + return ; + } + if (panel.kind === "error") { + return ; + } + const { actions, next, loadingMore, moreError } = panel.state; + if (actions.length === 0) { + return No agent actions yet.; + } + return ( +
+
+ + + + + + + + + + + + + {actions.map((a) => ( + + + + + + + + + ))} + +
TimeClientToolSummaryOutcomeDuration
+ {timeAgo(a.at)} + {a.by}{a.tool} + {a.summary} + + + {a.outcome} + + {a.error_code && ( + {a.error_code} + )} + {`${(a.duration_ms / 1000).toFixed(1)}s`}
+
+ {moreError && ( + + )} + {next && ( + + )} +
+ ); +} + +// --- Deploys ------------------------------------------------------------------- +// Ported from Noite's deploys.tsx: status pill, sha + copy, expandable row. + +function DeployRow({ deploy }: { deploy: ConsoleDeploy }) { + return ( +
+ {deploy.status} + + {shortSha(deploy.sha)} + + + + {timeAgo(deploy.created_at)} + + + {deploy.duration_ms == null + ? "" + : `${(deploy.duration_ms / 1000).toFixed(1)}s`} + +
+ ); +} + +export function DeploysView({ + app, + expanded, + onToggle, +}: { + app: AppDetail; + expanded: string[]; + onToggle: (id: string) => void; +}) { + if (app.deploys.length === 0) { + return No deploys yet.; + } + return ( +
+ {app.deploys.map((d) => { + const open = expanded.includes(d.deploy_id); + return ( +
+ + {open && ( +
+
+ Commit + + {d.sha}{" "} + + +
+
+ Created + {when(d.created_at)} +
+
+ Findings + {d.findings_count} +
+
+ URL + + + {app.url} + {" "} + + +
+ {d.message && ( + + )} +
+ )} +
+ ); + })} +
+ ); +} + +// --- Logs ---------------------------------------------------------------------- + +export function LogsView({ + panel, + onRefresh, +}: { + panel: Panel; + onRefresh: () => void; +}) { + if (panel.kind === "loading") { + return ; + } + if (panel.kind === "error") { + return ; + } + const lines = panel.value; + return ( +
+
+

+ Recent logs{" "} + + ({lines.length.toLocaleString()} lines) + +

+ +
+ {lines.length === 0 ? ( +

+ No log lines yet. +

+ ) : ( +
    + {lines.map((entry) => ( +
  1. + + {entry.line} +
  2. + ))} +
+ )} +
+ ); +} + +// --- Errors -------------------------------------------------------------------- +// Ported from Noite's errors.tsx: grouped issues with counts + first/last +// seen, plus the trailing-hour proxy 5xx count. + +export function ErrorsView({ panel }: { panel: Panel }) { + if (panel.kind === "loading") { + return ; + } + if (panel.kind === "error") { + return ; + } + const { issues, s5xx_last_hour } = panel.value; + if (issues.length === 0) { + return ( + + {`No errors recorded.${s5xx_last_hour > 0 ? ` ${s5xx_last_hour} proxy 5xx in the last hour, with no grouped issue.` : ""}`} + + ); + } + return ( +
+
+

Errors

+ + {s5xx_last_hour.toLocaleString()} proxy 5xx in the last hour + +
+
+ + + + + + + + + + + + {issues.map((issue) => ( + + + + + + + + ))} + +
Last seenErrorSeenFirst seenStatus
+ {timeAgo(issue.last_seen)} + + {issue.message} + {issue.frame && ( + +
+ {issue.frame} +
+ )} +
{issue.count.toLocaleString()}× + {timeAgo(issue.first_seen)} + + + {issue.status} + +
+
+
+ ); +} + +// --- Secrets ------------------------------------------------------------------- + +export function SecretsView({ panel }: { panel: Panel }) { + if (panel.kind === "loading") { + return ; + } + if (panel.kind === "error") { + return ; + } + const secrets = panel.value.secrets; + return ( +
+
+

+ Names only — values never leave the server. Your agent manages them + over MCP (`list_secrets`, `set_secret`, `unset_secret`); they reach + the app as Worker env vars on the next deploy. +

+
+ {secrets.length === 0 ? ( + No secrets set. + ) : ( +
+
+ + + + + + + + + {secrets.map((s) => ( + + + + + ))} + +
NameUpdated
+ {s.name}{" "} + + + {timeAgo(s.updated_at)} +
+
+
+ )} +
+ ); +} + +// --- Settings -------------------------------------------------------------------- + +export function SettingsView({ app }: { app: AppDetail }) { + return ( +
+
+

+ Read-only for now — your agent manages these over MCP. +

+
+
+
App
+
+ {app.slug}{" "} + +
+
+
+
URL
+
+ + {app.url} + {" "} + +
+
+
+
Visibility
+
{app.visibility}
+
+
+
Status
+
+ + {app.status} + +
+
+
+
Your role
+
{app.role}
+
+
+
Last request
+
+ {app.last_request_at ? timeAgo(app.last_request_at) : "never"} +
+
+
+
+
+ ); +} diff --git a/web/pages/console/app.astro b/web/pages/console/app.astro index 5f29101..9698aa5 100644 --- a/web/pages/console/app.astro +++ b/web/pages/console/app.astro @@ -1,5 +1,5 @@ --- -import { AppView } from "./_islands/app"; +import { Console } from "./_islands/console"; import "./console.css"; --- @@ -9,18 +9,12 @@ import "./console.css"; - + App · Outer console -
-
-

Outer console

- Docs -
-
- -
-
+
+ +
diff --git a/web/pages/console/console.css b/web/pages/console/console.css index 42a37c1..811ba70 100644 --- a/web/pages/console/console.css +++ b/web/pages/console/console.css @@ -1,85 +1,1141 @@ -/* Console-only stylesheet, linked from the standalone console shells. - Tailwind v4 (via Blume's Vite) plus daisyUI. Self-contained on purpose: - the shells link no Blume stylesheet, so Blume's tokens don't exist here - and the palette below carries Blume's own light/dark values instead (read - off the built docs CSS). Nothing in this file reaches the docs. - `@source "."` pins Tailwind's content scan to this directory: Blume's - Vite does not see the island `.tsx` otherwise (verified: classes only - appear with the explicit source). The light theme is the default; the - dark one follows the OS preference. */ -@import "tailwindcss"; - -@source "."; - -@plugin "daisyui" { - themes: false; - exclude: scrollbar; -} - -@plugin "daisyui/theme" { - name: "light"; - default: true; - color-scheme: light; - --color-base-100: oklch(100% 0 0); - --color-base-200: oklch(98% 0 0); - --color-base-300: oklch(92% 0 0); - --color-base-content: oklch(14.5% 0 0); - --color-primary: oklch(55% 0.16 250); - --color-primary-content: oklch(100% 0 0); - --color-secondary: oklch(65% 0.241 354.308); - --color-secondary-content: oklch(94% 0.028 342.258); - --color-accent: oklch(55% 0.16 250); - --color-accent-content: oklch(100% 0 0); - --color-neutral: oklch(14% 0.005 285.823); - --color-neutral-content: oklch(92% 0.004 286.32); - --color-info: oklch(74% 0.16 232.661); - --color-info-content: oklch(29% 0.066 243.157); - --color-success: oklch(76% 0.177 163.223); - --color-success-content: oklch(37% 0.077 168.94); - --color-warning: oklch(82% 0.189 84.429); - --color-warning-content: oklch(41% 0.112 45.904); - --color-error: oklch(71% 0.194 13.428); - --color-error-content: oklch(27% 0.105 12.094); - --radius-selector: 0.5rem; - --radius-field: 0.5rem; - --radius-box: 0.75rem; - --size-selector: 0.25rem; - --size-field: 0.25rem; - --border: 1px; - --depth: 1; - --noise: 0; -} - -@plugin "daisyui/theme" { - name: "dark"; - prefersdark: true; - color-scheme: dark; - --color-base-100: oklch(8.5% 0 0); - --color-base-200: oklch(16% 0 0); - --color-base-300: oklch(24% 0 0); - --color-base-content: oklch(96% 0 0); - --color-primary: oklch(62% 0.16 250); - --color-primary-content: oklch(8.5% 0 0); - --color-secondary: oklch(65% 0.241 354.308); - --color-secondary-content: oklch(94% 0.028 342.258); - --color-accent: oklch(62% 0.16 250); - --color-accent-content: oklch(8.5% 0 0); - --color-neutral: oklch(14% 0.005 285.823); - --color-neutral-content: oklch(92% 0.004 286.32); - --color-info: oklch(74% 0.16 232.661); - --color-info-content: oklch(29% 0.066 243.157); - --color-success: oklch(76% 0.177 163.223); - --color-success-content: oklch(37% 0.077 168.94); - --color-warning: oklch(82% 0.189 84.429); - --color-warning-content: oklch(41% 0.112 45.904); - --color-error: oklch(71% 0.194 13.428); - --color-error-content: oklch(27% 0.105 12.094); - --radius-selector: 0.5rem; - --radius-field: 0.5rem; - --radius-box: 0.75rem; - --size-selector: 0.25rem; - --size-field: 0.25rem; - --border: 1px; - --depth: 1; - --noise: 0; +/* Outer console shell: rail + sidebar + main panel. + Standalone (no Blume stylesheet, no icon font, no CDN): every glyph is an + inline SVG in _islands/icons.tsx. Light theme is the default, matching the + reference (soft light-gray canvas, white panels, ~18px radii); the dark + variant follows the OS preference. Tokens live in CSS variables. */ +:root { + color-scheme: light dark; + --canvas: #f2f3f5; + --panel: #ffffff; + --panel-soft: #f6f7f9; + --border: #e4e6eb; + --border-soft: #eceef2; + --ink: #141414; + --ink-soft: #55585f; + --ink-muted: #8a8e96; + --accent: #141414; + --pill-bg: #f0f1f4; + --ok: #1c7a3d; + --ok-bg: #e5f4ea; + --info: #2456c4; + --info-bg: #e7eefc; + --bad: #b3261e; + --bad-bg: #fbe9e7; + --warn: #8a5a00; + --warn-bg: #fdf3dd; + --rail-tile: #eef0f3; + --shadow: 0 1px 2px rgb(20 20 20 / 0.05), 0 8px 24px -12px rgb(20 20 20 / 0.12); + --radius: 18px; + --radius-sm: 12px; + --font: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + --mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace; +} + +@media (prefers-color-scheme: dark) { + :root { + --canvas: #101214; + --panel: #17191d; + --panel-soft: #1d2025; + --border: #2b2f36; + --border-soft: #24282e; + --ink: #f0f1f3; + --ink-soft: #b7bbc2; + --ink-muted: #82878f; + --accent: #f0f1f3; + --pill-bg: #24282e; + --ok: #7ddba0; + --ok-bg: #14301f; + --info: #9db8f5; + --info-bg: #1a2745; + --bad: #f5a49e; + --bad-bg: #3d1a17; + --warn: #e8c26a; + --warn-bg: #3a2d10; + --rail-tile: #22262c; + --shadow: 0 1px 2px rgb(0 0 0 / 0.3), 0 8px 24px -12px rgb(0 0 0 / 0.5); + } +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; +} + +body { + background: var(--canvas); + color: var(--ink); + font-family: var(--font); + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +/* --- App frame --------------------------------------------------------- */ + +.console { + display: grid; + grid-template-columns: 64px 260px minmax(0, 1fr); + gap: 12px; + align-items: start; + min-height: 100dvh; + padding: 12px; +} + +.console.is-collapsed { + grid-template-columns: 64px minmax(0, 1fr); +} + +.console.is-collapsed .side { + display: none; +} + +.skip-link { + position: absolute; + left: -9999px; + top: 0; + background: var(--panel); + color: var(--ink); + padding: 8px 12px; + border-radius: 0 0 var(--radius-sm) var(--radius-sm); + z-index: 100; +} + +.skip-link:focus { + left: 12px; +} + +:focus-visible { + outline: 2px solid var(--ink); + outline-offset: 2px; + border-radius: 6px; +} + +/* --- Rail -------------------------------------------------------------- */ + +.rail { + position: sticky; + top: 12px; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 10px 0; + min-height: calc(100dvh - 24px); + box-shadow: var(--shadow); +} + +.rail-apps { + display: flex; + flex-direction: column; + gap: 8px; + align-items: center; + overflow-y: auto; +} + +.rail-tile { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + border: 1px solid transparent; + border-radius: 14px; + background: var(--rail-tile); + color: var(--ink-soft); + cursor: pointer; + text-decoration: none; + padding: 0; +} + +.rail-tile:hover { + color: var(--ink); + border-color: var(--border); +} + +.rail-tile.is-active { + background: var(--panel); + color: var(--ink); + border-color: var(--border); + box-shadow: var(--shadow); +} + +.rail-mark { + background: var(--ink); + color: var(--panel); +} + +.rail-mono { + font-weight: 700; + font-size: 21px; + letter-spacing: 0.02em; + line-height: 1; +} + +.status-dot { + position: absolute; + right: 6px; + bottom: 6px; + width: 10px; + height: 10px; + border-radius: 50%; + border: 2px solid var(--panel); + background: var(--ink-muted); +} + +.status-dot.is-running, +.status-dot.is-success { + background: #22a355; +} + +.status-dot.is-sleeping { + background: var(--ink-muted); +} + +.status-dot.is-building, +.status-dot.is-deploying, +.status-dot.is-reviewing, +.status-dot.is-queued { + background: #d99a00; +} + +.status-dot.is-failed, +.status-dot.is-blocked, +.status-dot.is-error { + background: #d43d2a; +} + +.rail-create { + position: relative; +} + +.rail-foot { + margin-top: auto; + display: flex; + flex-direction: column; + gap: 8px; + align-items: center; + position: relative; +} + +.rail-pop { + position: absolute; + left: 54px; + bottom: 0; + width: 260px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + box-shadow: var(--shadow); + padding: 14px; + z-index: 40; + color: var(--ink); +} + +.rail-create .rail-pop { + bottom: auto; + top: 0; +} + +.rail-pop-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; +} + +.rail-pop p { + margin: 6px 0; + color: var(--ink-soft); +} + +.rail-pop code { + font-family: var(--mono); + font-size: 12px; + word-break: break-all; +} + +.rail-pop-row { + display: flex; + align-items: center; + gap: 8px; +} + +.rail-account-email { + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; +} + +/* App tile tints: deterministic pastel per slug hash (tint-0..7). */ +.tint-0 { background: #e8edf5; color: #33405c; } +.tint-1 { background: #e9f2ec; color: #2c4f3a; } +.tint-2 { background: #f5eddf; color: #6b4a1f; } +.tint-3 { background: #f3e4e4; color: #743333; } +.tint-4 { background: #ece7f5; color: #4a3670; } +.tint-5 { background: #e2f0f2; color: #27555c; } +.tint-6 { background: #f2ecdf; color: #6b5320; } +.tint-7 { background: #e7ecef; color: #3c4650; } + +.rail-tile.is-active[class*="tint-"] { + background: var(--panel); + color: var(--ink); +} + +@media (prefers-color-scheme: dark) { + .tint-0 { background: #232c3d; color: #b9c6e2; } + .tint-1 { background: #1e3128; color: #a8d4b8; } + .tint-2 { background: #382e1c; color: #dcc493; } + .tint-3 { background: #3a2222; color: #e0a9a9; } + .tint-4 { background: #2c2440; color: #c2b2e8; } + .tint-5 { background: #1e3033; color: #a3cfd4; } + .tint-6 { background: #35301c; color: #d6c48c; } + .tint-7 { background: #262b31; color: #b6c0cb; } + .rail-tile.is-active[class*="tint-"] { + background: var(--panel); + } +} + +/* --- Sidebar ----------------------------------------------------------- */ + +.side { + position: sticky; + top: 12px; + display: flex; + flex-direction: column; + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px 12px 12px; + min-height: calc(100dvh - 24px); + max-height: calc(100dvh - 24px); + overflow-y: auto; + box-shadow: var(--shadow); +} + +.side-head { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 0 4px 12px; +} + +.side-id { + min-width: 0; + flex: 1; +} + +.side-name { + margin: 0; + font-weight: 700; + font-size: 15px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.side-url { + display: block; + font-size: 12px; + color: var(--ink-muted); + text-decoration: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.side-url:hover { + color: var(--ink); + text-decoration: underline; +} + +.side-label { + margin: 14px 4px 6px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ink-muted); +} + +.side-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.side-item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 8px 10px; + border: 0; + border-radius: 10px; + background: transparent; + color: var(--ink-soft); + font: inherit; + font-size: 13.5px; + cursor: pointer; + text-align: left; + text-decoration: none; +} + +button.side-item:hover { + background: var(--panel-soft); + color: var(--ink); +} + +.side-item.is-active { + background: var(--pill-bg); + color: var(--ink); + font-weight: 600; +} + +.side-item.is-disabled { + cursor: not-allowed; + opacity: 0.75; +} + +.count-badge { + margin-left: auto; + min-width: 22px; + height: 22px; + padding: 0 6px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + background: var(--ink); + color: var(--panel); + font-size: 12px; + font-weight: 700; +} + +.soon-pill { + margin-left: auto; + padding: 2px 8px; + border-radius: 999px; + background: var(--pill-bg); + color: var(--ink-muted); + font-size: 11px; + font-weight: 600; +} + +.side-foot { + margin-top: auto; + padding: 12px 4px 0; + border-top: 1px solid var(--border-soft); +} + +.side-foot-url { + margin: 0 0 6px; + font-size: 12px; + color: var(--ink-muted); +} + +.side-foot-row { + display: flex; + align-items: center; + gap: 8px; +} + +.scrim { + display: none; +} + +/* --- Main -------------------------------------------------------------- */ + +.main { + min-width: 0; + padding: 6px 4px 40px; +} + +.main-head { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 4px 16px; +} + +.main-title { + margin: 0; + font-size: 21px; + font-weight: 700; + letter-spacing: -0.01em; +} + +button.main-menu { + display: none; +} + +.range { + margin-left: auto; + display: inline-flex; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 999px; + padding: 3px; + gap: 2px; +} + +.range-btn { + border: 0; + background: transparent; + color: var(--ink-soft); + font: inherit; + font-size: 12.5px; + font-weight: 600; + padding: 5px 12px; + border-radius: 999px; + cursor: pointer; +} + +.range-btn:hover { + color: var(--ink); +} + +.range-btn.is-active { + background: var(--ink); + color: var(--panel); +} + +.main-body { + display: flex; + flex-direction: column; + gap: 12px; +} + +.stack { + display: flex; + flex-direction: column; + gap: 12px; +} + +.grid-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.card { + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 18px 20px; + box-shadow: var(--shadow); +} + +.card h2 { + margin: 0; + font-size: 14px; + font-weight: 700; +} + +.card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.muted { + color: var(--ink-muted); +} + +.mono { + font-family: var(--mono); + font-size: 12.5px; +} + +.trim { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.wrap { + overflow-wrap: anywhere; + white-space: normal; +} + +.nowrap { + white-space: nowrap; +} + +.link { + color: var(--ink); + text-decoration: underline; + text-underline-offset: 2px; +} + +.link-btn { + border: 0; + background: none; + padding: 0; + font: inherit; + font-size: 13px; + font-weight: 600; + color: var(--ink-soft); + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; +} + +.link-btn:hover { + color: var(--ink); +} + +.btn { + border: 1px solid var(--border); + background: var(--panel); + color: var(--ink); + font: inherit; + font-size: 13px; + font-weight: 600; + padding: 7px 14px; + border-radius: 10px; + cursor: pointer; +} + +.btn:hover:not(:disabled) { + background: var(--panel-soft); +} + +.btn:disabled { + opacity: 0.6; + cursor: default; +} + +.icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: var(--ink-soft); + cursor: pointer; + padding: 0; + flex: none; +} + +.icon-btn:hover { + background: var(--pill-bg); + color: var(--ink); +} + +.menu-row { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + border: 0; + background: transparent; + color: var(--ink); + font: inherit; + font-size: 13px; + padding: 8px 6px; + border-radius: 8px; + cursor: pointer; + text-align: left; +} + +.menu-row:hover { + background: var(--pill-bg); +} + +/* Pills + badges */ + +.pill { + display: inline-flex; + align-items: center; + padding: 2px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + background: var(--pill-bg); + color: var(--ink-soft); + white-space: nowrap; +} + +.pill.is-ok { + background: var(--ok-bg); + color: var(--ok); +} + +.pill.is-info { + background: var(--info-bg); + color: var(--info); +} + +.pill.is-bad { + background: var(--bad-bg); + color: var(--bad); +} + +.pill.is-warn { + background: var(--warn-bg); + color: var(--warn); +} + +.pill.is-muted { + background: var(--pill-bg); + color: var(--ink-muted); +} + +.pill.is-neutral { + background: var(--pill-bg); + color: var(--ink-soft); +} + +.delta-pill { + display: inline-block; + margin-left: 8px; + padding: 1px 8px; + border-radius: 999px; + background: var(--pill-bg); + color: var(--ink-soft); + font-size: 12px; + font-weight: 700; + vertical-align: middle; +} + +/* Stat cards */ + +.stat-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; +} + +.stat-label { + display: flex; + align-items: center; + gap: 7px; + margin: 0 0 8px; + font-size: 13px; + font-weight: 600; + color: var(--ink-soft); +} + +.stat-value { + margin: 0; + font-size: 26px; + font-weight: 700; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; +} + +.stat-sub { + margin: 4px 0 0; + font-size: 12.5px; + color: var(--ink-muted); +} + +/* Charts */ + +.chart-block { + margin: 0; + color: var(--ink); +} + +.chart-block svg { + display: block; + height: auto; +} + +.legend { + display: flex; + gap: 16px; + margin-top: 8px; + font-size: 12px; + color: var(--ink-muted); +} + +.card-head .legend { + margin-top: 0; +} + +.legend-item { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.swatch { + display: inline-block; + width: 14px; + height: 3px; + border-radius: 2px; +} + +.swatch-dark { + background: var(--ink); +} + +.swatch-light { + background: var(--ink-muted); + opacity: 0.5; +} + +.axis-ends { + display: flex; + justify-content: space-between; + margin-top: 4px; + font-size: 11px; + color: var(--ink-muted); +} + +/* Tables */ + +.table-wrap { + overflow-x: auto; + margin: 0 -4px; + padding: 0 4px; +} + +table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +th { + text-align: left; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--ink-muted); + padding: 6px 10px 6px 0; + border-bottom: 1px solid var(--border); + white-space: nowrap; +} + +td { + padding: 8px 10px 8px 0; + border-bottom: 1px solid var(--border-soft); + vertical-align: top; +} + +tr:last-child td { + border-bottom: 0; +} + +/* Lists */ + +.mini-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.mini-row { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + font-size: 13px; +} + +.mini-row .trim { + flex: 1; +} + +/* Deploys */ + +.deploy-row { + display: flex; + align-items: center; + gap: 10px; + font-size: 13px; + min-width: 0; +} + +.deploy-head { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + border: 0; + background: none; + padding: 0; + font: inherit; + color: inherit; + cursor: pointer; + text-align: left; +} + +.deploy-chevron { + margin-left: auto; + color: var(--ink-muted); + display: inline-flex; +} + +.deploy-detail { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--border-soft); + display: flex; + flex-direction: column; + gap: 8px; +} + +.detail-row { + display: flex; + gap: 12px; + font-size: 13px; +} + +.detail-row > :first-child { + flex: none; + width: 90px; +} + +.deploy-message { + margin: 4px 0 0; + padding: 10px 12px; + border-radius: var(--radius-sm); + background: var(--bad-bg); + color: var(--bad); + font-size: 13px; +} + +.def-list { + margin: 12px 0 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +/* Logs */ + +.log-list { + list-style: none; + margin: 0; + padding: 12px; + display: flex; + flex-direction: column; + gap: 2px; + background: var(--panel-soft); + border: 1px solid var(--border-soft); + border-radius: var(--radius-sm); + font-family: var(--mono); + font-size: 12px; + max-height: 480px; + overflow: auto; +} + +.log-line { + display: flex; + gap: 8px; + overflow-wrap: anywhere; +} + +.log-line code { + white-space: pre-wrap; +} + +.log-stream { + flex: none; + color: var(--ink-muted); + user-select: none; +} + +.log-line.is-stderr .log-stream { + color: var(--bad); +} + +/* Skeletons + states */ + +.skeleton-card, +.skeleton-chart, +.skeleton-rail, +.skeleton-side, +.skeleton-main { + border-radius: var(--radius); + background: linear-gradient( + 100deg, + var(--panel-soft) 30%, + var(--border-soft) 50%, + var(--panel-soft) 70% + ); + background-size: 200% 100%; + animation: shimmer 1.4s linear infinite; + border: 1px solid var(--border-soft); +} + +@keyframes shimmer { + to { + background-position: -200% 0; + } +} + +.skeleton-card { + height: 120px; +} + +.skeleton-chart { + height: 280px; +} + +.console-boot { + display: grid; + grid-template-columns: 64px 260px minmax(0, 1fr); + gap: 12px; + min-height: 100dvh; + padding: 12px; +} + +.skeleton-rail { + min-height: calc(100dvh - 24px); +} + +.skeleton-side, +.skeleton-main { + min-height: 60dvh; +} + +.console-error, +.console-empty { + min-height: 100dvh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + text-align: center; + padding: 24px; + background: var(--canvas); + color: var(--ink); +} + +.console-empty h1 { + margin: 8px 0 0; + font-size: 20px; +} + +.console-empty p { + margin: 0; + max-width: 420px; + color: var(--ink-soft); +} + +.console-empty code { + font-family: var(--mono); + font-size: 12.5px; +} + +/* --- Mobile (≤ 768px): rail stays, sidebar becomes a drawer ------------ */ + +@media (width <= 768px) { + .console, + .console.is-collapsed { + grid-template-columns: 56px minmax(0, 1fr); + padding: 8px; + gap: 8px; + } + + .rail { + top: 8px; + min-height: calc(100dvh - 16px); + } + + .rail-tile { + width: 40px; + height: 40px; + } + + .side { + position: fixed; + top: 8px; + bottom: 8px; + left: 64px; + width: min(300px, calc(100dvw - 80px)); + max-height: none; + z-index: 50; + transform: translateX(-120%); + transition: transform 0.18s ease-out; + } + + .side.is-open { + transform: none; + } + + .console.is-collapsed .side { + display: block; + } + + .scrim { + display: block; + position: fixed; + inset: 0; + border: 0; + padding: 0; + background: rgb(0 0 0 / 0.3); + z-index: 40; + cursor: default; + } + + button.main-menu { + display: inline-flex; + } + + button.side-collapse { + display: none; + } + + .stat-grid { + grid-template-columns: 1fr 1fr; + } + + .grid-2 { + grid-template-columns: 1fr; + } + + .console-boot { + grid-template-columns: 56px minmax(0, 1fr); + } + + .console-boot .skeleton-side { + display: none; + } + + .rail-pop { + left: 48px; + } +} + +@media (prefers-reduced-motion: reduce) { + .skeleton-card, + .skeleton-chart, + .skeleton-rail, + .skeleton-side, + .skeleton-main { + animation: none; + } + + .side { + transition: none; + } } diff --git a/web/pages/console/index.astro b/web/pages/console/index.astro index 7ad3a05..61f3ae4 100644 --- a/web/pages/console/index.astro +++ b/web/pages/console/index.astro @@ -1,5 +1,5 @@ --- -import { AppsList } from "./_islands/apps"; +import { Console } from "./_islands/console"; import "./console.css"; --- @@ -9,18 +9,12 @@ import "./console.css"; - + Console · Outer -
-
-

Outer console

- Docs -
-
- -
-
+
+ +