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.
This commit is contained in:
+3
-3
@@ -4,10 +4,10 @@ import type { LucideProps } from "lucide-solid";
|
||||
export function EmptyState(props: { icon: Component<LucideProps>; title: string; detail: string }) {
|
||||
const Icon = props.icon;
|
||||
return (
|
||||
<div class="empty-state">
|
||||
<div class="flex min-h-56 flex-col items-center justify-center gap-2 px-6 text-center text-muted">
|
||||
<Icon size={22} />
|
||||
<strong>{props.title}</strong>
|
||||
<span>{props.detail}</span>
|
||||
<strong class="text-sm text-ink">{props.title}</strong>
|
||||
<span class="text-xs">{props.detail}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { JSXElement } from "solid-js";
|
||||
|
||||
export function PageHeading(props: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
actions?: JSXElement;
|
||||
}) {
|
||||
return (
|
||||
<header class="mb-5 flex flex-col justify-between gap-4 sm:flex-row sm:items-end">
|
||||
<div>
|
||||
<span class="text-[11px] font-bold uppercase text-cyan-strong">{props.eyebrow}</span>
|
||||
<h1 class="mt-1 text-2xl font-bold text-ink">{props.title}</h1>
|
||||
<p class="mt-1 max-w-3xl text-sm text-muted">{props.description}</p>
|
||||
</div>
|
||||
{props.actions && <div class="flex shrink-0 items-center gap-2">{props.actions}</div>}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { splitProps, type JSX } from "solid-js";
|
||||
import { cn } from "../../lib/cn";
|
||||
|
||||
export type PageProps = JSX.HTMLAttributes<HTMLElement>;
|
||||
|
||||
/** Standard constrained content area rendered inside a Page iframe. */
|
||||
export function Page(props: PageProps) {
|
||||
const [local, rest] = splitProps(props, ["class", "children"]);
|
||||
return (
|
||||
<main
|
||||
{...rest}
|
||||
data-slot="page"
|
||||
class={cn("mx-auto w-full max-w-[1480px] px-4 py-5 sm:px-6 lg:px-8 lg:py-7", local.class)}
|
||||
>
|
||||
{local.children}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Show
|
||||
when={props.services.length > 0}
|
||||
fallback={
|
||||
<EmptyState icon={Search} title="没有匹配的服务" detail="调整搜索关键词或状态筛选。" />
|
||||
}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>服务</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<Show when={!props.compact}>
|
||||
<TableHead>制品</TableHead>
|
||||
<TableHead>Host 能力</TableHead>
|
||||
</Show>
|
||||
<TableHead>调用</TableHead>
|
||||
<TableHead>延迟</TableHead>
|
||||
<TableHead>
|
||||
<span class="sr-only">操作</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<For each={props.services}>
|
||||
{(service) => {
|
||||
const key = serviceKey(service);
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div class="flex min-w-48 items-center gap-3">
|
||||
<span class="flex size-8 shrink-0 items-center justify-center rounded-md bg-brand-soft text-brand">
|
||||
<Box size={15} />
|
||||
</span>
|
||||
<span class="min-w-0">
|
||||
<span class="flex items-center gap-2">
|
||||
<strong class="truncate">{service.id}</strong>
|
||||
<Show when={service.active}>
|
||||
<span class="inline-flex items-center gap-1 rounded-sm bg-cyan-soft px-1.5 py-0.5 text-[10px] font-semibold text-cyan-strong">
|
||||
<RadioTower size={10} />
|
||||
对外
|
||||
</span>
|
||||
</Show>
|
||||
</span>
|
||||
<small class="mt-0.5 block text-xs text-muted">{service.revision}</small>
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={service.status} />
|
||||
</TableCell>
|
||||
<Show when={!props.compact}>
|
||||
<TableCell
|
||||
class="max-w-48 truncate font-mono text-xs text-muted"
|
||||
title={service.artifact}
|
||||
>
|
||||
{service.artifact}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Show
|
||||
when={service.capabilities.length > 0}
|
||||
fallback={<span class="text-xs text-muted">无</span>}
|
||||
>
|
||||
<span
|
||||
class="inline-flex max-w-56 items-center gap-1.5 text-xs text-muted"
|
||||
title={service.capabilities
|
||||
.map((capability) => capability.interface)
|
||||
.join("\n")}
|
||||
>
|
||||
<Braces size={12} />
|
||||
<span class="truncate">
|
||||
{service.capabilities[0]?.package}/{service.capabilities[0]?.name}
|
||||
</span>
|
||||
<Show when={service.capabilities.length > 1}>
|
||||
<small>+{service.capabilities.length - 1}</small>
|
||||
</Show>
|
||||
</span>
|
||||
</Show>
|
||||
</TableCell>
|
||||
</Show>
|
||||
<TableCell class="font-mono text-xs">
|
||||
{service.calls.toLocaleString("zh-CN")}
|
||||
</TableCell>
|
||||
<TableCell class="font-mono text-xs">
|
||||
{service.latencyMs === null ? "—" : `${service.latencyMs} ms`}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="设为对外版本"
|
||||
title={service.active ? "当前对外版本" : "设为对外版本"}
|
||||
disabled={
|
||||
service.active ||
|
||||
props.state.snapshot?.runtime.status !== "running" ||
|
||||
props.state.deploymentAction !== null
|
||||
}
|
||||
onClick={() => props.command({ type: "open-activate", serviceKey: key })}
|
||||
>
|
||||
{props.state.deploymentAction === key ? (
|
||||
<RefreshCw class="animate-spin" size={15} />
|
||||
) : (
|
||||
<RadioTower size={15} />
|
||||
)}
|
||||
</IconButton>
|
||||
<Show
|
||||
when={service.status === "running"}
|
||||
fallback={
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={`启动 ${service.id}`}
|
||||
title="启动"
|
||||
disabled={props.state.serviceAction !== null}
|
||||
onClick={() =>
|
||||
props.command({
|
||||
type: "service-action",
|
||||
serviceKey: key,
|
||||
action: "start",
|
||||
})
|
||||
}
|
||||
>
|
||||
<Play size={15} />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={`调用 ${service.id}`}
|
||||
title="测试调用"
|
||||
onClick={() => props.command({ type: "open-invoke", serviceKey: key })}
|
||||
>
|
||||
<SquareTerminal size={15} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={`重启 ${service.id}`}
|
||||
title="重启"
|
||||
disabled={props.state.serviceAction !== null}
|
||||
onClick={() =>
|
||||
props.command({
|
||||
type: "service-action",
|
||||
serviceKey: key,
|
||||
action: "restart",
|
||||
})
|
||||
}
|
||||
>
|
||||
<RotateCcw size={15} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
tone="danger"
|
||||
aria-label={`停止 ${service.id}`}
|
||||
title="停止"
|
||||
disabled={props.state.serviceAction !== null}
|
||||
onClick={() =>
|
||||
props.command({
|
||||
type: "service-action",
|
||||
serviceKey: key,
|
||||
action: "stop",
|
||||
})
|
||||
}
|
||||
>
|
||||
<CircleStop size={15} />
|
||||
</IconButton>
|
||||
</Show>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<span data-slot="service-status" class={statusBadgeVariants({ status: props.status })}>
|
||||
{STATUS_META[props.status].label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { JSXElement } from "solid-js";
|
||||
|
||||
export function PageHeading(props: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
actions?: JSXElement;
|
||||
}) {
|
||||
return (
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<span class="eyebrow">{props.eyebrow}</span>
|
||||
<h1>{props.title}</h1>
|
||||
<p>{props.description}</p>
|
||||
</div>
|
||||
{props.actions && <div class="heading-actions">{props.actions}</div>}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Show
|
||||
when={props.services.length > 0}
|
||||
fallback={
|
||||
<EmptyState icon={Search} title="没有匹配的服务" detail="调整搜索关键词或状态筛选。" />
|
||||
}
|
||||
>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>服务</th>
|
||||
<th>状态</th>
|
||||
<Show when={!props.compact}>
|
||||
<th>制品</th>
|
||||
<th>Host 能力</th>
|
||||
</Show>
|
||||
<th>调用</th>
|
||||
<th>延迟</th>
|
||||
<th>
|
||||
<span class="sr-only">操作</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={props.services}>
|
||||
{(service) => {
|
||||
const key = serviceKey(service);
|
||||
return (
|
||||
<tr>
|
||||
<td>
|
||||
<div class="flex min-w-48 items-center gap-3">
|
||||
<span class="flex size-8 shrink-0 items-center justify-center rounded-md bg-brand-soft text-brand">
|
||||
<Box size={15} />
|
||||
</span>
|
||||
<span class="min-w-0">
|
||||
<span class="flex items-center gap-2">
|
||||
<strong class="truncate">{service.id}</strong>
|
||||
<Show when={service.active}>
|
||||
<span class="inline-flex items-center gap-1 rounded-sm bg-cyan-soft px-1.5 py-0.5 text-[10px] font-semibold text-cyan-strong">
|
||||
<RadioTower size={10} />
|
||||
对外
|
||||
</span>
|
||||
</Show>
|
||||
</span>
|
||||
<small class="mt-0.5 block text-xs text-muted">{service.revision}</small>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<StatusBadge status={service.status} />
|
||||
</td>
|
||||
<Show when={!props.compact}>
|
||||
<td class="code max-w-48 truncate text-muted" title={service.artifact}>
|
||||
{service.artifact}
|
||||
</td>
|
||||
<td>
|
||||
<Show
|
||||
when={service.capabilities.length > 0}
|
||||
fallback={<span class="text-xs text-muted">无</span>}
|
||||
>
|
||||
<span
|
||||
class="inline-flex max-w-56 items-center gap-1.5 text-xs text-muted"
|
||||
title={service.capabilities
|
||||
.map((capability) => capability.interface)
|
||||
.join("\n")}
|
||||
>
|
||||
<Braces size={12} />
|
||||
<span class="truncate">
|
||||
{service.capabilities[0]?.package}/{service.capabilities[0]?.name}
|
||||
</span>
|
||||
<Show when={service.capabilities.length > 1}>
|
||||
<small>+{service.capabilities.length - 1}</small>
|
||||
</Show>
|
||||
</span>
|
||||
</Show>
|
||||
</td>
|
||||
</Show>
|
||||
<td class="font-mono text-xs">{service.calls.toLocaleString("zh-CN")}</td>
|
||||
<td class="font-mono text-xs">
|
||||
{service.latencyMs === null ? "—" : `${service.latencyMs} ms`}
|
||||
</td>
|
||||
<td>
|
||||
<div class="row-actions">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="设为对外版本"
|
||||
title={service.active ? "当前对外版本" : "设为对外版本"}
|
||||
disabled={
|
||||
service.active ||
|
||||
props.state.snapshot?.runtime.status !== "running" ||
|
||||
props.state.deploymentAction !== null
|
||||
}
|
||||
onClick={() => props.command({ type: "open-activate", serviceKey: key })}
|
||||
>
|
||||
{props.state.deploymentAction === key ? (
|
||||
<RefreshCw class="spin" size={15} />
|
||||
) : (
|
||||
<RadioTower size={15} />
|
||||
)}
|
||||
</button>
|
||||
<Show
|
||||
when={service.status === "running"}
|
||||
fallback={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`启动 ${service.id}`}
|
||||
title="启动"
|
||||
disabled={props.state.serviceAction !== null}
|
||||
onClick={() =>
|
||||
props.command({
|
||||
type: "service-action",
|
||||
serviceKey: key,
|
||||
action: "start",
|
||||
})
|
||||
}
|
||||
>
|
||||
<Play size={15} />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`调用 ${service.id}`}
|
||||
title="测试调用"
|
||||
onClick={() => props.command({ type: "open-invoke", serviceKey: key })}
|
||||
>
|
||||
<SquareTerminal size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`重启 ${service.id}`}
|
||||
title="重启"
|
||||
disabled={props.state.serviceAction !== null}
|
||||
onClick={() =>
|
||||
props.command({
|
||||
type: "service-action",
|
||||
serviceKey: key,
|
||||
action: "restart",
|
||||
})
|
||||
}
|
||||
>
|
||||
<RotateCcw size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`停止 ${service.id}`}
|
||||
title="停止"
|
||||
disabled={props.state.serviceAction !== null}
|
||||
onClick={() =>
|
||||
props.command({
|
||||
type: "service-action",
|
||||
serviceKey: key,
|
||||
action: "stop",
|
||||
})
|
||||
}
|
||||
>
|
||||
<CircleStop size={15} />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
@@ -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 <span class={`status-badge ${meta().className}`}>{meta().label}</span>;
|
||||
}
|
||||
@@ -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<LucideProps>;
|
||||
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<HostGlobalState>(() => ({
|
||||
@@ -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() {
|
||||
</div>
|
||||
</div>
|
||||
<nav class="flex gap-1 overflow-x-auto px-3 pb-3 lg:block lg:min-h-0 lg:flex-1 lg:space-y-1 lg:overflow-y-auto lg:pb-0">
|
||||
<For each={NAVIGATION}>
|
||||
<For each={PAGE_DEFINITIONS}>
|
||||
{(item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
@@ -456,8 +459,9 @@ export default function HostApp() {
|
||||
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted"
|
||||
size={15}
|
||||
/>
|
||||
<input
|
||||
class="input h-9 pl-9"
|
||||
<Input
|
||||
density="compact"
|
||||
class="pl-9"
|
||||
value={query()}
|
||||
aria-label="搜索"
|
||||
placeholder="搜索服务、制品或事件"
|
||||
@@ -472,7 +476,7 @@ export default function HostApp() {
|
||||
<PageFrame
|
||||
frame={frame}
|
||||
active={view() === frame.view}
|
||||
title={titleFor(frame.view)}
|
||||
title={pageDefinition(frame.view).label}
|
||||
register={(frameView, element) => frameElements.set(frameView, element)}
|
||||
unregister={(frameView, element) => {
|
||||
if (frameElements.get(frameView) === element) frameElements.delete(frameView);
|
||||
@@ -488,11 +492,11 @@ export default function HostApp() {
|
||||
正在加载 {title()}
|
||||
</div>
|
||||
<div
|
||||
class="loading-progress-track"
|
||||
class="h-1 w-40 overflow-hidden rounded-sm bg-line"
|
||||
role="progressbar"
|
||||
aria-label={`${title()}加载进度`}
|
||||
>
|
||||
<span class="loading-progress-indicator" />
|
||||
<span class="block h-full w-[36%] animate-[loading-progress-swing_1s_ease-in-out_infinite_alternate] rounded-sm bg-brand motion-reduce:translate-x-[89%] motion-reduce:animate-none" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -543,14 +547,6 @@ function initialView(): View {
|
||||
return isView(requested) ? requested : "overview";
|
||||
}
|
||||
|
||||
function keepsFrameAlive(view: View): boolean {
|
||||
return NAVIGATION.some((item) => item.id === view && item.keepAlive === true);
|
||||
}
|
||||
|
||||
function titleFor(view: View): string {
|
||||
return NAVIGATION.find((item) => item.id === view)?.label ?? view;
|
||||
}
|
||||
|
||||
function PageFrame(props: {
|
||||
frame: FrameEntry;
|
||||
active: boolean;
|
||||
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
X,
|
||||
} from "lucide-solid";
|
||||
import { createMemo, createSignal, onCleanup, onMount, Show, type JSXElement } from "solid-js";
|
||||
import { Button, IconButton } from "../components/ui/button";
|
||||
import { Field, FieldLabel } from "../components/ui/field";
|
||||
import { Segment, SegmentedControl } from "../components/ui/segmented-control";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
import type { InvokeResult } from "../lib/api";
|
||||
import {
|
||||
formatInvocationOutput,
|
||||
@@ -88,29 +92,38 @@ function DialogFrame(props: {
|
||||
onCleanup(() => window.removeEventListener("keydown", onKeyDown));
|
||||
|
||||
return (
|
||||
<div class="dialog-backdrop" role="presentation">
|
||||
<dialog open class="dialog" aria-label={props.title}>
|
||||
<header class="dialog-header">
|
||||
<div class="dialog-title">
|
||||
<span>{props.icon}</span>
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-[#0d1715]/55 p-3 backdrop-blur-[1px]"
|
||||
role="presentation"
|
||||
>
|
||||
<dialog
|
||||
open
|
||||
class="relative m-0 max-h-[calc(100dvh-24px)] w-full max-w-3xl overflow-auto rounded-md border border-line bg-white p-0 shadow-2xl"
|
||||
aria-label={props.title}
|
||||
>
|
||||
<header class="flex items-start justify-between gap-4 border-b border-line px-5 py-4 sm:px-7 sm:py-5">
|
||||
<div class="flex min-w-0 items-start gap-3">
|
||||
<span class="flex size-10 shrink-0 items-center justify-center rounded-md bg-brand-soft text-brand">
|
||||
{props.icon}
|
||||
</span>
|
||||
<div>
|
||||
<h2>{props.title}</h2>
|
||||
<p>{props.description}</p>
|
||||
<h2 class="text-lg font-bold sm:text-xl">{props.title}</h2>
|
||||
<p class="mt-1 text-sm text-muted">{props.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="icon-btn"
|
||||
type="button"
|
||||
<IconButton
|
||||
aria-label="关闭"
|
||||
title="关闭"
|
||||
disabled={props.closeDisabled}
|
||||
onClick={props.onClose}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</IconButton>
|
||||
</header>
|
||||
<div class="dialog-body">{props.children}</div>
|
||||
<footer class="dialog-footer">{props.footer}</footer>
|
||||
<div class="px-5 py-5 sm:px-7">{props.children}</div>
|
||||
<footer class="flex items-center justify-end gap-2 border-t border-line bg-[#fafbfb] px-5 py-4 sm:px-7">
|
||||
{props.footer}
|
||||
</footer>
|
||||
</dialog>
|
||||
</div>
|
||||
);
|
||||
@@ -159,28 +172,19 @@ function UploadDialog(props: {
|
||||
onClose={props.onClose}
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
type="button"
|
||||
disabled={props.busy}
|
||||
onClick={props.onClose}
|
||||
>
|
||||
<Button disabled={props.busy} onClick={props.onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="button"
|
||||
disabled={props.busy || !file()}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
{props.busy ? <RefreshCw class="spin" size={16} /> : <ShieldCheck size={16} />}
|
||||
</Button>
|
||||
<Button variant="primary" disabled={props.busy || !file()} onClick={() => void submit()}>
|
||||
{props.busy ? <RefreshCw class="animate-spin" size={16} /> : <ShieldCheck size={16} />}
|
||||
{props.busy ? "正在校验" : isWit() ? "校验并发布" : "校验并注册"}
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<label class="upload-zone">
|
||||
<label class="flex min-h-48 cursor-pointer flex-col items-center justify-center gap-2 rounded-md border border-dashed border-line bg-[#fbfcfc] px-5 text-center text-muted transition-colors hover:border-brand hover:bg-brand-soft">
|
||||
<input
|
||||
class="sr-only"
|
||||
type="file"
|
||||
accept={isWit() ? ".wasm,application/wasm" : ".wasmpkg,application/zip"}
|
||||
onChange={(event) => {
|
||||
@@ -197,15 +201,19 @@ function UploadDialog(props: {
|
||||
) : (
|
||||
<CloudUpload size={28} />
|
||||
)}
|
||||
<strong>{file()?.name ?? `选择 ${isWit() ? "WIT Package" : "组件包"}`}</strong>
|
||||
<span>
|
||||
<strong class="text-sm text-ink">
|
||||
{file()?.name ?? `选择 ${isWit() ? "WIT Package" : "组件包"}`}
|
||||
</strong>
|
||||
<span class="text-xs">
|
||||
{file()
|
||||
? `${(file()!.size / 1024).toFixed(1)} KB · 等待校验`
|
||||
: `${isWit() ? ".wasm · 最大 4 MB" : ".wasmpkg · 最大 64 MB"}`}
|
||||
</span>
|
||||
</label>
|
||||
<Show when={error()}>
|
||||
<div class="form-error">{error()}</div>
|
||||
<div class="mt-3 rounded-md border border-coral-strong/30 bg-coral-soft px-3 py-2 text-sm text-coral-strong">
|
||||
{error()}
|
||||
</div>
|
||||
</Show>
|
||||
</DialogFrame>
|
||||
);
|
||||
@@ -226,29 +234,33 @@ function ActivateDialog(props: {
|
||||
onClose={props.onClose}
|
||||
footer={
|
||||
<>
|
||||
<button class="btn btn-secondary" disabled={props.busy} onClick={props.onClose}>
|
||||
<Button disabled={props.busy} onClick={props.onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={props.busy}
|
||||
onClick={() => void props.onActivate(props.dialog.service)}
|
||||
>
|
||||
{props.busy ? <RefreshCw class="spin" size={16} /> : <RadioTower size={16} />}
|
||||
{props.busy ? <RefreshCw class="animate-spin" size={16} /> : <RadioTower size={16} />}
|
||||
{props.busy ? "正在切换" : "确认切换"}
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div class="grid grid-cols-[1fr_auto_1fr] items-center gap-3 rounded-md border border-line bg-canvas p-4">
|
||||
<div>
|
||||
<span class="text-xs text-muted">当前版本</span>
|
||||
<strong class="code mt-1 block">{props.dialog.currentRevision ?? "尚未部署"}</strong>
|
||||
<strong class="mt-1 block font-mono text-xs">
|
||||
{props.dialog.currentRevision ?? "尚未部署"}
|
||||
</strong>
|
||||
</div>
|
||||
<ArrowRight size={18} class="text-muted" />
|
||||
<div>
|
||||
<span class="text-xs text-muted">目标版本</span>
|
||||
<strong class="code mt-1 block text-brand">{props.dialog.service.revision}</strong>
|
||||
<strong class="mt-1 block font-mono text-xs text-brand">
|
||||
{props.dialog.service.revision}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-4 text-sm leading-6 text-muted">
|
||||
@@ -301,37 +313,36 @@ function InvokeDialog(props: {
|
||||
onClose={props.onClose}
|
||||
footer={
|
||||
<>
|
||||
<button class="btn btn-secondary" disabled={running()} onClick={props.onClose}>
|
||||
<Button disabled={running()} onClick={props.onClose}>
|
||||
关闭
|
||||
</button>
|
||||
<button class="btn btn-primary" disabled={running()} onClick={() => void invoke()}>
|
||||
{running() ? <RefreshCw class="spin" size={16} /> : <Play size={16} />}
|
||||
</Button>
|
||||
<Button variant="primary" disabled={running()} onClick={() => void invoke()}>
|
||||
{running() ? <RefreshCw class="animate-spin" size={16} /> : <Play size={16} />}
|
||||
执行调用
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between gap-3">
|
||||
<div class="segmented">
|
||||
<button classList={{ active: format() === "utf8" }} onClick={() => setFormat("utf8")}>
|
||||
<SegmentedControl aria-label="输入格式">
|
||||
<Segment active={format() === "utf8"} onClick={() => setFormat("utf8")}>
|
||||
UTF-8
|
||||
</button>
|
||||
<button classList={{ active: format() === "hex" }} onClick={() => setFormat("hex")}>
|
||||
</Segment>
|
||||
<Segment active={format() === "hex"} onClick={() => setFormat("hex")}>
|
||||
HEX
|
||||
</button>
|
||||
</div>
|
||||
<span class="code text-muted">{inputBytes()} B</span>
|
||||
</Segment>
|
||||
</SegmentedControl>
|
||||
<span class="font-mono text-xs text-muted">{inputBytes()} B</span>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span>输入</span>
|
||||
<textarea
|
||||
class="textarea"
|
||||
<Field>
|
||||
<FieldLabel>输入</FieldLabel>
|
||||
<Textarea
|
||||
value={input()}
|
||||
spellcheck={false}
|
||||
placeholder={format() === "utf8" ? "输入请求内容" : "00 ff a1"}
|
||||
onInput={(event) => setInput(event.currentTarget.value)}
|
||||
/>
|
||||
</label>
|
||||
</Field>
|
||||
<div class="mt-4">
|
||||
<div class="mb-1.5 flex items-center justify-between">
|
||||
<span class="text-xs font-semibold text-muted">输出</span>
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
import type { View } from "../lib/model";
|
||||
import type { View } from "../pages/registry";
|
||||
|
||||
export type FrameEntry = {
|
||||
view: View;
|
||||
instance: number;
|
||||
keepAlive: boolean;
|
||||
lastUsed: number;
|
||||
/**
|
||||
* Time at which this document became hidden, using the cache clock.
|
||||
*
|
||||
* Active and transient documents use `null`. Measuring from deactivation
|
||||
* prevents a page that has been visible for a long time from expiring as
|
||||
* soon as the user navigates away.
|
||||
*/
|
||||
inactiveSince: number | null;
|
||||
};
|
||||
|
||||
export type FrameCacheOptions = {
|
||||
/**
|
||||
* Maximum number of keep-alive documents, including the active document.
|
||||
*
|
||||
* A non-keep-alive active document may temporarily exist in addition to this
|
||||
* limit. The active document is never evicted; inactive entries are removed
|
||||
* from least to most recently used.
|
||||
* How long a keep-alive document may remain continuously hidden.
|
||||
*/
|
||||
maxKeepAliveEntries: number;
|
||||
inactiveTtlMs: number;
|
||||
shouldKeepAlive: (view: View) => boolean;
|
||||
/** Injectable monotonic clock for deterministic tests. */
|
||||
now?: () => number;
|
||||
};
|
||||
|
||||
export type FrameTransition = {
|
||||
@@ -25,24 +30,30 @@ export type FrameTransition = {
|
||||
activated: FrameEntry;
|
||||
};
|
||||
|
||||
export type FrameExpiration = Omit<FrameTransition, "activated">;
|
||||
|
||||
/**
|
||||
* Owns the bounded set of iframe document identities.
|
||||
* Owns iframe document identities and expires retained, hidden documents.
|
||||
*
|
||||
* Entries retain object identity between snapshots so Solid's `<For>` keeps
|
||||
* the corresponding iframe DOM node alive. `instance` is never reused, which
|
||||
* makes a recreated document observable in its URL and prevents stale browser
|
||||
* history or service-worker responses from masquerading as a retained frame.
|
||||
*
|
||||
* Primary pages may all opt into retention because their set is finite.
|
||||
* Transient/detail pages opt out and are removed immediately after navigation.
|
||||
* Retained pages are released only after a continuous inactive interval, not
|
||||
* because another primary page happened to be opened.
|
||||
*/
|
||||
export class FrameCache {
|
||||
readonly #options: FrameCacheOptions;
|
||||
readonly #entries = new Map<View, FrameEntry>();
|
||||
#active: View;
|
||||
#nextInstance = 1;
|
||||
#clock = 0;
|
||||
|
||||
constructor(initialView: View, options: FrameCacheOptions) {
|
||||
if (!Number.isInteger(options.maxKeepAliveEntries) || options.maxKeepAliveEntries < 0) {
|
||||
throw new RangeError("maxKeepAliveEntries must be a non-negative integer");
|
||||
if (!Number.isFinite(options.inactiveTtlMs) || options.inactiveTtlMs <= 0) {
|
||||
throw new RangeError("inactiveTtlMs must be a positive finite number");
|
||||
}
|
||||
this.#options = options;
|
||||
this.#active = initialView;
|
||||
@@ -60,27 +71,29 @@ export class FrameCache {
|
||||
activate(view: View): FrameTransition {
|
||||
const removed: FrameEntry[] = [];
|
||||
const previous = this.#entries.get(this.#active);
|
||||
const now = this.#now();
|
||||
|
||||
if (previous && previous.view !== view && !previous.keepAlive) {
|
||||
this.#entries.delete(previous.view);
|
||||
removed.push(previous);
|
||||
if (previous && previous.view !== view) {
|
||||
if (previous.keepAlive) {
|
||||
previous.inactiveSince = now;
|
||||
} else {
|
||||
this.#entries.delete(previous.view);
|
||||
removed.push(previous);
|
||||
}
|
||||
}
|
||||
|
||||
// Prune before activation so revisiting an already-expired page creates a
|
||||
// fresh iframe instead of reviving stale document state.
|
||||
removed.push(...this.#removeExpired(now));
|
||||
|
||||
let activated = this.#entries.get(view);
|
||||
if (!activated) {
|
||||
activated = this.#create(view);
|
||||
this.#entries.set(view, activated);
|
||||
}
|
||||
activated.lastUsed = ++this.#clock;
|
||||
activated.inactiveSince = null;
|
||||
this.#active = view;
|
||||
|
||||
while (this.#keepAliveCount() > this.#options.maxKeepAliveEntries) {
|
||||
const candidate = this.#oldestInactiveKeepAlive();
|
||||
if (!candidate) break;
|
||||
this.#entries.delete(candidate.view);
|
||||
removed.push(candidate);
|
||||
}
|
||||
|
||||
return {
|
||||
entries: this.snapshot(),
|
||||
removed,
|
||||
@@ -88,29 +101,63 @@ export class FrameCache {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes retained documents whose continuous hidden interval has elapsed.
|
||||
*
|
||||
* Host calls this from a timer and sends `dispose` before applying the
|
||||
* returned snapshot to the DOM.
|
||||
*/
|
||||
expireInactive(): FrameExpiration {
|
||||
return {
|
||||
removed: this.#removeExpired(this.#now()),
|
||||
entries: this.snapshot(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the delay until the next hidden document expires.
|
||||
*
|
||||
* `null` means no retained page is currently hidden. Recomputing the delay
|
||||
* after every navigation and expiration avoids one interval timer per page.
|
||||
*/
|
||||
timeUntilExpiration(): number | null {
|
||||
const now = this.#now();
|
||||
let delay: number | null = null;
|
||||
for (const entry of this.#entries.values()) {
|
||||
if (!entry.keepAlive || entry.inactiveSince === null) continue;
|
||||
const remaining = Math.max(0, entry.inactiveSince + this.#options.inactiveTtlMs - now);
|
||||
if (delay === null || remaining < delay) delay = remaining;
|
||||
}
|
||||
return delay;
|
||||
}
|
||||
|
||||
#create(view: View): FrameEntry {
|
||||
return {
|
||||
view,
|
||||
instance: this.#nextInstance++,
|
||||
keepAlive: this.#options.shouldKeepAlive(view),
|
||||
lastUsed: ++this.#clock,
|
||||
inactiveSince: null,
|
||||
};
|
||||
}
|
||||
|
||||
#keepAliveCount(): number {
|
||||
let count = 0;
|
||||
#removeExpired(now: number): FrameEntry[] {
|
||||
const removed: FrameEntry[] = [];
|
||||
for (const entry of this.#entries.values()) {
|
||||
if (entry.keepAlive) count += 1;
|
||||
if (
|
||||
entry.view === this.#active ||
|
||||
!entry.keepAlive ||
|
||||
entry.inactiveSince === null ||
|
||||
now - entry.inactiveSince < this.#options.inactiveTtlMs
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
this.#entries.delete(entry.view);
|
||||
removed.push(entry);
|
||||
}
|
||||
return count;
|
||||
return removed;
|
||||
}
|
||||
|
||||
#oldestInactiveKeepAlive(): FrameEntry | undefined {
|
||||
let oldest: FrameEntry | undefined;
|
||||
for (const entry of this.#entries.values()) {
|
||||
if (!entry.keepAlive || entry.view === this.#active) continue;
|
||||
if (!oldest || entry.lastUsed < oldest.lastUsed) oldest = entry;
|
||||
}
|
||||
return oldest;
|
||||
#now(): number {
|
||||
return this.#options.now?.() ?? performance.now();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
import type { BackendEvent, BackendService } from "./api";
|
||||
|
||||
export const VIEWS = [
|
||||
"overview",
|
||||
"services",
|
||||
"instances",
|
||||
"wit-packages",
|
||||
"activity",
|
||||
"settings",
|
||||
] as const;
|
||||
|
||||
export type View = (typeof VIEWS)[number];
|
||||
export type ConnectionState = "connecting" | "online" | "offline";
|
||||
export type ServiceStatus = "running" | "stopped" | "faulted";
|
||||
export type EventTone = "success" | "warning" | "danger" | "neutral";
|
||||
@@ -47,16 +37,12 @@ export type FormattedInvocationOutput = {
|
||||
automatic: boolean;
|
||||
};
|
||||
|
||||
export const STATUS_META: Record<ServiceStatus, { label: string; className: string }> = {
|
||||
running: { label: "运行中", className: "status-running" },
|
||||
stopped: { label: "已停止", className: "status-stopped" },
|
||||
faulted: { label: "故障", className: "status-faulted" },
|
||||
export const STATUS_META: Record<ServiceStatus, { label: string }> = {
|
||||
running: { label: "运行中" },
|
||||
stopped: { label: "已停止" },
|
||||
faulted: { label: "故障" },
|
||||
};
|
||||
|
||||
export function isView(value: string | null): value is View {
|
||||
return VIEWS.includes(value as View);
|
||||
}
|
||||
|
||||
export function serviceKey(service: Pick<Service, "id" | "revision">): string {
|
||||
return `${service.id}@${service.revision}`;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { History, RefreshCw } from "lucide-solid";
|
||||
import { createMemo, For, Show } from "solid-js";
|
||||
import { EmptyState } from "../../components/ui/empty-state";
|
||||
import { PageHeading } from "../../components/ui/page-heading";
|
||||
import { createMemo, For, onCleanup, Show } from "solid-js";
|
||||
import { EmptyState } from "../../components/feedback/empty-state";
|
||||
import { Page } from "../../components/layout/page";
|
||||
import { PageHeading } from "../../components/layout/page-heading";
|
||||
import { IconButton } from "../../components/ui/button";
|
||||
import { Card } from "../../components/ui/card";
|
||||
import { eventsFrom } from "../../lib/view-state";
|
||||
import type { PageProps } from "../types";
|
||||
|
||||
@@ -13,6 +16,11 @@ const toneClass = {
|
||||
};
|
||||
|
||||
export default function ActivityPage(props: PageProps) {
|
||||
// A retained Activity iframe may have been hidden for a while. Request a
|
||||
// fresh snapshot whenever it becomes visible instead of waiting for the
|
||||
// Host's next background poll.
|
||||
onCleanup(props.lifecycle.onShow(() => props.command({ type: "refresh" })));
|
||||
|
||||
const events = createMemo(() => {
|
||||
const query = props.state()?.query.trim().toLowerCase() ?? "";
|
||||
return eventsFrom(props.state()).filter(
|
||||
@@ -24,18 +32,22 @@ export default function ActivityPage(props: PageProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<main class="page">
|
||||
<Page>
|
||||
<PageHeading
|
||||
eyebrow="EVENTS"
|
||||
title="调用记录"
|
||||
description="最近 256 条持久化生命周期与调用事件。"
|
||||
actions={
|
||||
<button class="icon-btn" title="刷新" onClick={() => props.command({ type: "refresh" })}>
|
||||
<IconButton
|
||||
aria-label="刷新"
|
||||
title="刷新"
|
||||
onClick={() => props.command({ type: "refresh" })}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
<section class="panel">
|
||||
<Card as="section">
|
||||
<Show
|
||||
when={events().length > 0}
|
||||
fallback={
|
||||
@@ -55,7 +67,7 @@ export default function ActivityPage(props: PageProps) {
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
</main>
|
||||
</Card>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Cpu, RefreshCw, Server, SquareTerminal } from "lucide-solid";
|
||||
import { createMemo, For, Show } from "solid-js";
|
||||
import { EmptyState } from "../../components/ui/empty-state";
|
||||
import { PageHeading } from "../../components/ui/page-heading";
|
||||
import { StatusBadge } from "../../components/ui/status-badge";
|
||||
import { EmptyState } from "../../components/feedback/empty-state";
|
||||
import { Page } from "../../components/layout/page";
|
||||
import { PageHeading } from "../../components/layout/page-heading";
|
||||
import { StatusBadge } from "../../components/services/status-badge";
|
||||
import { Button, IconButton } from "../../components/ui/button";
|
||||
import { Card } from "../../components/ui/card";
|
||||
import { serviceKey } from "../../lib/model";
|
||||
import { servicesFrom } from "../../lib/view-state";
|
||||
import type { PageProps } from "../types";
|
||||
@@ -14,29 +17,33 @@ export default function InstancesPage(props: PageProps) {
|
||||
);
|
||||
|
||||
return (
|
||||
<main class="page">
|
||||
<Page>
|
||||
<PageHeading
|
||||
eyebrow="ACTORS"
|
||||
title="运行实例"
|
||||
description={`${running()} 个 Actor 保持独立 Store 与串行 mailbox。`}
|
||||
actions={
|
||||
<button class="icon-btn" title="刷新" onClick={() => props.command({ type: "refresh" })}>
|
||||
<IconButton
|
||||
aria-label="刷新"
|
||||
title="刷新"
|
||||
onClick={() => props.command({ type: "refresh" })}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
<Show
|
||||
when={services().length > 0}
|
||||
fallback={
|
||||
<div class="panel">
|
||||
<Card>
|
||||
<EmptyState icon={Server} title="暂无实例" detail="先注册一个组件包。" />
|
||||
</div>
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
<section class="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
<For each={services()}>
|
||||
{(service) => (
|
||||
<article class="panel p-5">
|
||||
<Card as="article" padding="medium">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<span class="flex size-10 shrink-0 items-center justify-center rounded-md bg-cyan-soft text-cyan-strong">
|
||||
@@ -44,7 +51,9 @@ export default function InstancesPage(props: PageProps) {
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<strong class="block truncate">{service.id}</strong>
|
||||
<span class="code mt-1 block truncate text-muted">{service.revision}</span>
|
||||
<span class="mt-1 block truncate font-mono text-xs text-muted">
|
||||
{service.revision}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={service.status} />
|
||||
@@ -71,8 +80,8 @@ export default function InstancesPage(props: PageProps) {
|
||||
</dl>
|
||||
<div class="mt-5 flex justify-end gap-2">
|
||||
<Show when={service.status === "running"}>
|
||||
<button
|
||||
class="btn btn-secondary h-9"
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() =>
|
||||
props.command({
|
||||
type: "open-invoke",
|
||||
@@ -82,10 +91,10 @@ export default function InstancesPage(props: PageProps) {
|
||||
>
|
||||
<SquareTerminal size={15} />
|
||||
调用
|
||||
</button>
|
||||
</Button>
|
||||
</Show>
|
||||
<button
|
||||
class="btn btn-secondary h-9"
|
||||
<Button
|
||||
size="small"
|
||||
disabled={props.state()?.serviceAction !== null}
|
||||
onClick={() =>
|
||||
props.command({
|
||||
@@ -96,13 +105,13 @@ export default function InstancesPage(props: PageProps) {
|
||||
}
|
||||
>
|
||||
{service.status === "running" ? "停止" : "启动"}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
</Card>
|
||||
)}
|
||||
</For>
|
||||
</section>
|
||||
</Show>
|
||||
</main>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,9 @@
|
||||
import { AlertTriangle, LoaderCircle } from "lucide-solid";
|
||||
import { lazy, Show, Suspense, type Component } from "solid-js";
|
||||
import { lazy, Show, Suspense } from "solid-js";
|
||||
import { render } from "solid-js/web";
|
||||
import { isView, type View } from "../lib/model";
|
||||
import { createFrameBridge } from "../primitives/create-frame-bridge";
|
||||
import "../styles/app.css";
|
||||
import type { PageProps } from "./types";
|
||||
|
||||
// Each view is a dynamic import so a freshly-created iframe evaluates only the
|
||||
// page it owns. Destroying that iframe releases the chunk's runtime objects.
|
||||
const pages: Record<View, Component<PageProps>> = {
|
||||
overview: lazy(() => import("./overview")),
|
||||
services: lazy(() => import("./services")),
|
||||
instances: lazy(() => import("./instances")),
|
||||
"wit-packages": lazy(() => import("./wit-packages")),
|
||||
activity: lazy(() => import("./activity")),
|
||||
settings: lazy(() => import("./settings")),
|
||||
};
|
||||
import { isView, pageDefinition } from "./registry";
|
||||
|
||||
function FramePage() {
|
||||
const requested = new URLSearchParams(window.location.search).get("view");
|
||||
@@ -36,17 +24,23 @@ function FramePage() {
|
||||
>
|
||||
{(activeView) => {
|
||||
const bridge = createFrameBridge(activeView());
|
||||
const Page = pages[activeView()];
|
||||
document.title = `${activeView()} · Wasmeld`;
|
||||
const definition = pageDefinition(activeView());
|
||||
const Page = lazy(definition.load);
|
||||
document.title = `${definition.label} · Wasmeld`;
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<main class="flex min-h-dvh items-center justify-center text-muted">
|
||||
<LoaderCircle class="spin" size={24} />
|
||||
<LoaderCircle class="animate-spin" size={24} />
|
||||
</main>
|
||||
}
|
||||
>
|
||||
<Page state={bridge.state} active={bridge.active} command={bridge.command} />
|
||||
<Page
|
||||
state={bridge.state}
|
||||
active={bridge.active}
|
||||
lifecycle={bridge.lifecycle}
|
||||
command={bridge.command}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -11,8 +11,11 @@ import {
|
||||
Zap,
|
||||
} from "lucide-solid";
|
||||
import { createMemo, For, Show, type JSXElement } from "solid-js";
|
||||
import { PageHeading } from "../../components/ui/page-heading";
|
||||
import { ServiceTable } from "../../components/ui/service-table";
|
||||
import { Page } from "../../components/layout/page";
|
||||
import { PageHeading } from "../../components/layout/page-heading";
|
||||
import { ServiceTable } from "../../components/services/service-table";
|
||||
import { Button, IconButton } from "../../components/ui/button";
|
||||
import { Card, CardHeader } from "../../components/ui/card";
|
||||
import { eventsFrom, servicesFrom } from "../../lib/view-state";
|
||||
import { formatDuration } from "../../lib/model";
|
||||
import type { PageProps } from "../types";
|
||||
@@ -29,7 +32,7 @@ export default function OverviewPage(props: PageProps) {
|
||||
const errors = createMemo(() => services().reduce((total, service) => total + service.errors, 0));
|
||||
|
||||
return (
|
||||
<main class="page">
|
||||
<Page>
|
||||
<Show when={props.state()} fallback={<OverviewSkeleton />}>
|
||||
{(state) => (
|
||||
<>
|
||||
@@ -39,28 +42,28 @@ export default function OverviewPage(props: PageProps) {
|
||||
description={`${services().length} 个已注册版本,${running()} 个 Actor 正在运行。`}
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
class="icon-btn"
|
||||
type="button"
|
||||
<IconButton
|
||||
aria-label="刷新"
|
||||
title="刷新"
|
||||
onClick={() => props.command({ type: "refresh" })}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="button"
|
||||
</IconButton>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => props.command({ type: "open-register" })}
|
||||
>
|
||||
<CloudUpload size={16} />
|
||||
注册组件
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<section class="metric-grid" aria-label="关键指标">
|
||||
<section
|
||||
class="mb-5 grid grid-cols-1 border-l border-t border-line sm:grid-cols-2 xl:grid-cols-4"
|
||||
aria-label="关键指标"
|
||||
>
|
||||
<Metric
|
||||
icon={<Package size={18} />}
|
||||
tone="bg-cyan-soft text-cyan-strong"
|
||||
@@ -91,7 +94,10 @@ export default function OverviewPage(props: PageProps) {
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="panel mb-5 grid lg:grid-cols-[minmax(280px,1.4fr)_repeat(3,minmax(130px,1fr))]">
|
||||
<Card
|
||||
as="section"
|
||||
class="mb-5 grid lg:grid-cols-[minmax(280px,1.4fr)_repeat(3,minmax(130px,1fr))]"
|
||||
>
|
||||
<div class="flex items-center gap-3 border-b border-line px-5 py-4 lg:border-b-0 lg:border-r">
|
||||
<span class="flex size-10 items-center justify-center rounded-md bg-brand-soft text-brand">
|
||||
<Cpu size={18} />
|
||||
@@ -108,32 +114,33 @@ export default function OverviewPage(props: PageProps) {
|
||||
<Show
|
||||
when={state().snapshot?.runtime.status === "running"}
|
||||
fallback={
|
||||
<button
|
||||
class="icon-btn"
|
||||
<IconButton
|
||||
aria-label="启动 Runtime"
|
||||
title="启动 Runtime"
|
||||
disabled={state().runtimeAction !== null}
|
||||
onClick={() => props.command({ type: "runtime-action", action: "start" })}
|
||||
>
|
||||
<Play size={15} />
|
||||
</button>
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<button
|
||||
class="icon-btn"
|
||||
<IconButton
|
||||
aria-label="重启 Runtime"
|
||||
title="重启 Runtime"
|
||||
disabled={state().runtimeAction !== null}
|
||||
onClick={() => props.command({ type: "runtime-action", action: "restart" })}
|
||||
>
|
||||
<RotateCcw size={15} />
|
||||
</button>
|
||||
<button
|
||||
class="icon-btn text-coral-strong"
|
||||
</IconButton>
|
||||
<IconButton
|
||||
tone="danger"
|
||||
aria-label="停止 Runtime"
|
||||
title="停止 Runtime"
|
||||
disabled={state().runtimeAction !== null}
|
||||
onClick={() => props.command({ type: "runtime-action", action: "stop" })}
|
||||
>
|
||||
<CircleStop size={15} />
|
||||
</button>
|
||||
</IconButton>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
@@ -156,32 +163,32 @@ export default function OverviewPage(props: PageProps) {
|
||||
}
|
||||
detail="当前 Runtime"
|
||||
/>
|
||||
</section>
|
||||
</Card>
|
||||
|
||||
<div class="grid gap-5 xl:grid-cols-[minmax(0,1.7fr)_minmax(300px,0.8fr)]">
|
||||
<section class="panel min-w-0">
|
||||
<div class="panel-header">
|
||||
<Card as="section" class="min-w-0">
|
||||
<CardHeader>
|
||||
<div>
|
||||
<h2>服务状态</h2>
|
||||
<p>已注册版本及当前 Actor 状态</p>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-secondary h-9"
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => props.command({ type: "navigate", view: "services" })}
|
||||
>
|
||||
查看全部
|
||||
</button>
|
||||
</div>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<ServiceTable
|
||||
services={services().slice(0, 5)}
|
||||
state={state()}
|
||||
command={props.command}
|
||||
compact
|
||||
/>
|
||||
</section>
|
||||
</Card>
|
||||
|
||||
<aside class="panel">
|
||||
<div class="panel-header">
|
||||
<Card as="aside">
|
||||
<CardHeader>
|
||||
<div>
|
||||
<h2>最近事件</h2>
|
||||
<p>生命周期与调用结果</p>
|
||||
@@ -190,7 +197,7 @@ export default function OverviewPage(props: PageProps) {
|
||||
<span class="size-1.5 rounded-full bg-brand" />
|
||||
LIVE
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<div class="divide-y divide-line">
|
||||
<For
|
||||
each={events().slice(0, 6)}
|
||||
@@ -207,12 +214,12 @@ export default function OverviewPage(props: PageProps) {
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</aside>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</main>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -224,13 +231,15 @@ function Metric(props: {
|
||||
note: string;
|
||||
}) {
|
||||
return (
|
||||
<article class="metric">
|
||||
<div class={`metric-icon ${props.tone}`}>{props.icon}</div>
|
||||
<div class="metric-copy">
|
||||
<span>{props.label}</span>
|
||||
<strong>{props.value}</strong>
|
||||
<article class="flex min-h-24 items-center gap-3 border-b border-r border-line bg-white px-4 py-3">
|
||||
<div class={`flex size-10 shrink-0 items-center justify-center rounded-md ${props.tone}`}>
|
||||
{props.icon}
|
||||
</div>
|
||||
<small>{props.note}</small>
|
||||
<div class="min-w-0 flex-1">
|
||||
<span class="block text-xs text-muted">{props.label}</span>
|
||||
<strong class="mt-1 block text-2xl font-bold">{props.value}</strong>
|
||||
</div>
|
||||
<small class="self-end whitespace-nowrap pb-1 text-[11px] text-muted">{props.note}</small>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -248,8 +257,8 @@ function RuntimeStat(props: { label: string; value: string; detail: string }) {
|
||||
function OverviewSkeleton() {
|
||||
return (
|
||||
<div class="space-y-5 py-5">
|
||||
<div class="skeleton-line w-36" />
|
||||
<div class="skeleton-line h-8 w-64" />
|
||||
<div class="h-3 w-36 animate-pulse rounded-sm bg-[#dfe6e4]" />
|
||||
<div class="h-8 w-64 animate-pulse rounded-sm bg-[#dfe6e4]" />
|
||||
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<For each={[1, 2, 3, 4]}>{() => <div class="h-24 animate-pulse bg-white" />}</For>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
FileCode2,
|
||||
History,
|
||||
LayoutDashboard,
|
||||
Package,
|
||||
Server,
|
||||
Settings,
|
||||
type LucideProps,
|
||||
} from "lucide-solid";
|
||||
import type { Component } from "solid-js";
|
||||
import type { PageProps } from "./types";
|
||||
|
||||
type PageModule = {
|
||||
default: Component<PageProps>;
|
||||
};
|
||||
|
||||
type PageDefinition = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: Component<LucideProps>;
|
||||
/**
|
||||
* Requests that Host retain this page's iframe after navigation.
|
||||
*
|
||||
* Primary navigation pages normally opt in; detail and short-lived workflow
|
||||
* pages should opt out. This is still a preference rather than ownership of
|
||||
* the iframe lifetime: Host expires documents that remain hidden beyond its
|
||||
* inactivity TTL. A retained page must pause background work while
|
||||
* `active()` is false and release all resources when disposed.
|
||||
*/
|
||||
keepAlive: boolean;
|
||||
/**
|
||||
* Loads only this page's code inside the iframe document.
|
||||
*
|
||||
* Keep this as a dynamic import. Turning it into a static import would make
|
||||
* every iframe evaluate every page and weaken the document lifecycle
|
||||
* boundary used to release page-owned memory.
|
||||
*/
|
||||
load: () => Promise<PageModule>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The single source of truth for Wasmeld Console pages.
|
||||
*
|
||||
* Add a page here after creating `src/pages/<id>/index.tsx`. Host navigation,
|
||||
* route validation, iframe titles, keep-alive policy and page module loading
|
||||
* are all derived from this registry, so they cannot drift independently.
|
||||
*/
|
||||
export const PAGE_DEFINITIONS = [
|
||||
{
|
||||
id: "overview",
|
||||
label: "运行概览",
|
||||
icon: LayoutDashboard,
|
||||
keepAlive: true,
|
||||
load: () => import("./overview"),
|
||||
},
|
||||
{
|
||||
id: "services",
|
||||
label: "服务版本",
|
||||
icon: Package,
|
||||
keepAlive: true,
|
||||
load: () => import("./services"),
|
||||
},
|
||||
{
|
||||
id: "instances",
|
||||
label: "运行实例",
|
||||
icon: Server,
|
||||
keepAlive: true,
|
||||
load: () => import("./instances"),
|
||||
},
|
||||
{
|
||||
id: "wit-packages",
|
||||
label: "WIT 包",
|
||||
icon: FileCode2,
|
||||
keepAlive: true,
|
||||
load: () => import("./wit-packages"),
|
||||
},
|
||||
{
|
||||
id: "activity",
|
||||
label: "调用记录",
|
||||
icon: History,
|
||||
keepAlive: true,
|
||||
load: () => import("./activity"),
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
label: "运行设置",
|
||||
icon: Settings,
|
||||
keepAlive: true,
|
||||
load: () => import("./settings"),
|
||||
},
|
||||
] as const satisfies readonly PageDefinition[];
|
||||
|
||||
export type View = (typeof PAGE_DEFINITIONS)[number]["id"];
|
||||
|
||||
export const VIEWS: readonly View[] = PAGE_DEFINITIONS.map((page) => page.id);
|
||||
|
||||
const PAGE_BY_ID = new Map<View, PageDefinition>(PAGE_DEFINITIONS.map((page) => [page.id, page]));
|
||||
|
||||
export function isView(value: string | null): value is View {
|
||||
return value !== null && PAGE_BY_ID.has(value as View);
|
||||
}
|
||||
|
||||
export function pageDefinition(view: View): PageDefinition {
|
||||
// `View` is derived from PAGE_DEFINITIONS, so every valid value has an entry.
|
||||
return PAGE_BY_ID.get(view)!;
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import { CloudUpload, Filter, RefreshCw } from "lucide-solid";
|
||||
import { createMemo, createSignal, Show } from "solid-js";
|
||||
import { PageHeading } from "../../components/ui/page-heading";
|
||||
import { ServiceTable } from "../../components/ui/service-table";
|
||||
import { Page } from "../../components/layout/page";
|
||||
import { PageHeading } from "../../components/layout/page-heading";
|
||||
import { ServiceTable } from "../../components/services/service-table";
|
||||
import { Button, IconButton } from "../../components/ui/button";
|
||||
import { Card, CardHeader } from "../../components/ui/card";
|
||||
import { Select } from "../../components/ui/select";
|
||||
import { servicesFrom } from "../../lib/view-state";
|
||||
import type { ServiceStatus } from "../../lib/model";
|
||||
import type { PageProps } from "../types";
|
||||
@@ -22,7 +26,7 @@ export default function ServicesPage(props: PageProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<main class="page">
|
||||
<Page>
|
||||
<PageHeading
|
||||
eyebrow="COMPONENTS"
|
||||
title="服务版本"
|
||||
@@ -34,8 +38,8 @@ export default function ServicesPage(props: PageProps) {
|
||||
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted"
|
||||
size={14}
|
||||
/>
|
||||
<select
|
||||
class="select w-36 pl-9"
|
||||
<Select
|
||||
class="w-36 pl-9"
|
||||
value={status()}
|
||||
onChange={(event) => setStatus(event.currentTarget.value as "all" | ServiceStatus)}
|
||||
>
|
||||
@@ -43,34 +47,35 @@ export default function ServicesPage(props: PageProps) {
|
||||
<option value="running">运行中</option>
|
||||
<option value="stopped">已停止</option>
|
||||
<option value="faulted">故障</option>
|
||||
</select>
|
||||
</Select>
|
||||
</label>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onClick={() => props.command({ type: "open-register" })}
|
||||
>
|
||||
<Button variant="primary" onClick={() => props.command({ type: "open-register" })}>
|
||||
<CloudUpload size={16} />
|
||||
注册组件
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<Card as="section">
|
||||
<CardHeader>
|
||||
<div>
|
||||
<h2>已注册版本</h2>
|
||||
<p>{filtered().length} 个匹配项</p>
|
||||
</div>
|
||||
<button class="icon-btn" title="刷新" onClick={() => props.command({ type: "refresh" })}>
|
||||
<IconButton
|
||||
aria-label="刷新"
|
||||
title="刷新"
|
||||
onClick={() => props.command({ type: "refresh" })}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</IconButton>
|
||||
</CardHeader>
|
||||
<Show when={props.state()}>
|
||||
{(state) => (
|
||||
<ServiceTable services={filtered()} state={state()} command={props.command} />
|
||||
)}
|
||||
</Show>
|
||||
</section>
|
||||
</main>
|
||||
</Card>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { Database, Globe2, Save, ShieldCheck } from "lucide-solid";
|
||||
import { createEffect, createSignal } from "solid-js";
|
||||
import { displayApiBase } from "../../lib/api";
|
||||
import { PageHeading } from "../../components/ui/page-heading";
|
||||
import { Page } from "../../components/layout/page";
|
||||
import { PageHeading } from "../../components/layout/page-heading";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Card, CardHeader } from "../../components/ui/card";
|
||||
import { Field, FieldLabel } from "../../components/ui/field";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import type { PageProps } from "../types";
|
||||
|
||||
export default function SettingsPage(props: PageProps) {
|
||||
@@ -16,21 +21,21 @@ export default function SettingsPage(props: PageProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<main class="page">
|
||||
<Page>
|
||||
<PageHeading
|
||||
eyebrow="CONFIGURATION"
|
||||
title="运行设置"
|
||||
description="配置管理 API 连接;运行边界由后端策略统一控制。"
|
||||
/>
|
||||
<div class="grid gap-5 xl:grid-cols-[minmax(0,1.4fr)_minmax(280px,0.7fr)]">
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<Card as="section">
|
||||
<CardHeader>
|
||||
<div>
|
||||
<h2>管理 API</h2>
|
||||
<p>留空表示使用当前页面同源地址</p>
|
||||
</div>
|
||||
<Globe2 size={18} class="text-cyan-strong" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<form
|
||||
class="p-5"
|
||||
onSubmit={(event) => {
|
||||
@@ -38,27 +43,26 @@ export default function SettingsPage(props: PageProps) {
|
||||
props.command({ type: "save-api-base", value: endpoint() });
|
||||
}}
|
||||
>
|
||||
<label class="field">
|
||||
<span>Endpoint</span>
|
||||
<input
|
||||
class="input"
|
||||
<Field>
|
||||
<FieldLabel>Endpoint</FieldLabel>
|
||||
<Input
|
||||
type="url"
|
||||
value={endpoint()}
|
||||
placeholder={window.location.origin}
|
||||
onInput={(event) => setEndpoint(event.currentTarget.value)}
|
||||
/>
|
||||
</label>
|
||||
</Field>
|
||||
<p class="mt-2 text-xs text-muted">
|
||||
当前解析地址:{displayApiBase(props.state()?.apiBase ?? "")}
|
||||
</p>
|
||||
<div class="mt-5 flex justify-end">
|
||||
<button class="btn btn-primary" type="submit">
|
||||
<Button variant="primary" type="submit">
|
||||
<Save size={15} />
|
||||
保存连接
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</Card>
|
||||
<aside class="space-y-3">
|
||||
<Info
|
||||
icon={ShieldCheck}
|
||||
@@ -68,14 +72,14 @@ export default function SettingsPage(props: PageProps) {
|
||||
<Info icon={Database} title="持久化" detail="控制状态和服务 KV 存储于本地 libSQL。" />
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
function Info(props: { icon: typeof ShieldCheck; title: string; detail: string }) {
|
||||
const Icon = props.icon;
|
||||
return (
|
||||
<article class="panel flex gap-3 p-4">
|
||||
<Card as="article" padding="small" class="flex gap-3">
|
||||
<span class="flex size-9 shrink-0 items-center justify-center rounded-md bg-brand-soft text-brand">
|
||||
<Icon size={16} />
|
||||
</span>
|
||||
@@ -83,6 +87,6 @@ function Info(props: { icon: typeof ShieldCheck; title: string; detail: string }
|
||||
<strong class="text-sm">{props.title}</strong>
|
||||
<p class="mt-1 text-xs leading-5 text-muted">{props.detail}</p>
|
||||
</div>
|
||||
</article>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Accessor } from "solid-js";
|
||||
import type { PageLifecycle } from "../sdk/lifecycle";
|
||||
import type { HostSharedState, PageCommand } from "../sdk/protocol";
|
||||
|
||||
export type PageProps = {
|
||||
@@ -8,5 +9,12 @@ export type PageProps = {
|
||||
* render loops should pause until this accessor becomes true again.
|
||||
*/
|
||||
active: Accessor<boolean>;
|
||||
/**
|
||||
* Transition events for imperative Page work.
|
||||
*
|
||||
* Use `onShow` to refresh stale data, `onHide` to pause background work, and
|
||||
* `onUnload` for final cleanup. Subscriptions return an unsubscribe function.
|
||||
*/
|
||||
lifecycle: PageLifecycle;
|
||||
command: (command: PageCommand) => void;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import { CloudUpload, Download, FileCode2, Search } from "lucide-solid";
|
||||
import { createMemo, For, Show } from "solid-js";
|
||||
import { EmptyState } from "../../components/ui/empty-state";
|
||||
import { PageHeading } from "../../components/ui/page-heading";
|
||||
import { EmptyState } from "../../components/feedback/empty-state";
|
||||
import { Page } from "../../components/layout/page";
|
||||
import { PageHeading } from "../../components/layout/page-heading";
|
||||
import { Button, IconLink } from "../../components/ui/button";
|
||||
import { Card, CardHeader } from "../../components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import { witPackageDownloadUrl } from "../../lib/api";
|
||||
import type { PageProps } from "../types";
|
||||
|
||||
@@ -21,85 +32,83 @@ export default function WitPackagesPage(props: PageProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<main class="page">
|
||||
<Page>
|
||||
<PageHeading
|
||||
eyebrow="INTERFACES"
|
||||
title="WIT 包"
|
||||
description="管理不可变、版本化的 Component 接口依赖。"
|
||||
actions={
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onClick={() => props.command({ type: "open-wit-publish" })}
|
||||
>
|
||||
<Button variant="primary" onClick={() => props.command({ type: "open-wit-publish" })}>
|
||||
<CloudUpload size={16} />
|
||||
发布 WIT 包
|
||||
</button>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<Card as="section">
|
||||
<CardHeader>
|
||||
<div>
|
||||
<h2>Registry</h2>
|
||||
<p>{packages().length} 个匹配包版本</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<Show
|
||||
when={packages().length > 0}
|
||||
fallback={
|
||||
<EmptyState icon={Search} title="没有匹配的 WIT 包" detail="发布或调整搜索关键词。" />
|
||||
}
|
||||
>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>包</th>
|
||||
<th>版本</th>
|
||||
<th>直接依赖</th>
|
||||
<th>SHA-256</th>
|
||||
<th>
|
||||
<span class="sr-only">下载</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={packages()}>
|
||||
{(item) => (
|
||||
<tr>
|
||||
<td>
|
||||
<span class="flex items-center gap-2">
|
||||
<FileCode2 size={15} class="text-cyan-strong" />
|
||||
<strong>{item.name}</strong>
|
||||
</span>
|
||||
</td>
|
||||
<td class="code">{item.version}</td>
|
||||
<td class="text-xs text-muted">
|
||||
{item.dependencies.length
|
||||
? item.dependencies
|
||||
.map((dependency) => `${dependency.name}@${dependency.version}`)
|
||||
.join(", ")
|
||||
: "无"}
|
||||
</td>
|
||||
<td class="code max-w-64 truncate text-muted" title={item.sha256}>
|
||||
{item.sha256}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<a
|
||||
class="icon-btn"
|
||||
title="下载"
|
||||
href={witPackageDownloadUrl(props.state()?.apiBase ?? "", item)}
|
||||
>
|
||||
<Download size={15} />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>包</TableHead>
|
||||
<TableHead>版本</TableHead>
|
||||
<TableHead>直接依赖</TableHead>
|
||||
<TableHead>SHA-256</TableHead>
|
||||
<TableHead>
|
||||
<span class="sr-only">下载</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<For each={packages()}>
|
||||
{(item) => (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<span class="flex items-center gap-2">
|
||||
<FileCode2 size={15} class="text-cyan-strong" />
|
||||
<strong>{item.name}</strong>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell class="font-mono text-xs">{item.version}</TableCell>
|
||||
<TableCell class="text-xs text-muted">
|
||||
{item.dependencies.length
|
||||
? item.dependencies
|
||||
.map((dependency) => `${dependency.name}@${dependency.version}`)
|
||||
.join(", ")
|
||||
: "无"}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class="max-w-64 truncate font-mono text-xs text-muted"
|
||||
title={item.sha256}
|
||||
>
|
||||
{item.sha256}
|
||||
</TableCell>
|
||||
<TableCell class="text-right">
|
||||
<IconLink
|
||||
aria-label={`下载 ${item.name}@${item.version}`}
|
||||
title="下载"
|
||||
href={witPackageDownloadUrl(props.state()?.apiBase ?? "", item)}
|
||||
>
|
||||
<Download size={15} />
|
||||
</IconLink>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</For>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Show>
|
||||
</section>
|
||||
</main>
|
||||
</Card>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createSignal, onCleanup, onMount } from "solid-js";
|
||||
import type { View } from "../lib/model";
|
||||
import type { View } from "../pages/registry";
|
||||
import { createPageLifecycleDispatcher, type PageLifecycleEventType } from "../sdk/lifecycle";
|
||||
import { isHostMessage, type HostSharedState, type PageCommand } from "../sdk/protocol";
|
||||
import { notifyReady, sendCommand } from "../sdk/page";
|
||||
|
||||
@@ -7,12 +8,14 @@ import { notifyReady, sendCommand } from "../sdk/page";
|
||||
* Connects one iframe document to the persistent Host Shell.
|
||||
*
|
||||
* A keep-alive document remains mounted while inactive and exposes that state
|
||||
* through `active`. Removing the iframe still runs `onCleanup`, so an evicted
|
||||
* document cannot continue to receive Host snapshots.
|
||||
* through `active`. Host `dispose`, browser `pagehide` and Solid cleanup all
|
||||
* converge on one deduplicated SDK unload transition. The document boundary
|
||||
* itself remains the final guarantee that an evicted Page releases its realm.
|
||||
*/
|
||||
export function createFrameBridge(view: View) {
|
||||
const [state, setState] = createSignal<HostSharedState | null>(null);
|
||||
const [active, setActive] = createSignal(false);
|
||||
const pageLifecycle = createPageLifecycleDispatcher();
|
||||
|
||||
const receive = (event: MessageEvent<unknown>) => {
|
||||
if (event.origin !== window.location.origin || event.source !== window.parent) return;
|
||||
@@ -22,7 +25,12 @@ export function createFrameBridge(view: View) {
|
||||
}
|
||||
if (event.data.type === "lifecycle") {
|
||||
const phase = event.data.phase;
|
||||
setActive(phase === "activate");
|
||||
const lifecycleEvent = pageLifecycle.dispatch(eventForPhase(phase), "host");
|
||||
if (!lifecycleEvent) return;
|
||||
setActive(lifecycleEvent.current === "visible");
|
||||
|
||||
// Preserve the original DOM events for existing pages. New code should
|
||||
// use the typed SDK lifecycle returned by this bridge.
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("wasmeld:lifecycle", {
|
||||
detail: { phase },
|
||||
@@ -34,16 +42,39 @@ export function createFrameBridge(view: View) {
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener("message", receive);
|
||||
const unload = (event: PageTransitionEvent) => {
|
||||
// A persisted document is frozen in the back-forward cache, not unloaded.
|
||||
// Its Host and iframe will resume together when the browser restores it.
|
||||
if (event.persisted) return;
|
||||
if (pageLifecycle.dispatch("unload", "document")) setActive(false);
|
||||
};
|
||||
window.addEventListener("pagehide", unload);
|
||||
notifyReady(view);
|
||||
onCleanup(() => window.removeEventListener("pagehide", unload));
|
||||
});
|
||||
|
||||
onCleanup(() => window.removeEventListener("message", receive));
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("message", receive);
|
||||
pageLifecycle.dispatch("unload", "document");
|
||||
});
|
||||
|
||||
return {
|
||||
state,
|
||||
active,
|
||||
lifecycle: pageLifecycle.lifecycle,
|
||||
command(command: PageCommand) {
|
||||
sendCommand(view, command);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function eventForPhase(phase: "activate" | "deactivate" | "dispose"): PageLifecycleEventType {
|
||||
switch (phase) {
|
||||
case "activate":
|
||||
return "show";
|
||||
case "deactivate":
|
||||
return "hide";
|
||||
case "dispose":
|
||||
return "unload";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { View } from "../lib/model";
|
||||
import type { View } from "../pages/registry";
|
||||
import {
|
||||
SDK_CHANNEL,
|
||||
SDK_VERSION,
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
export type PageLifecycleEventType = "show" | "hide" | "unload";
|
||||
export type PageLifecycleState = "initializing" | "visible" | "hidden" | "unloaded";
|
||||
export type PageLifecycleSource = "host" | "document";
|
||||
|
||||
export type PageLifecycleEvent = {
|
||||
type: PageLifecycleEventType;
|
||||
previous: PageLifecycleState;
|
||||
current: PageLifecycleState;
|
||||
/**
|
||||
* `host` means the event came from iframe navigation policy. `document`
|
||||
* means the browser is discarding the document and acts as an unload
|
||||
* fallback when a Host `dispose` message cannot be delivered first.
|
||||
*/
|
||||
source: PageLifecycleSource;
|
||||
};
|
||||
|
||||
export type PageLifecycleListener = (event: PageLifecycleEvent) => void;
|
||||
|
||||
/**
|
||||
* Framework-independent lifecycle exposed to every Page.
|
||||
*
|
||||
* Event subscriptions are best for imperative work at a transition boundary:
|
||||
* refresh data on show, pause a stream on hide, and close external resources
|
||||
* on unload. Each method returns an idempotent unsubscribe function that a UI
|
||||
* framework should call during component cleanup.
|
||||
*/
|
||||
export type PageLifecycle = {
|
||||
state: () => PageLifecycleState;
|
||||
/** Subscribes only to future transitions of the selected type. */
|
||||
on: (type: PageLifecycleEventType, listener: PageLifecycleListener) => () => void;
|
||||
/**
|
||||
* Subscribes to show and immediately replays it when already visible.
|
||||
*
|
||||
* Replay prevents a lazy-loaded Page from missing the initial Host event.
|
||||
*/
|
||||
onShow: (listener: PageLifecycleListener) => () => void;
|
||||
/** Subscribes to hide and immediately replays it when already hidden. */
|
||||
onHide: (listener: PageLifecycleListener) => () => void;
|
||||
onUnload: (listener: PageLifecycleListener) => () => void;
|
||||
};
|
||||
|
||||
export type PageLifecycleDispatcher = {
|
||||
lifecycle: PageLifecycle;
|
||||
/**
|
||||
* Applies one lifecycle transition. Repeated and post-unload transitions are
|
||||
* ignored, so Host `dispose` followed by browser `pagehide` emits one unload.
|
||||
*/
|
||||
dispatch: (
|
||||
type: PageLifecycleEventType,
|
||||
source: PageLifecycleSource,
|
||||
) => PageLifecycleEvent | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates the lifecycle state machine for one iframe document.
|
||||
*
|
||||
* This module deliberately has no Solid or DOM dependency. A React, Vue or
|
||||
* other Page adapter can expose the same object without changing SDK behavior.
|
||||
*/
|
||||
export function createPageLifecycleDispatcher(): PageLifecycleDispatcher {
|
||||
let state: PageLifecycleState = "initializing";
|
||||
let lastEvent: PageLifecycleEvent | null = null;
|
||||
const listeners: Record<PageLifecycleEventType, Set<PageLifecycleListener>> = {
|
||||
show: new Set(),
|
||||
hide: new Set(),
|
||||
unload: new Set(),
|
||||
};
|
||||
|
||||
function on(type: PageLifecycleEventType, listener: PageLifecycleListener): () => void {
|
||||
if (state === "unloaded") return () => {};
|
||||
listeners[type].add(listener);
|
||||
return () => listeners[type].delete(listener);
|
||||
}
|
||||
|
||||
function onCurrent(type: "show" | "hide", listener: PageLifecycleListener): () => void {
|
||||
const unsubscribe = on(type, listener);
|
||||
if (lastEvent?.type === type) listener(lastEvent);
|
||||
return unsubscribe;
|
||||
}
|
||||
|
||||
const lifecycle: PageLifecycle = {
|
||||
state: () => state,
|
||||
on,
|
||||
onShow: (listener) => onCurrent("show", listener),
|
||||
onHide: (listener) => onCurrent("hide", listener),
|
||||
onUnload: (listener) => on("unload", listener),
|
||||
};
|
||||
|
||||
function dispatch(
|
||||
type: PageLifecycleEventType,
|
||||
source: PageLifecycleSource,
|
||||
): PageLifecycleEvent | null {
|
||||
if (state === "unloaded") return null;
|
||||
const current = stateFor(type);
|
||||
if (current === state) return null;
|
||||
|
||||
const event: PageLifecycleEvent = {
|
||||
type,
|
||||
previous: state,
|
||||
current,
|
||||
source,
|
||||
};
|
||||
state = current;
|
||||
lastEvent = event;
|
||||
for (const listener of [...listeners[type]]) listener(event);
|
||||
if (current === "unloaded") {
|
||||
for (const subscriptions of Object.values(listeners)) subscriptions.clear();
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
return { lifecycle, dispatch };
|
||||
}
|
||||
|
||||
function stateFor(type: PageLifecycleEventType): PageLifecycleState {
|
||||
switch (type) {
|
||||
case "show":
|
||||
return "visible";
|
||||
case "hide":
|
||||
return "hidden";
|
||||
case "unload":
|
||||
return "unloaded";
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { View } from "../lib/model";
|
||||
import type { View } from "../pages/registry";
|
||||
import { SDK_CHANNEL, SDK_VERSION, type PageCommand, type PageMessage } from "./protocol";
|
||||
|
||||
export type {
|
||||
PageLifecycle,
|
||||
PageLifecycleEvent,
|
||||
PageLifecycleEventType,
|
||||
PageLifecycleListener,
|
||||
PageLifecycleSource,
|
||||
PageLifecycleState,
|
||||
} from "./lifecycle";
|
||||
|
||||
export function notifyReady(view: View): void {
|
||||
send({
|
||||
channel: SDK_CHANNEL,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { BackendSnapshot } from "../lib/api";
|
||||
import type { ConnectionState, View } from "../lib/model";
|
||||
import type { ConnectionState } from "../lib/model";
|
||||
import type { View } from "../pages/registry";
|
||||
|
||||
export const SDK_CHANNEL = "wasmeld-console";
|
||||
|
||||
|
||||
@@ -64,273 +64,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply inline-flex h-10 items-center justify-center gap-2 rounded-md border px-4 text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-45;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply border-brand bg-brand text-white hover:border-brand-strong hover:bg-brand-strong;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply border-line bg-white text-ink hover:bg-canvas;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply border-coral-strong bg-white text-coral-strong hover:bg-coral-soft;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
@apply inline-flex size-9 shrink-0 items-center justify-center rounded-md border border-line bg-white text-muted transition-colors hover:bg-canvas hover:text-ink disabled:cursor-not-allowed disabled:opacity-45;
|
||||
}
|
||||
|
||||
.panel {
|
||||
@apply border-line bg-surface rounded-md border;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
@apply border-line flex min-h-16 items-center justify-between gap-4 border-b px-5 py-3;
|
||||
}
|
||||
|
||||
.panel-header h2 {
|
||||
@apply text-base font-semibold;
|
||||
}
|
||||
|
||||
.panel-header p {
|
||||
@apply mt-0.5 text-xs text-muted;
|
||||
}
|
||||
|
||||
.page {
|
||||
@apply mx-auto w-full max-w-[1480px] px-4 py-5 sm:px-6 lg:px-8 lg:py-7;
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
@apply mb-5 flex flex-col justify-between gap-4 sm:flex-row sm:items-end;
|
||||
}
|
||||
|
||||
.page-heading h1 {
|
||||
@apply mt-1 text-2xl font-bold text-ink;
|
||||
}
|
||||
|
||||
.page-heading p {
|
||||
@apply mt-1 max-w-3xl text-sm text-muted;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
@apply text-cyan-strong text-[11px] font-bold uppercase;
|
||||
}
|
||||
|
||||
.heading-actions {
|
||||
@apply flex shrink-0 items-center gap-2;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
@apply mb-5 grid grid-cols-1 border-l border-t border-line sm:grid-cols-2 xl:grid-cols-4;
|
||||
}
|
||||
|
||||
.metric {
|
||||
@apply flex min-h-24 items-center gap-3 border-b border-r border-line bg-white px-4 py-3;
|
||||
}
|
||||
|
||||
.metric-icon {
|
||||
@apply flex size-10 shrink-0 items-center justify-center rounded-md;
|
||||
}
|
||||
|
||||
.metric-copy {
|
||||
@apply min-w-0 flex-1;
|
||||
}
|
||||
|
||||
.metric-copy span {
|
||||
@apply block text-xs text-muted;
|
||||
}
|
||||
|
||||
.metric-copy strong {
|
||||
@apply mt-1 block text-2xl font-bold;
|
||||
}
|
||||
|
||||
.metric > small {
|
||||
@apply self-end whitespace-nowrap pb-1 text-[11px] text-muted;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
@apply w-full overflow-x-auto;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
@apply w-full min-w-[760px] border-collapse text-left text-sm;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
@apply border-line bg-canvas border-b px-4 py-2.5 text-[11px] font-semibold uppercase text-muted;
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
@apply border-line border-b px-4 py-3 align-middle;
|
||||
}
|
||||
|
||||
.data-table tbody tr {
|
||||
@apply transition-colors hover:bg-[#f8faf9];
|
||||
}
|
||||
|
||||
.data-table tbody tr:last-child td {
|
||||
@apply border-b-0;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
@apply inline-flex items-center gap-1.5 whitespace-nowrap text-xs font-semibold;
|
||||
}
|
||||
|
||||
.status-badge::before {
|
||||
content: "";
|
||||
@apply size-1.5 rounded-full bg-current;
|
||||
}
|
||||
|
||||
.status-running {
|
||||
@apply text-brand;
|
||||
}
|
||||
|
||||
.status-stopped {
|
||||
@apply text-muted;
|
||||
}
|
||||
|
||||
.status-faulted {
|
||||
@apply text-coral-strong;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
@apply flex items-center justify-end gap-1;
|
||||
}
|
||||
|
||||
.row-actions button {
|
||||
@apply inline-flex size-8 items-center justify-center rounded-md text-muted hover:bg-canvas hover:text-ink disabled:cursor-not-allowed disabled:opacity-35;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
@apply flex min-h-56 flex-col items-center justify-center gap-2 px-6 text-center text-muted;
|
||||
}
|
||||
|
||||
.empty-state strong {
|
||||
@apply text-sm text-ink;
|
||||
}
|
||||
|
||||
.empty-state span {
|
||||
@apply text-xs;
|
||||
}
|
||||
|
||||
.code {
|
||||
@apply font-mono text-xs;
|
||||
}
|
||||
|
||||
.field {
|
||||
@apply block;
|
||||
}
|
||||
|
||||
.field > span {
|
||||
@apply mb-1.5 block text-xs font-semibold text-muted;
|
||||
}
|
||||
|
||||
.input,
|
||||
.select,
|
||||
.textarea {
|
||||
@apply border-line w-full rounded-md border bg-white px-3 text-sm text-ink outline-none transition-colors focus:border-brand;
|
||||
}
|
||||
|
||||
.input,
|
||||
.select {
|
||||
@apply h-10;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
@apply min-h-36 resize-y py-3 font-mono;
|
||||
}
|
||||
|
||||
.dialog-backdrop {
|
||||
@apply fixed inset-0 z-50 flex items-center justify-center bg-[#0d1715]/55 p-3 backdrop-blur-[1px];
|
||||
}
|
||||
|
||||
.dialog {
|
||||
@apply border-line max-h-[calc(100dvh-24px)] w-full max-w-3xl overflow-auto rounded-md border bg-white p-0 shadow-2xl;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
@apply border-line flex items-start justify-between gap-4 border-b px-5 py-4 sm:px-7 sm:py-5;
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
@apply flex min-w-0 items-start gap-3;
|
||||
}
|
||||
|
||||
.dialog-title > span {
|
||||
@apply bg-brand-soft text-brand flex size-10 shrink-0 items-center justify-center rounded-md;
|
||||
}
|
||||
|
||||
.dialog-title h2 {
|
||||
@apply text-lg font-bold sm:text-xl;
|
||||
}
|
||||
|
||||
.dialog-title p {
|
||||
@apply mt-1 text-sm text-muted;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
@apply px-5 py-5 sm:px-7;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
@apply border-line flex items-center justify-end gap-2 border-t bg-[#fafbfb] px-5 py-4 sm:px-7;
|
||||
}
|
||||
|
||||
.upload-zone {
|
||||
@apply border-line flex min-h-48 cursor-pointer flex-col items-center justify-center gap-2 rounded-md border border-dashed bg-[#fbfcfc] px-5 text-center text-muted transition-colors hover:border-brand hover:bg-brand-soft;
|
||||
}
|
||||
|
||||
.upload-zone input {
|
||||
@apply sr-only;
|
||||
}
|
||||
|
||||
.upload-zone strong {
|
||||
@apply text-sm text-ink;
|
||||
}
|
||||
|
||||
.upload-zone span {
|
||||
@apply text-xs;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
@apply border-coral-strong/30 bg-coral-soft text-coral-strong mt-3 rounded-md border px-3 py-2 text-sm;
|
||||
}
|
||||
|
||||
.segmented {
|
||||
@apply border-line inline-flex rounded-md border bg-canvas p-1;
|
||||
}
|
||||
|
||||
.segmented button {
|
||||
@apply h-8 rounded-sm px-3 text-xs font-semibold text-muted;
|
||||
}
|
||||
|
||||
.segmented button.active {
|
||||
@apply bg-white text-ink shadow-sm;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
@apply h-3 animate-pulse rounded-sm bg-[#dfe6e4];
|
||||
}
|
||||
|
||||
.loading-progress-track {
|
||||
@apply h-1 w-40 overflow-hidden rounded-sm bg-line;
|
||||
}
|
||||
|
||||
.loading-progress-indicator {
|
||||
@apply block h-full w-[36%] rounded-sm bg-brand;
|
||||
animation: loading-progress-swing 1s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes loading-progress-swing {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
@@ -340,16 +73,3 @@
|
||||
transform: translateX(178%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.loading-progress-indicator {
|
||||
animation: none;
|
||||
transform: translateX(89%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
const uiDirectory = new URL("../src/components/ui/", import.meta.url);
|
||||
const appStylesheet = new URL("../src/styles/app.css", import.meta.url);
|
||||
|
||||
test("UI primitives do not depend on page, Host or domain modules", async () => {
|
||||
const files = (await readdir(uiDirectory)).filter(
|
||||
(name) => name.endsWith(".ts") || name.endsWith(".tsx"),
|
||||
);
|
||||
|
||||
for (const file of files) {
|
||||
const source = await readFile(new URL(file, uiDirectory), "utf8");
|
||||
const imports = [...source.matchAll(/from\s+["']([^"']+)["']/g)].map((match) => match[1]);
|
||||
|
||||
for (const specifier of imports) {
|
||||
assert.doesNotMatch(
|
||||
specifier,
|
||||
/(?:^|\/)(?:feedback|host|layout|pages|sdk|services)(?:\/|$)|\/lib\/(?:api|model|view-state)$/,
|
||||
`${file} must remain business-independent: ${specifier}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("global stylesheet does not own component variants", async () => {
|
||||
const source = await readFile(appStylesheet, "utf8");
|
||||
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/@layer\s+components/,
|
||||
"component classes and variants belong beside their Solid components",
|
||||
);
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { FrameCache } from "../src/host/frame-cache.ts";
|
||||
|
||||
test("retains opted-in iframe identities across navigation", () => {
|
||||
const cache = new FrameCache("services", {
|
||||
maxKeepAliveEntries: 2,
|
||||
inactiveTtlMs: 1_000,
|
||||
shouldKeepAlive: (view) => view === "services" || view === "settings",
|
||||
});
|
||||
const services = cache.snapshot()[0];
|
||||
@@ -26,7 +26,7 @@ test("retains opted-in iframe identities across navigation", () => {
|
||||
|
||||
test("recreates non-keep-alive documents after leaving them", () => {
|
||||
const cache = new FrameCache("overview", {
|
||||
maxKeepAliveEntries: 2,
|
||||
inactiveTtlMs: 1_000,
|
||||
shouldKeepAlive: () => false,
|
||||
});
|
||||
const firstInstance = cache.snapshot()[0].instance;
|
||||
@@ -41,40 +41,72 @@ test("recreates non-keep-alive documents after leaving them", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("evicts inactive keep-alive documents in LRU order", () => {
|
||||
test("expires a keep-alive document after its continuous hidden TTL", () => {
|
||||
let now = 5_000;
|
||||
const cache = new FrameCache("services", {
|
||||
maxKeepAliveEntries: 2,
|
||||
inactiveTtlMs: 1_000,
|
||||
shouldKeepAlive: () => true,
|
||||
now: () => now,
|
||||
});
|
||||
const servicesInstance = cache.snapshot()[0].instance;
|
||||
|
||||
// Time spent visible does not consume the inactive TTL.
|
||||
now = 50_000;
|
||||
cache.activate("settings");
|
||||
const overflow = cache.activate("instances");
|
||||
now = 50_999;
|
||||
assert.equal(cache.timeUntilExpiration(), 1);
|
||||
assert.deepEqual(cache.expireInactive().removed, []);
|
||||
|
||||
now = 51_000;
|
||||
const expiration = cache.expireInactive();
|
||||
assert.deepEqual(
|
||||
overflow.removed.map((entry) => entry.view),
|
||||
expiration.removed.map((entry) => entry.view),
|
||||
["services"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
overflow.entries.map((entry) => entry.view),
|
||||
["settings", "instances"],
|
||||
expiration.entries.map((entry) => entry.view),
|
||||
["settings"],
|
||||
);
|
||||
|
||||
const recreated = cache.activate("services");
|
||||
assert.notEqual(recreated.activated.instance, servicesInstance);
|
||||
});
|
||||
|
||||
test("revisiting a hidden page before its TTL preserves and resets it", () => {
|
||||
let now = 0;
|
||||
const cache = new FrameCache("services", {
|
||||
inactiveTtlMs: 1_000,
|
||||
shouldKeepAlive: () => true,
|
||||
now: () => now,
|
||||
});
|
||||
const services = cache.snapshot()[0];
|
||||
|
||||
cache.activate("settings");
|
||||
now = 999;
|
||||
const revisited = cache.activate("services");
|
||||
assert.equal(revisited.activated, services);
|
||||
|
||||
now = 1_500;
|
||||
assert.deepEqual(cache.expireInactive().removed, []);
|
||||
assert.equal(cache.timeUntilExpiration(), 499);
|
||||
|
||||
now = 1_999;
|
||||
const expiration = cache.expireInactive();
|
||||
assert.deepEqual(
|
||||
recreated.removed.map((entry) => entry.view),
|
||||
expiration.removed.map((entry) => entry.view),
|
||||
["settings"],
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects invalid keep-alive limits", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
new FrameCache("overview", {
|
||||
maxKeepAliveEntries: -1,
|
||||
shouldKeepAlive: () => true,
|
||||
}),
|
||||
RangeError,
|
||||
);
|
||||
test("rejects invalid inactive TTL values", () => {
|
||||
for (const inactiveTtlMs of [0, -1, Number.POSITIVE_INFINITY, Number.NaN]) {
|
||||
assert.throws(
|
||||
() =>
|
||||
new FrameCache("overview", {
|
||||
inactiveTtlMs,
|
||||
shouldKeepAlive: () => true,
|
||||
}),
|
||||
RangeError,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createPageLifecycleDispatcher } from "../src/sdk/lifecycle.ts";
|
||||
|
||||
test("maps page visibility transitions to show, hide and unload events", () => {
|
||||
const dispatcher = createPageLifecycleDispatcher();
|
||||
const events = [];
|
||||
dispatcher.lifecycle.onShow((event) => events.push(event));
|
||||
dispatcher.lifecycle.onHide((event) => events.push(event));
|
||||
dispatcher.lifecycle.onUnload((event) => events.push(event));
|
||||
|
||||
assert.equal(dispatcher.lifecycle.state(), "initializing");
|
||||
dispatcher.dispatch("show", "host");
|
||||
dispatcher.dispatch("hide", "host");
|
||||
dispatcher.dispatch("show", "host");
|
||||
dispatcher.dispatch("unload", "host");
|
||||
|
||||
assert.equal(dispatcher.lifecycle.state(), "unloaded");
|
||||
assert.deepEqual(
|
||||
events.map(({ type, previous, current, source }) => ({
|
||||
type,
|
||||
previous,
|
||||
current,
|
||||
source,
|
||||
})),
|
||||
[
|
||||
{ type: "show", previous: "initializing", current: "visible", source: "host" },
|
||||
{ type: "hide", previous: "visible", current: "hidden", source: "host" },
|
||||
{ type: "show", previous: "hidden", current: "visible", source: "host" },
|
||||
{ type: "unload", previous: "visible", current: "unloaded", source: "host" },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("deduplicates lifecycle events and ignores transitions after unload", () => {
|
||||
const dispatcher = createPageLifecycleDispatcher();
|
||||
const events = [];
|
||||
dispatcher.lifecycle.on("show", (event) => events.push(event.type));
|
||||
dispatcher.lifecycle.on("unload", (event) => events.push(event.type));
|
||||
|
||||
assert.equal(dispatcher.dispatch("show", "host")?.type, "show");
|
||||
assert.equal(dispatcher.dispatch("show", "host"), null);
|
||||
assert.equal(dispatcher.dispatch("unload", "host")?.type, "unload");
|
||||
assert.equal(dispatcher.dispatch("unload", "document"), null);
|
||||
assert.equal(dispatcher.dispatch("show", "host"), null);
|
||||
assert.deepEqual(events, ["show", "unload"]);
|
||||
});
|
||||
|
||||
test("unsubscribe removes a page lifecycle listener", () => {
|
||||
const dispatcher = createPageLifecycleDispatcher();
|
||||
let calls = 0;
|
||||
const unsubscribe = dispatcher.lifecycle.onHide(() => {
|
||||
calls += 1;
|
||||
});
|
||||
|
||||
unsubscribe();
|
||||
unsubscribe();
|
||||
dispatcher.dispatch("hide", "host");
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test("show and hide helpers replay current state for lazy pages", () => {
|
||||
const dispatcher = createPageLifecycleDispatcher();
|
||||
dispatcher.dispatch("show", "host");
|
||||
|
||||
const replayed = [];
|
||||
dispatcher.lifecycle.onShow((event) => replayed.push(event.type));
|
||||
dispatcher.lifecycle.onHide((event) => replayed.push(event.type));
|
||||
dispatcher.lifecycle.on("show", (event) => replayed.push(`future:${event.type}`));
|
||||
assert.deepEqual(replayed, ["show"]);
|
||||
|
||||
dispatcher.dispatch("hide", "host");
|
||||
dispatcher.lifecycle.onHide((event) => replayed.push(`late:${event.type}`));
|
||||
assert.deepEqual(replayed, ["show", "hide", "late:hide"]);
|
||||
});
|
||||
Reference in New Issue
Block a user