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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user