feat(console-web): replace TanStack UI with Solid host pages
- move the management frontend under the wasmeld-console crate - use a persistent Host shell with disposable iframe page documents - version Host/Page postMessage state and command contracts - migrate runtime, service, WIT, invocation, and settings workflows - add Tailwind CSS v4, responsive layouts, and Host-owned dialogs - emit a dependency-free PWA worker with API cache exclusions - remove the superseded root TanStack Start application
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
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 { eventsFrom } from "../../lib/view-state";
|
||||
import type { PageProps } from "../types";
|
||||
|
||||
const toneClass = {
|
||||
success: "bg-brand",
|
||||
warning: "bg-amber-strong",
|
||||
danger: "bg-coral-strong",
|
||||
neutral: "bg-muted",
|
||||
};
|
||||
|
||||
export default function ActivityPage(props: PageProps) {
|
||||
const events = createMemo(() => {
|
||||
const query = props.state()?.query.trim().toLowerCase() ?? "";
|
||||
return eventsFrom(props.state()).filter(
|
||||
(event) =>
|
||||
!query ||
|
||||
event.title.toLowerCase().includes(query) ||
|
||||
event.detail.toLowerCase().includes(query),
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<main class="page">
|
||||
<PageHeading
|
||||
eyebrow="EVENTS"
|
||||
title="调用记录"
|
||||
description="最近 256 条持久化生命周期与调用事件。"
|
||||
actions={
|
||||
<button class="icon-btn" title="刷新" onClick={() => props.command({ type: "refresh" })}>
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<section class="panel">
|
||||
<Show
|
||||
when={events().length > 0}
|
||||
fallback={
|
||||
<EmptyState icon={History} title="暂无事件" detail="Runtime 操作和调用会显示在这里。" />
|
||||
}
|
||||
>
|
||||
<div class="divide-y divide-line">
|
||||
<For each={events()}>
|
||||
{(event) => (
|
||||
<article class="grid gap-2 px-5 py-4 sm:grid-cols-[10px_minmax(180px,0.8fr)_2fr_84px] sm:items-center">
|
||||
<span class={`size-2 rounded-full ${toneClass[event.tone]}`} />
|
||||
<strong class="text-sm">{event.title}</strong>
|
||||
<p class="text-xs leading-5 text-muted">{event.detail}</p>
|
||||
<time class="font-mono text-xs text-muted sm:text-right">{event.time}</time>
|
||||
</article>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
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 { serviceKey } from "../../lib/model";
|
||||
import { servicesFrom } from "../../lib/view-state";
|
||||
import type { PageProps } from "../types";
|
||||
|
||||
export default function InstancesPage(props: PageProps) {
|
||||
const services = createMemo(() => servicesFrom(props.state()));
|
||||
const running = createMemo(
|
||||
() => services().filter((service) => service.status === "running").length,
|
||||
);
|
||||
|
||||
return (
|
||||
<main class="page">
|
||||
<PageHeading
|
||||
eyebrow="ACTORS"
|
||||
title="运行实例"
|
||||
description={`${running()} 个 Actor 保持独立 Store 与串行 mailbox。`}
|
||||
actions={
|
||||
<button class="icon-btn" title="刷新" onClick={() => props.command({ type: "refresh" })}>
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<Show
|
||||
when={services().length > 0}
|
||||
fallback={
|
||||
<div class="panel">
|
||||
<EmptyState icon={Server} title="暂无实例" detail="先注册一个组件包。" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<section class="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
<For each={services()}>
|
||||
{(service) => (
|
||||
<article class="panel p-5">
|
||||
<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">
|
||||
<Cpu size={18} />
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={service.status} />
|
||||
</div>
|
||||
<dl class="mt-5 grid grid-cols-2 gap-x-4 gap-y-3 border-t border-line pt-4 text-xs">
|
||||
<div>
|
||||
<dt class="text-muted">内存上限</dt>
|
||||
<dd class="mt-1 font-semibold">{service.memoryMb} MB</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-muted">Mailbox</dt>
|
||||
<dd class="mt-1 font-semibold">{service.mailbox}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-muted">Deadline</dt>
|
||||
<dd class="mt-1 font-semibold">{service.deadlineMs} ms</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-muted">最近延迟</dt>
|
||||
<dd class="mt-1 font-semibold">
|
||||
{service.latencyMs === null ? "—" : `${service.latencyMs} ms`}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="mt-5 flex justify-end gap-2">
|
||||
<Show when={service.status === "running"}>
|
||||
<button
|
||||
class="btn btn-secondary h-9"
|
||||
onClick={() =>
|
||||
props.command({
|
||||
type: "open-invoke",
|
||||
serviceKey: serviceKey(service),
|
||||
})
|
||||
}
|
||||
>
|
||||
<SquareTerminal size={15} />
|
||||
调用
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
class="btn btn-secondary h-9"
|
||||
disabled={props.state()?.serviceAction !== null}
|
||||
onClick={() =>
|
||||
props.command({
|
||||
type: "service-action",
|
||||
serviceKey: serviceKey(service),
|
||||
action: service.status === "running" ? "stop" : "start",
|
||||
})
|
||||
}
|
||||
>
|
||||
{service.status === "running" ? "停止" : "启动"}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
</For>
|
||||
</section>
|
||||
</Show>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { AlertTriangle, LoaderCircle } from "lucide-solid";
|
||||
import { lazy, Show, Suspense, type Component } 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")),
|
||||
};
|
||||
|
||||
function FramePage() {
|
||||
const requested = new URLSearchParams(window.location.search).get("view");
|
||||
const view = isView(requested) ? requested : null;
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={view}
|
||||
fallback={
|
||||
<main class="flex min-h-dvh items-center justify-center p-6 text-center text-coral-strong">
|
||||
<div>
|
||||
<AlertTriangle class="mx-auto mb-3" size={28} />
|
||||
<strong class="block">未知页面</strong>
|
||||
<span class="mt-1 block text-sm text-muted">{requested ?? "(empty)"}</span>
|
||||
</div>
|
||||
</main>
|
||||
}
|
||||
>
|
||||
{(activeView) => {
|
||||
const bridge = createFrameBridge(activeView());
|
||||
const Page = pages[activeView()];
|
||||
document.title = `${activeView()} · Wasmeld`;
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<main class="flex min-h-dvh items-center justify-center text-muted">
|
||||
<LoaderCircle class="spin" size={24} />
|
||||
</main>
|
||||
}
|
||||
>
|
||||
<Page state={bridge.state} command={bridge.command} />
|
||||
</Suspense>
|
||||
);
|
||||
}}
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
||||
const root = document.getElementById("app");
|
||||
if (!root) throw new Error("missing #app root");
|
||||
render(() => <FramePage />, root);
|
||||
@@ -0,0 +1,258 @@
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
CircleStop,
|
||||
CloudUpload,
|
||||
Cpu,
|
||||
Package,
|
||||
Play,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
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 { eventsFrom, servicesFrom } from "../../lib/view-state";
|
||||
import { formatDuration } from "../../lib/model";
|
||||
import type { PageProps } from "../types";
|
||||
|
||||
export default function OverviewPage(props: PageProps) {
|
||||
const services = createMemo(() => servicesFrom(props.state()));
|
||||
const events = createMemo(() => eventsFrom(props.state()));
|
||||
const running = createMemo(
|
||||
() => services().filter((service) => service.status === "running").length,
|
||||
);
|
||||
const totalCalls = createMemo(() =>
|
||||
services().reduce((total, service) => total + service.calls, 0),
|
||||
);
|
||||
const errors = createMemo(() => services().reduce((total, service) => total + service.errors, 0));
|
||||
|
||||
return (
|
||||
<main class="page">
|
||||
<Show when={props.state()} fallback={<OverviewSkeleton />}>
|
||||
{(state) => (
|
||||
<>
|
||||
<PageHeading
|
||||
eyebrow="LOCAL RUNTIME"
|
||||
title="运行概览"
|
||||
description={`${services().length} 个已注册版本,${running()} 个 Actor 正在运行。`}
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
class="icon-btn"
|
||||
type="button"
|
||||
aria-label="刷新"
|
||||
title="刷新"
|
||||
onClick={() => props.command({ type: "refresh" })}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="button"
|
||||
onClick={() => props.command({ type: "open-register" })}
|
||||
>
|
||||
<CloudUpload size={16} />
|
||||
注册组件
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<section class="metric-grid" aria-label="关键指标">
|
||||
<Metric
|
||||
icon={<Package size={18} />}
|
||||
tone="bg-cyan-soft text-cyan-strong"
|
||||
label="服务版本"
|
||||
value={services().length.toString()}
|
||||
note={`${state().snapshot?.deployments.length ?? 0} 个对外服务`}
|
||||
/>
|
||||
<Metric
|
||||
icon={<Activity size={18} />}
|
||||
tone="bg-brand-soft text-brand"
|
||||
label="运行实例"
|
||||
value={running().toString()}
|
||||
note={`${services().length - running()} 个未运行`}
|
||||
/>
|
||||
<Metric
|
||||
icon={<Zap size={18} />}
|
||||
tone="bg-amber-soft text-amber-strong"
|
||||
label="累计调用"
|
||||
value={totalCalls().toLocaleString("zh-CN")}
|
||||
note="持久化统计"
|
||||
/>
|
||||
<Metric
|
||||
icon={<AlertTriangle size={18} />}
|
||||
tone="bg-coral-soft text-coral-strong"
|
||||
label="调用异常"
|
||||
value={errors().toString()}
|
||||
note={errors() > 0 ? "需要检查" : "无异常"}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="panel 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} />
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<strong class="block">Wasmeld Runtime</strong>
|
||||
<span class="mt-0.5 block truncate text-xs text-muted">
|
||||
{state().snapshot?.runtime.status === "running"
|
||||
? "Wasmtime sandbox 正常"
|
||||
: "Engine 与 Actor 已释放"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<Show
|
||||
when={state().snapshot?.runtime.status === "running"}
|
||||
fallback={
|
||||
<button
|
||||
class="icon-btn"
|
||||
title="启动 Runtime"
|
||||
disabled={state().runtimeAction !== null}
|
||||
onClick={() => props.command({ type: "runtime-action", action: "start" })}
|
||||
>
|
||||
<Play size={15} />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<button
|
||||
class="icon-btn"
|
||||
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"
|
||||
title="停止 Runtime"
|
||||
disabled={state().runtimeAction !== null}
|
||||
onClick={() => props.command({ type: "runtime-action", action: "stop" })}
|
||||
>
|
||||
<CircleStop size={15} />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<RuntimeStat
|
||||
label="已加载版本"
|
||||
value={(state().snapshot?.runtime.registered_services ?? 0).toString()}
|
||||
detail="已编译 Component"
|
||||
/>
|
||||
<RuntimeStat
|
||||
label="活跃 Store"
|
||||
value={(state().snapshot?.runtime.running_services ?? 0).toString()}
|
||||
detail="串行 Actor"
|
||||
/>
|
||||
<RuntimeStat
|
||||
label="运行时间"
|
||||
value={
|
||||
state().snapshot?.runtime.status === "running"
|
||||
? formatDuration(state().snapshot?.runtime.uptime_ms ?? 0)
|
||||
: "—"
|
||||
}
|
||||
detail="当前 Runtime"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<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">
|
||||
<div>
|
||||
<h2>服务状态</h2>
|
||||
<p>已注册版本及当前 Actor 状态</p>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-secondary h-9"
|
||||
onClick={() => props.command({ type: "navigate", view: "services" })}
|
||||
>
|
||||
查看全部
|
||||
</button>
|
||||
</div>
|
||||
<ServiceTable
|
||||
services={services().slice(0, 5)}
|
||||
state={state()}
|
||||
command={props.command}
|
||||
compact
|
||||
/>
|
||||
</section>
|
||||
|
||||
<aside class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>最近事件</h2>
|
||||
<p>生命周期与调用结果</p>
|
||||
</div>
|
||||
<span class="inline-flex items-center gap-1.5 text-[11px] font-bold text-brand">
|
||||
<span class="size-1.5 rounded-full bg-brand" />
|
||||
LIVE
|
||||
</span>
|
||||
</div>
|
||||
<div class="divide-y divide-line">
|
||||
<For
|
||||
each={events().slice(0, 6)}
|
||||
fallback={<p class="px-5 py-12 text-center text-sm text-muted">暂无事件</p>}
|
||||
>
|
||||
{(event) => (
|
||||
<div class="px-5 py-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<strong class="truncate text-xs">{event.title}</strong>
|
||||
<span class="font-mono text-[10px] text-muted">{event.time}</span>
|
||||
</div>
|
||||
<p class="mt-1 line-clamp-2 text-xs leading-5 text-muted">{event.detail}</p>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric(props: {
|
||||
icon: JSXElement;
|
||||
tone: string;
|
||||
label: string;
|
||||
value: string;
|
||||
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>
|
||||
</div>
|
||||
<small>{props.note}</small>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function RuntimeStat(props: { label: string; value: string; detail: string }) {
|
||||
return (
|
||||
<div class="border-b border-line px-5 py-4 last:border-b-0 lg:border-b-0 lg:border-r lg:last:border-r-0">
|
||||
<span class="block text-xs text-muted">{props.label}</span>
|
||||
<strong class="mt-1 block text-lg">{props.value}</strong>
|
||||
<small class="mt-0.5 block text-[11px] text-muted">{props.detail}</small>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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="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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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 { servicesFrom } from "../../lib/view-state";
|
||||
import type { ServiceStatus } from "../../lib/model";
|
||||
import type { PageProps } from "../types";
|
||||
|
||||
export default function ServicesPage(props: PageProps) {
|
||||
const [status, setStatus] = createSignal<"all" | ServiceStatus>("all");
|
||||
const services = createMemo(() => servicesFrom(props.state()));
|
||||
const filtered = createMemo(() => {
|
||||
const query = props.state()?.query.trim().toLowerCase() ?? "";
|
||||
return services().filter(
|
||||
(service) =>
|
||||
(status() === "all" || service.status === status()) &&
|
||||
(!query ||
|
||||
service.id.toLowerCase().includes(query) ||
|
||||
service.revision.toLowerCase().includes(query) ||
|
||||
service.artifact.toLowerCase().includes(query)),
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<main class="page">
|
||||
<PageHeading
|
||||
eyebrow="COMPONENTS"
|
||||
title="服务版本"
|
||||
description="管理不可变 Component Revision、Actor 状态和对外 Deployment。"
|
||||
actions={
|
||||
<>
|
||||
<label class="relative">
|
||||
<Filter
|
||||
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"
|
||||
value={status()}
|
||||
onChange={(event) => setStatus(event.currentTarget.value as "all" | ServiceStatus)}
|
||||
>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="running">运行中</option>
|
||||
<option value="stopped">已停止</option>
|
||||
<option value="faulted">故障</option>
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onClick={() => props.command({ type: "open-register" })}
|
||||
>
|
||||
<CloudUpload size={16} />
|
||||
注册组件
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>已注册版本</h2>
|
||||
<p>{filtered().length} 个匹配项</p>
|
||||
</div>
|
||||
<button class="icon-btn" title="刷新" onClick={() => props.command({ type: "refresh" })}>
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<Show when={props.state()}>
|
||||
{(state) => (
|
||||
<ServiceTable services={filtered()} state={state()} command={props.command} />
|
||||
)}
|
||||
</Show>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
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 type { PageProps } from "../types";
|
||||
|
||||
export default function SettingsPage(props: PageProps) {
|
||||
const [endpoint, setEndpoint] = createSignal("");
|
||||
let initialized = false;
|
||||
createEffect(() => {
|
||||
const state = props.state();
|
||||
if (state && !initialized) {
|
||||
setEndpoint(state.apiBase);
|
||||
initialized = true;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<main class="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">
|
||||
<div>
|
||||
<h2>管理 API</h2>
|
||||
<p>留空表示使用当前页面同源地址</p>
|
||||
</div>
|
||||
<Globe2 size={18} class="text-cyan-strong" />
|
||||
</div>
|
||||
<form
|
||||
class="p-5"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
props.command({ type: "save-api-base", value: endpoint() });
|
||||
}}
|
||||
>
|
||||
<label class="field">
|
||||
<span>Endpoint</span>
|
||||
<input
|
||||
class="input"
|
||||
type="url"
|
||||
value={endpoint()}
|
||||
placeholder={window.location.origin}
|
||||
onInput={(event) => setEndpoint(event.currentTarget.value)}
|
||||
/>
|
||||
</label>
|
||||
<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">
|
||||
<Save size={15} />
|
||||
保存连接
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
<aside class="space-y-3">
|
||||
<Info
|
||||
icon={ShieldCheck}
|
||||
title="Sandbox"
|
||||
detail="网络与 Host 能力默认拒绝,按 WIT import 精确链接。"
|
||||
/>
|
||||
<Info icon={Database} title="持久化" detail="控制状态和服务 KV 存储于本地 libSQL。" />
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function Info(props: { icon: typeof ShieldCheck; title: string; detail: string }) {
|
||||
const Icon = props.icon;
|
||||
return (
|
||||
<article class="panel flex gap-3 p-4">
|
||||
<span class="flex size-9 shrink-0 items-center justify-center rounded-md bg-brand-soft text-brand">
|
||||
<Icon size={16} />
|
||||
</span>
|
||||
<div>
|
||||
<strong class="text-sm">{props.title}</strong>
|
||||
<p class="mt-1 text-xs leading-5 text-muted">{props.detail}</p>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { Accessor } from "solid-js";
|
||||
import type { HostSharedState, PageCommand } from "../sdk/protocol";
|
||||
|
||||
export type PageProps = {
|
||||
state: Accessor<HostSharedState | null>;
|
||||
command: (command: PageCommand) => void;
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
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 { witPackageDownloadUrl } from "../../lib/api";
|
||||
import type { PageProps } from "../types";
|
||||
|
||||
export default function WitPackagesPage(props: PageProps) {
|
||||
const packages = createMemo(() => {
|
||||
const state = props.state();
|
||||
const query = state?.query.trim().toLowerCase() ?? "";
|
||||
return (
|
||||
state?.snapshot?.witPackages.filter(
|
||||
(item) =>
|
||||
!query ||
|
||||
item.name.toLowerCase().includes(query) ||
|
||||
item.version.toLowerCase().includes(query) ||
|
||||
item.sha256.toLowerCase().includes(query),
|
||||
) ?? []
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<main class="page">
|
||||
<PageHeading
|
||||
eyebrow="INTERFACES"
|
||||
title="WIT 包"
|
||||
description="管理不可变、版本化的 Component 接口依赖。"
|
||||
actions={
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onClick={() => props.command({ type: "open-wit-publish" })}
|
||||
>
|
||||
<CloudUpload size={16} />
|
||||
发布 WIT 包
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>Registry</h2>
|
||||
<p>{packages().length} 个匹配包版本</p>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</Show>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user