diff --git a/crates/wasmeld-console/web/src/components/ui/empty-state.tsx b/crates/wasmeld-console/web/src/components/feedback/empty-state.tsx similarity index 56% rename from crates/wasmeld-console/web/src/components/ui/empty-state.tsx rename to crates/wasmeld-console/web/src/components/feedback/empty-state.tsx index a34e0df..190c809 100644 --- a/crates/wasmeld-console/web/src/components/ui/empty-state.tsx +++ b/crates/wasmeld-console/web/src/components/feedback/empty-state.tsx @@ -4,10 +4,10 @@ import type { LucideProps } from "lucide-solid"; export function EmptyState(props: { icon: Component; title: string; detail: string }) { const Icon = props.icon; return ( -
+
- {props.title} - {props.detail} + {props.title} + {props.detail}
); } diff --git a/crates/wasmeld-console/web/src/components/layout/page-heading.tsx b/crates/wasmeld-console/web/src/components/layout/page-heading.tsx new file mode 100644 index 0000000..64bbfde --- /dev/null +++ b/crates/wasmeld-console/web/src/components/layout/page-heading.tsx @@ -0,0 +1,19 @@ +import type { JSXElement } from "solid-js"; + +export function PageHeading(props: { + eyebrow: string; + title: string; + description: string; + actions?: JSXElement; +}) { + return ( +
+
+ {props.eyebrow} +

{props.title}

+

{props.description}

+
+ {props.actions &&
{props.actions}
} +
+ ); +} diff --git a/crates/wasmeld-console/web/src/components/layout/page.tsx b/crates/wasmeld-console/web/src/components/layout/page.tsx new file mode 100644 index 0000000..bfa229a --- /dev/null +++ b/crates/wasmeld-console/web/src/components/layout/page.tsx @@ -0,0 +1,18 @@ +import { splitProps, type JSX } from "solid-js"; +import { cn } from "../../lib/cn"; + +export type PageProps = JSX.HTMLAttributes; + +/** Standard constrained content area rendered inside a Page iframe. */ +export function Page(props: PageProps) { + const [local, rest] = splitProps(props, ["class", "children"]); + return ( +
+ {local.children} +
+ ); +} diff --git a/crates/wasmeld-console/web/src/components/services/service-table.tsx b/crates/wasmeld-console/web/src/components/services/service-table.tsx new file mode 100644 index 0000000..52665bf --- /dev/null +++ b/crates/wasmeld-console/web/src/components/services/service-table.tsx @@ -0,0 +1,201 @@ +import { + Box, + Braces, + CircleStop, + Play, + RadioTower, + RefreshCw, + RotateCcw, + Search, + SquareTerminal, +} from "lucide-solid"; +import { For, Show } from "solid-js"; +import { serviceKey, type Service } from "../../lib/model"; +import type { HostSharedState, PageCommand } from "../../sdk/protocol"; +import { EmptyState } from "../feedback/empty-state"; +import { IconButton } from "../ui/button"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table"; +import { StatusBadge } from "./status-badge"; + +export function ServiceTable(props: { + services: Service[]; + state: HostSharedState; + command: (command: PageCommand) => void; + compact?: boolean; +}) { + return ( + 0} + fallback={ + + } + > + + + + 服务 + 状态 + + 制品 + Host 能力 + + 调用 + 延迟 + + 操作 + + + + + + {(service) => { + const key = serviceKey(service); + return ( + + +
+ + + + + + {service.id} + + + + 对外 + + + + {service.revision} + +
+
+ + + + + + {service.artifact} + + + 0} + fallback={} + > + capability.interface) + .join("\n")} + > + + + {service.capabilities[0]?.package}/{service.capabilities[0]?.name} + + 1}> + +{service.capabilities.length - 1} + + + + + + + {service.calls.toLocaleString("zh-CN")} + + + {service.latencyMs === null ? "—" : `${service.latencyMs} ms`} + + +
+ props.command({ type: "open-activate", serviceKey: key })} + > + {props.state.deploymentAction === key ? ( + + ) : ( + + )} + + + props.command({ + type: "service-action", + serviceKey: key, + action: "start", + }) + } + > + + + } + > + props.command({ type: "open-invoke", serviceKey: key })} + > + + + + props.command({ + type: "service-action", + serviceKey: key, + action: "restart", + }) + } + > + + + + props.command({ + type: "service-action", + serviceKey: key, + action: "stop", + }) + } + > + + + +
+
+
+ ); + }} +
+
+
+
+ ); +} diff --git a/crates/wasmeld-console/web/src/components/services/status-badge.tsx b/crates/wasmeld-console/web/src/components/services/status-badge.tsx new file mode 100644 index 0000000..c3730af --- /dev/null +++ b/crates/wasmeld-console/web/src/components/services/status-badge.tsx @@ -0,0 +1,23 @@ +import { cva } from "class-variance-authority"; +import { STATUS_META, type ServiceStatus } from "../../lib/model"; + +const statusBadgeVariants = cva( + "inline-flex items-center gap-1.5 whitespace-nowrap text-xs font-semibold before:size-1.5 before:rounded-full before:bg-current before:content-['']", + { + variants: { + status: { + running: "text-brand", + stopped: "text-muted", + faulted: "text-coral-strong", + }, + }, + }, +); + +export function StatusBadge(props: { status: ServiceStatus }) { + return ( + + {STATUS_META[props.status].label} + + ); +} diff --git a/crates/wasmeld-console/web/src/components/ui/page-heading.tsx b/crates/wasmeld-console/web/src/components/ui/page-heading.tsx deleted file mode 100644 index 1f47c0d..0000000 --- a/crates/wasmeld-console/web/src/components/ui/page-heading.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import type { JSXElement } from "solid-js"; - -export function PageHeading(props: { - eyebrow: string; - title: string; - description: string; - actions?: JSXElement; -}) { - return ( -
-
- {props.eyebrow} -

{props.title}

-

{props.description}

-
- {props.actions &&
{props.actions}
} -
- ); -} diff --git a/crates/wasmeld-console/web/src/components/ui/service-table.tsx b/crates/wasmeld-console/web/src/components/ui/service-table.tsx deleted file mode 100644 index bd3e4b2..0000000 --- a/crates/wasmeld-console/web/src/components/ui/service-table.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import { - Box, - Braces, - CircleStop, - Play, - RadioTower, - RefreshCw, - RotateCcw, - Search, - SquareTerminal, -} from "lucide-solid"; -import { For, Show } from "solid-js"; -import { serviceKey, type Service } from "../../lib/model"; -import type { HostSharedState, PageCommand } from "../../sdk/protocol"; -import { EmptyState } from "./empty-state"; -import { StatusBadge } from "./status-badge"; - -export function ServiceTable(props: { - services: Service[]; - state: HostSharedState; - command: (command: PageCommand) => void; - compact?: boolean; -}) { - return ( - 0} - fallback={ - - } - > -
- - - - - - - - - - - - - - - - - {(service) => { - const key = serviceKey(service); - return ( - - - - - - - - - - - - ); - }} - - -
服务状态制品Host 能力调用延迟 - 操作 -
-
- - - - - - {service.id} - - - - 对外 - - - - {service.revision} - -
-
- - - {service.artifact} - - 0} - fallback={} - > - capability.interface) - .join("\n")} - > - - - {service.capabilities[0]?.package}/{service.capabilities[0]?.name} - - 1}> - +{service.capabilities.length - 1} - - - - {service.calls.toLocaleString("zh-CN")} - {service.latencyMs === null ? "—" : `${service.latencyMs} ms`} - -
- - - props.command({ - type: "service-action", - serviceKey: key, - action: "start", - }) - } - > - - - } - > - - - - -
-
-
-
- ); -} diff --git a/crates/wasmeld-console/web/src/components/ui/status-badge.tsx b/crates/wasmeld-console/web/src/components/ui/status-badge.tsx deleted file mode 100644 index bab7c79..0000000 --- a/crates/wasmeld-console/web/src/components/ui/status-badge.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { STATUS_META, type ServiceStatus } from "../../lib/model"; - -export function StatusBadge(props: { status: ServiceStatus }) { - const meta = () => STATUS_META[props.status]; - return {meta().label}; -} diff --git a/crates/wasmeld-console/web/src/host/app.tsx b/crates/wasmeld-console/web/src/host/app.tsx index 4c0a25c..7e59c61 100644 --- a/crates/wasmeld-console/web/src/host/app.tsx +++ b/crates/wasmeld-console/web/src/host/app.tsx @@ -1,15 +1,4 @@ -import { - Boxes, - FileCode2, - History, - LayoutDashboard, - Package, - Search, - Server, - Settings, - ShieldCheck, - WifiOff, -} from "lucide-solid"; +import { Boxes, Search, ShieldCheck, WifiOff } from "lucide-solid"; import { batch, createEffect, @@ -20,9 +9,7 @@ import { onCleanup, onMount, Show, - type Component, } from "solid-js"; -import type { LucideProps } from "lucide-solid"; import { activateDeployment, changeRuntimeState, @@ -36,8 +23,10 @@ import { type BackendSnapshot, type InvokeResult, } from "../lib/api"; -import { isView, serviceKey, type ConnectionState, type Service, type View } from "../lib/model"; +import { serviceKey, type ConnectionState, type Service } from "../lib/model"; import { servicesFrom } from "../lib/view-state"; +import { isView, PAGE_DEFINITIONS, pageDefinition, type View } from "../pages/registry"; +import { Input } from "../components/ui/input"; import { pageUrl, sendLifecycle, sendState } from "../sdk/host"; import { isPageMessage, @@ -50,28 +39,15 @@ import { DialogLayer, type DialogState } from "./dialogs"; import { FrameCache, type FrameEntry } from "./frame-cache"; import { createBrowserHostTabSync, type HostTabSync } from "./tab-sync"; -const NAVIGATION: Array<{ - id: View; - label: string; - icon: Component; - keepAlive?: boolean; -}> = [ - { id: "overview", label: "运行概览", icon: LayoutDashboard }, - { id: "services", label: "服务版本", icon: Package, keepAlive: true }, - { id: "instances", label: "运行实例", icon: Server }, - { id: "wit-packages", label: "WIT 包", icon: FileCode2 }, - { id: "activity", label: "调用记录", icon: History }, - { id: "settings", label: "运行设置", icon: Settings, keepAlive: true }, -]; -const MAX_KEEP_ALIVE_IFRAMES = 3; +const KEEP_ALIVE_INACTIVE_TTL_MS = 30 * 60 * 1000; export default function HostApp() { // All backend ownership stays in this persistent document. Page iframes are // disposable renderers and can only request typed commands through the SDK. const initial = initialView(); const frameCache = new FrameCache(initial, { - maxKeepAliveEntries: MAX_KEEP_ALIVE_IFRAMES, - shouldKeepAlive: keepsFrameAlive, + inactiveTtlMs: KEEP_ALIVE_INACTIVE_TTL_MS, + shouldKeepAlive: (view) => pageDefinition(view).keepAlive, }); const [view, setView] = createSignal(initial); const [frames, setFrames] = createSignal(frameCache.snapshot()); @@ -90,6 +66,7 @@ export default function HostApp() { let tabSync: HostTabSync | null = null; let applyingSharedHostState = false; let toastTimer: number | undefined; + let frameExpirationTimer: number | undefined; let refreshGeneration = 0; const globalState = createMemo(() => ({ @@ -109,9 +86,7 @@ export default function HostApp() { () => servicesFrom(state()).filter((service) => service.status === "running").length, ); const frameReady = createMemo(() => readyViews().has(view())); - const title = createMemo( - () => NAVIGATION.find((item) => item.id === view())?.label ?? "运行概览", - ); + const title = createMemo(() => pageDefinition(view()).label); function stateFor(targetView: View): HostSharedState { return { ...state(), view: targetView }; @@ -162,7 +137,7 @@ export default function HostApp() { const transition = frameCache.activate(next); const removedViews = new Set(transition.removed.map((entry) => entry.view)); - // Retained frames are paused before being hidden. Transient and LRU-evicted + // Retained frames are paused before being hidden. Transient and expired // frames receive dispose before Solid removes their document from the DOM. if (!removedViews.has(previous)) sendFrameLifecycle(previous, "deactivate"); for (const entry of transition.removed) sendFrameLifecycle(entry.view, "dispose"); @@ -185,6 +160,7 @@ export default function HostApp() { url.searchParams.set("view", next); window.history.pushState({ view: next }, "", url); } + scheduleFrameExpiration(); } function findService(key: string): Service | null { @@ -309,6 +285,32 @@ export default function HostApp() { if (target) sendLifecycle(target, phase); } + function scheduleFrameExpiration() { + if (frameExpirationTimer !== undefined) { + window.clearTimeout(frameExpirationTimer); + frameExpirationTimer = undefined; + } + const delay = frameCache.timeUntilExpiration(); + if (delay === null) return; + frameExpirationTimer = window.setTimeout(expireInactiveFrames, delay); + } + + function expireInactiveFrames() { + frameExpirationTimer = undefined; + const expiration = frameCache.expireInactive(); + if (expiration.removed.length > 0) { + const removedViews = new Set(expiration.removed.map((entry) => entry.view)); + for (const entry of expiration.removed) sendFrameLifecycle(entry.view, "dispose"); + setReadyViews((current) => { + const remaining = new Set(current); + for (const removed of removedViews) remaining.delete(removed); + return remaining; + }); + setFrames(expiration.entries); + } + scheduleFrameExpiration(); + } + const popState = () => { const requested = new URLSearchParams(window.location.search).get("view"); navigate(isView(requested) ? requested : "overview", false); @@ -331,6 +333,7 @@ export default function HostApp() { window.removeEventListener("popstate", popState); tabSync?.close(); if (toastTimer !== undefined) window.clearTimeout(toastTimer); + if (frameExpirationTimer !== undefined) window.clearTimeout(frameExpirationTimer); for (const frameView of frameElements.keys()) sendFrameLifecycle(frameView, "dispose"); }); @@ -409,7 +412,7 @@ export default function HostApp() {