From 9ff3223c4414343ed0ebad27b3a35ca31b6c7f3d Mon Sep 17 00:00:00 2001 From: Maofeng Date: Thu, 30 Jul 2026 15:16:02 +0800 Subject: [PATCH] refactor(console-web): centralize page lifecycle and UI composition Declare navigation, page metadata, lazy loaders, and keep-alive policy in one registry. Retain primary iframe pages until a continuous inactive TTL expires and expose typed show, hide, and unload events through the Page SDK. Move business-aware components out of the UI primitive directory, migrate views and Host dialogs to variant primitives and local Tailwind classes, and remove the global component style layer. Keep native dialogs inside the overlay flex context so they remain centered. Cover iframe expiration, lifecycle dispatch, UI dependency direction, and global style ownership with focused tests. --- .../{ui => feedback}/empty-state.tsx | 6 +- .../src/components/layout/page-heading.tsx | 19 ++ .../web/src/components/layout/page.tsx | 18 ++ .../src/components/services/service-table.tsx | 201 +++++++++++++ .../src/components/services/status-badge.tsx | 23 ++ .../web/src/components/ui/page-heading.tsx | 19 -- .../web/src/components/ui/service-table.tsx | 195 ------------ .../web/src/components/ui/status-badge.tsx | 6 - crates/wasmeld-console/web/src/host/app.tsx | 94 +++--- .../wasmeld-console/web/src/host/dialogs.tsx | 125 ++++---- .../web/src/host/frame-cache.ts | 117 +++++--- crates/wasmeld-console/web/src/lib/model.ts | 22 +- .../web/src/pages/activity/index.tsx | 30 +- .../web/src/pages/instances/index.tsx | 45 +-- crates/wasmeld-console/web/src/pages/main.tsx | 30 +- .../web/src/pages/overview/index.tsx | 95 +++--- .../wasmeld-console/web/src/pages/registry.ts | 106 +++++++ .../web/src/pages/services/index.tsx | 41 +-- .../web/src/pages/settings/index.tsx | 36 ++- crates/wasmeld-console/web/src/pages/types.ts | 8 + .../web/src/pages/wit-packages/index.tsx | 133 +++++---- .../web/src/primitives/create-frame-bridge.ts | 41 ++- crates/wasmeld-console/web/src/sdk/host.ts | 2 +- .../wasmeld-console/web/src/sdk/lifecycle.ts | 124 ++++++++ crates/wasmeld-console/web/src/sdk/page.ts | 11 +- .../wasmeld-console/web/src/sdk/protocol.ts | 3 +- crates/wasmeld-console/web/src/styles/app.css | 280 ------------------ .../web/tests/component-boundaries.test.mjs | 35 +++ .../web/tests/frame-cache.test.mjs | 68 +++-- .../web/tests/page-lifecycle.test.mjs | 75 +++++ 30 files changed, 1136 insertions(+), 872 deletions(-) rename crates/wasmeld-console/web/src/components/{ui => feedback}/empty-state.tsx (56%) create mode 100644 crates/wasmeld-console/web/src/components/layout/page-heading.tsx create mode 100644 crates/wasmeld-console/web/src/components/layout/page.tsx create mode 100644 crates/wasmeld-console/web/src/components/services/service-table.tsx create mode 100644 crates/wasmeld-console/web/src/components/services/status-badge.tsx delete mode 100644 crates/wasmeld-console/web/src/components/ui/page-heading.tsx delete mode 100644 crates/wasmeld-console/web/src/components/ui/service-table.tsx delete mode 100644 crates/wasmeld-console/web/src/components/ui/status-badge.tsx create mode 100644 crates/wasmeld-console/web/src/pages/registry.ts create mode 100644 crates/wasmeld-console/web/src/sdk/lifecycle.ts create mode 100644 crates/wasmeld-console/web/tests/component-boundaries.test.mjs create mode 100644 crates/wasmeld-console/web/tests/page-lifecycle.test.mjs 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() {