2026-07-30 09:21:55 +08:00
|
|
|
import {
|
|
|
|
|
Boxes,
|
|
|
|
|
FileCode2,
|
|
|
|
|
History,
|
|
|
|
|
LayoutDashboard,
|
|
|
|
|
Package,
|
|
|
|
|
Search,
|
|
|
|
|
Server,
|
|
|
|
|
Settings,
|
|
|
|
|
ShieldCheck,
|
|
|
|
|
WifiOff,
|
|
|
|
|
} from "lucide-solid";
|
|
|
|
|
import {
|
2026-07-30 09:57:06 +08:00
|
|
|
batch,
|
2026-07-30 09:21:55 +08:00
|
|
|
createEffect,
|
|
|
|
|
createMemo,
|
|
|
|
|
createSignal,
|
|
|
|
|
For,
|
2026-07-30 09:57:06 +08:00
|
|
|
on,
|
2026-07-30 09:21:55 +08:00
|
|
|
onCleanup,
|
|
|
|
|
onMount,
|
|
|
|
|
Show,
|
|
|
|
|
type Component,
|
|
|
|
|
} from "solid-js";
|
|
|
|
|
import type { LucideProps } from "lucide-solid";
|
|
|
|
|
import {
|
|
|
|
|
activateDeployment,
|
|
|
|
|
changeRuntimeState,
|
|
|
|
|
changeServiceState,
|
|
|
|
|
fetchSnapshot,
|
|
|
|
|
getApiBase,
|
|
|
|
|
invokeComponent,
|
|
|
|
|
publishWitPackage,
|
|
|
|
|
registerComponent,
|
|
|
|
|
saveApiBase,
|
|
|
|
|
type BackendSnapshot,
|
|
|
|
|
type InvokeResult,
|
|
|
|
|
} from "../lib/api";
|
|
|
|
|
import { isView, serviceKey, type ConnectionState, type Service, type View } from "../lib/model";
|
|
|
|
|
import { servicesFrom } from "../lib/view-state";
|
2026-07-30 09:43:08 +08:00
|
|
|
import { pageUrl, sendLifecycle, sendState } from "../sdk/host";
|
2026-07-30 09:21:55 +08:00
|
|
|
import {
|
|
|
|
|
isPageMessage,
|
2026-07-30 09:57:06 +08:00
|
|
|
type HostGlobalState,
|
2026-07-30 09:21:55 +08:00
|
|
|
type HostSharedState,
|
|
|
|
|
type PageCommand,
|
|
|
|
|
type RuntimeAction,
|
|
|
|
|
} from "../sdk/protocol";
|
|
|
|
|
import { DialogLayer, type DialogState } from "./dialogs";
|
2026-07-30 09:43:08 +08:00
|
|
|
import { FrameCache, type FrameEntry } from "./frame-cache";
|
2026-07-30 09:57:06 +08:00
|
|
|
import { createBrowserHostTabSync, type HostTabSync } from "./tab-sync";
|
2026-07-30 09:21:55 +08:00
|
|
|
|
|
|
|
|
const NAVIGATION: Array<{
|
|
|
|
|
id: View;
|
|
|
|
|
label: string;
|
|
|
|
|
icon: Component<LucideProps>;
|
2026-07-30 09:43:08 +08:00
|
|
|
keepAlive?: boolean;
|
2026-07-30 09:21:55 +08:00
|
|
|
}> = [
|
|
|
|
|
{ id: "overview", label: "运行概览", icon: LayoutDashboard },
|
2026-07-30 09:43:08 +08:00
|
|
|
{ id: "services", label: "服务版本", icon: Package, keepAlive: true },
|
2026-07-30 09:21:55 +08:00
|
|
|
{ id: "instances", label: "运行实例", icon: Server },
|
|
|
|
|
{ id: "wit-packages", label: "WIT 包", icon: FileCode2 },
|
|
|
|
|
{ id: "activity", label: "调用记录", icon: History },
|
2026-07-30 09:43:08 +08:00
|
|
|
{ id: "settings", label: "运行设置", icon: Settings, keepAlive: true },
|
2026-07-30 09:21:55 +08:00
|
|
|
];
|
2026-07-30 09:43:08 +08:00
|
|
|
const MAX_KEEP_ALIVE_IFRAMES = 3;
|
2026-07-30 09:21:55 +08:00
|
|
|
|
|
|
|
|
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.
|
2026-07-30 09:43:08 +08:00
|
|
|
const initial = initialView();
|
|
|
|
|
const frameCache = new FrameCache(initial, {
|
|
|
|
|
maxKeepAliveEntries: MAX_KEEP_ALIVE_IFRAMES,
|
|
|
|
|
shouldKeepAlive: keepsFrameAlive,
|
|
|
|
|
});
|
|
|
|
|
const [view, setView] = createSignal(initial);
|
|
|
|
|
const [frames, setFrames] = createSignal(frameCache.snapshot());
|
|
|
|
|
const [readyViews, setReadyViews] = createSignal<ReadonlySet<View>>(new Set<View>());
|
2026-07-30 09:21:55 +08:00
|
|
|
const [snapshot, setSnapshot] = createSignal<BackendSnapshot | null>(null);
|
|
|
|
|
const [connection, setConnection] = createSignal<ConnectionState>("connecting");
|
|
|
|
|
const [apiBase, setApiBase] = createSignal(getApiBase());
|
|
|
|
|
const [query, setQuery] = createSignal("");
|
|
|
|
|
const [runtimeAction, setRuntimeAction] = createSignal<RuntimeAction | null>(null);
|
|
|
|
|
const [serviceAction, setServiceAction] = createSignal<string | null>(null);
|
|
|
|
|
const [deploymentAction, setDeploymentAction] = createSignal<string | null>(null);
|
|
|
|
|
const [dialog, setDialog] = createSignal<DialogState | null>(null);
|
|
|
|
|
const [dialogBusy, setDialogBusy] = createSignal(false);
|
|
|
|
|
const [toast, setToast] = createSignal<string | null>(null);
|
2026-07-30 09:43:08 +08:00
|
|
|
const frameElements = new Map<View, HTMLIFrameElement>();
|
2026-07-30 09:57:06 +08:00
|
|
|
let tabSync: HostTabSync | null = null;
|
|
|
|
|
let applyingSharedHostState = false;
|
2026-07-30 09:21:55 +08:00
|
|
|
let toastTimer: number | undefined;
|
|
|
|
|
let refreshGeneration = 0;
|
|
|
|
|
|
2026-07-30 09:57:06 +08:00
|
|
|
const globalState = createMemo<HostGlobalState>(() => ({
|
2026-07-30 09:21:55 +08:00
|
|
|
snapshot: snapshot(),
|
|
|
|
|
connection: connection(),
|
|
|
|
|
apiBase: apiBase(),
|
|
|
|
|
runtimeAction: runtimeAction(),
|
|
|
|
|
serviceAction: serviceAction(),
|
|
|
|
|
deploymentAction: deploymentAction(),
|
|
|
|
|
}));
|
2026-07-30 09:57:06 +08:00
|
|
|
const state = createMemo<HostSharedState>(() => ({
|
|
|
|
|
...globalState(),
|
|
|
|
|
view: view(),
|
|
|
|
|
query: query(),
|
|
|
|
|
}));
|
2026-07-30 09:21:55 +08:00
|
|
|
const activeServices = createMemo(
|
|
|
|
|
() => servicesFrom(state()).filter((service) => service.status === "running").length,
|
|
|
|
|
);
|
2026-07-30 09:43:08 +08:00
|
|
|
const frameReady = createMemo(() => readyViews().has(view()));
|
2026-07-30 09:21:55 +08:00
|
|
|
const title = createMemo(
|
|
|
|
|
() => NAVIGATION.find((item) => item.id === view())?.label ?? "运行概览",
|
|
|
|
|
);
|
|
|
|
|
|
2026-07-30 09:43:08 +08:00
|
|
|
function stateFor(targetView: View): HostSharedState {
|
|
|
|
|
return { ...state(), view: targetView };
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-30 09:57:06 +08:00
|
|
|
function applySharedHostState(next: HostGlobalState) {
|
|
|
|
|
applyingSharedHostState = true;
|
|
|
|
|
refreshGeneration += 1;
|
|
|
|
|
batch(() => {
|
|
|
|
|
setSnapshot(next.snapshot);
|
|
|
|
|
setConnection(next.connection);
|
|
|
|
|
setApiBase(next.apiBase);
|
|
|
|
|
setRuntimeAction(next.runtimeAction);
|
|
|
|
|
setServiceAction(next.serviceAction);
|
|
|
|
|
setDeploymentAction(next.deploymentAction);
|
|
|
|
|
});
|
|
|
|
|
applyingSharedHostState = false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-30 09:21:55 +08:00
|
|
|
async function refresh() {
|
|
|
|
|
const generation = ++refreshGeneration;
|
|
|
|
|
try {
|
|
|
|
|
const next = await fetchSnapshot(apiBase());
|
|
|
|
|
if (generation !== refreshGeneration) return;
|
2026-07-30 09:57:06 +08:00
|
|
|
batch(() => {
|
|
|
|
|
setSnapshot(next);
|
|
|
|
|
setConnection("online");
|
|
|
|
|
});
|
2026-07-30 09:21:55 +08:00
|
|
|
} catch {
|
|
|
|
|
if (generation !== refreshGeneration) return;
|
2026-07-30 09:57:06 +08:00
|
|
|
batch(() => {
|
|
|
|
|
setSnapshot(null);
|
|
|
|
|
setConnection("offline");
|
|
|
|
|
});
|
2026-07-30 09:21:55 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function notify(message: string) {
|
|
|
|
|
if (toastTimer !== undefined) window.clearTimeout(toastTimer);
|
|
|
|
|
setToast(message);
|
|
|
|
|
toastTimer = window.setTimeout(() => setToast(null), 2800);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function navigate(next: View, pushHistory = true) {
|
|
|
|
|
if (next === view()) return;
|
2026-07-30 09:43:08 +08:00
|
|
|
|
|
|
|
|
const previous = view();
|
|
|
|
|
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
|
|
|
|
|
// 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");
|
|
|
|
|
|
|
|
|
|
setReadyViews((current) => {
|
|
|
|
|
const remaining = new Set(current);
|
|
|
|
|
for (const removed of removedViews) remaining.delete(removed);
|
|
|
|
|
return remaining;
|
|
|
|
|
});
|
|
|
|
|
setFrames(transition.entries);
|
2026-07-30 09:21:55 +08:00
|
|
|
setView(next);
|
|
|
|
|
setQuery("");
|
2026-07-30 09:43:08 +08:00
|
|
|
|
|
|
|
|
if (readyViews().has(next)) {
|
|
|
|
|
sendFrameState(next);
|
|
|
|
|
sendFrameLifecycle(next, "activate");
|
|
|
|
|
}
|
2026-07-30 09:21:55 +08:00
|
|
|
if (pushHistory) {
|
|
|
|
|
const url = new URL(window.location.href);
|
|
|
|
|
url.searchParams.set("view", next);
|
|
|
|
|
window.history.pushState({ view: next }, "", url);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function findService(key: string): Service | null {
|
|
|
|
|
return servicesFrom(state()).find((service) => serviceKey(service) === key) ?? null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function performRuntimeAction(action: RuntimeAction) {
|
|
|
|
|
setRuntimeAction(action);
|
|
|
|
|
try {
|
|
|
|
|
await changeRuntimeState(apiBase(), action);
|
|
|
|
|
await refresh();
|
|
|
|
|
notify(
|
|
|
|
|
action === "start"
|
|
|
|
|
? "Runtime 已启动"
|
|
|
|
|
: action === "stop"
|
|
|
|
|
? "Runtime 已停止"
|
|
|
|
|
: "Runtime 已重启",
|
|
|
|
|
);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
notify(error instanceof Error ? error.message : "Runtime 操作失败");
|
|
|
|
|
} finally {
|
|
|
|
|
setRuntimeAction(null);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function performServiceAction(key: string, action: "start" | "stop" | "restart") {
|
|
|
|
|
const service = findService(key);
|
|
|
|
|
if (!service) return;
|
|
|
|
|
setServiceAction(key);
|
|
|
|
|
try {
|
|
|
|
|
await changeServiceState(apiBase(), service, action);
|
|
|
|
|
await refresh();
|
|
|
|
|
notify(
|
|
|
|
|
`${service.id} ${action === "stop" ? "已停止" : action === "restart" ? "已重启" : "已启动"}`,
|
|
|
|
|
);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
notify(error instanceof Error ? error.message : "Actor 操作失败");
|
|
|
|
|
} finally {
|
|
|
|
|
setServiceAction(null);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleCommand(command: PageCommand) {
|
|
|
|
|
switch (command.type) {
|
|
|
|
|
case "refresh":
|
|
|
|
|
await refresh();
|
|
|
|
|
return;
|
|
|
|
|
case "navigate":
|
|
|
|
|
navigate(command.view);
|
|
|
|
|
return;
|
|
|
|
|
case "open-register":
|
|
|
|
|
setDialog({ type: "register" });
|
|
|
|
|
return;
|
|
|
|
|
case "open-wit-publish":
|
|
|
|
|
setDialog({ type: "wit-publish" });
|
|
|
|
|
return;
|
|
|
|
|
case "open-invoke": {
|
|
|
|
|
const service = findService(command.serviceKey);
|
|
|
|
|
if (service) setDialog({ type: "invoke", service });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
case "open-activate": {
|
|
|
|
|
const service = findService(command.serviceKey);
|
|
|
|
|
if (!service) return;
|
|
|
|
|
const currentRevision =
|
|
|
|
|
snapshot()?.deployments.find((item) => item.service_id === service.id)?.active_revision ??
|
|
|
|
|
null;
|
|
|
|
|
setDialog({ type: "activate", service, currentRevision });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
case "runtime-action":
|
|
|
|
|
await performRuntimeAction(command.action);
|
|
|
|
|
return;
|
|
|
|
|
case "service-action":
|
|
|
|
|
await performServiceAction(command.serviceKey, command.action);
|
|
|
|
|
return;
|
|
|
|
|
case "save-api-base":
|
|
|
|
|
try {
|
|
|
|
|
const next = saveApiBase(command.value);
|
|
|
|
|
setApiBase(next);
|
|
|
|
|
setSnapshot(null);
|
|
|
|
|
setConnection("connecting");
|
|
|
|
|
await refresh();
|
|
|
|
|
notify("API 地址已保存");
|
|
|
|
|
} catch (error) {
|
|
|
|
|
notify(error instanceof Error ? error.message : "API 地址无效");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const receive = (event: MessageEvent<unknown>) => {
|
2026-07-30 09:43:08 +08:00
|
|
|
if (event.origin !== window.location.origin || !isPageMessage(event.data)) return;
|
|
|
|
|
const sourceView = viewForSource(event.source);
|
|
|
|
|
if (!sourceView || event.data.view !== sourceView) return;
|
|
|
|
|
|
2026-07-30 09:21:55 +08:00
|
|
|
if (event.data.type === "ready") {
|
2026-07-30 09:43:08 +08:00
|
|
|
setReadyViews((current) => new Set(current).add(sourceView));
|
|
|
|
|
sendFrameState(sourceView);
|
|
|
|
|
sendFrameLifecycle(sourceView, sourceView === view() ? "activate" : "deactivate");
|
2026-07-30 09:21:55 +08:00
|
|
|
return;
|
|
|
|
|
}
|
2026-07-30 09:43:08 +08:00
|
|
|
// Hidden documents cannot initiate management operations. This matters for
|
|
|
|
|
// page-owned timers or libraries that may finish work after deactivation.
|
|
|
|
|
if (sourceView !== view()) return;
|
2026-07-30 09:21:55 +08:00
|
|
|
void handleCommand(event.data.command);
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-30 09:43:08 +08:00
|
|
|
function viewForSource(source: MessageEventSource | null): View | null {
|
|
|
|
|
for (const [frameView, element] of frameElements) {
|
|
|
|
|
if (source === element.contentWindow) return frameView;
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sendFrameState(targetView: View) {
|
|
|
|
|
const target = frameElements.get(targetView)?.contentWindow;
|
|
|
|
|
if (target) sendState(target, stateFor(targetView));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sendFrameLifecycle(targetView: View, phase: "activate" | "deactivate" | "dispose") {
|
|
|
|
|
const target = frameElements.get(targetView)?.contentWindow;
|
|
|
|
|
if (target) sendLifecycle(target, phase);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-30 09:21:55 +08:00
|
|
|
const popState = () => {
|
|
|
|
|
const requested = new URLSearchParams(window.location.search).get("view");
|
|
|
|
|
navigate(isView(requested) ? requested : "overview", false);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
onMount(() => {
|
|
|
|
|
window.addEventListener("message", receive);
|
|
|
|
|
window.addEventListener("popstate", popState);
|
2026-07-30 09:57:06 +08:00
|
|
|
tabSync = createBrowserHostTabSync({
|
|
|
|
|
getState: globalState,
|
|
|
|
|
applyState: applySharedHostState,
|
|
|
|
|
});
|
2026-07-30 09:21:55 +08:00
|
|
|
void refresh();
|
|
|
|
|
const interval = window.setInterval(() => void refresh(), 5000);
|
|
|
|
|
onCleanup(() => window.clearInterval(interval));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
onCleanup(() => {
|
|
|
|
|
window.removeEventListener("message", receive);
|
|
|
|
|
window.removeEventListener("popstate", popState);
|
2026-07-30 09:57:06 +08:00
|
|
|
tabSync?.close();
|
2026-07-30 09:21:55 +08:00
|
|
|
if (toastTimer !== undefined) window.clearTimeout(toastTimer);
|
2026-07-30 09:43:08 +08:00
|
|
|
for (const frameView of frameElements.keys()) sendFrameLifecycle(frameView, "dispose");
|
2026-07-30 09:21:55 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
createEffect(() => {
|
2026-07-30 09:43:08 +08:00
|
|
|
state();
|
|
|
|
|
const ready = readyViews();
|
|
|
|
|
for (const frame of frames()) {
|
|
|
|
|
if (ready.has(frame.view)) sendFrameState(frame.view);
|
|
|
|
|
}
|
2026-07-30 09:21:55 +08:00
|
|
|
});
|
|
|
|
|
|
2026-07-30 09:57:06 +08:00
|
|
|
createEffect(
|
|
|
|
|
on(
|
|
|
|
|
globalState,
|
|
|
|
|
(next) => {
|
|
|
|
|
if (!applyingSharedHostState) tabSync?.publish(next);
|
|
|
|
|
},
|
|
|
|
|
{ defer: true },
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
2026-07-30 09:21:55 +08:00
|
|
|
async function register(file: File) {
|
|
|
|
|
setDialogBusy(true);
|
|
|
|
|
try {
|
|
|
|
|
const registered = await registerComponent(apiBase(), file);
|
|
|
|
|
await refresh();
|
|
|
|
|
setDialog(null);
|
|
|
|
|
notify(`${registered.id}@${registered.revision} 已注册`);
|
|
|
|
|
} finally {
|
|
|
|
|
setDialogBusy(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function publishWit(file: File) {
|
|
|
|
|
setDialogBusy(true);
|
|
|
|
|
try {
|
|
|
|
|
const published = await publishWitPackage(apiBase(), file);
|
|
|
|
|
await refresh();
|
|
|
|
|
setDialog(null);
|
|
|
|
|
notify(`${published.name}@${published.version} 已发布`);
|
|
|
|
|
} finally {
|
|
|
|
|
setDialogBusy(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function activate(service: Service) {
|
|
|
|
|
setDialogBusy(true);
|
|
|
|
|
setDeploymentAction(serviceKey(service));
|
|
|
|
|
try {
|
|
|
|
|
await activateDeployment(apiBase(), service);
|
|
|
|
|
await refresh();
|
|
|
|
|
setDialog(null);
|
|
|
|
|
notify(`${service.id}@${service.revision} 已设为对外版本`);
|
|
|
|
|
} finally {
|
|
|
|
|
setDialogBusy(false);
|
|
|
|
|
setDeploymentAction(null);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function invoke(service: Service, input: Uint8Array): Promise<InvokeResult> {
|
|
|
|
|
const result = await invokeComponent(apiBase(), service, input);
|
|
|
|
|
await refresh();
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div class="grid h-dvh min-w-0 grid-cols-[minmax(0,1fr)] grid-rows-[auto_56px_minmax(0,1fr)_30px] overflow-hidden bg-canvas lg:grid-cols-[232px_minmax(0,1fr)] lg:grid-rows-[64px_minmax(0,1fr)_30px]">
|
|
|
|
|
<aside class="border-line min-w-0 bg-[#16221f] text-white lg:row-span-3 lg:flex lg:min-h-0 lg:flex-col lg:border-r">
|
|
|
|
|
<div class="flex h-16 shrink-0 items-center gap-3 px-4 lg:px-5">
|
|
|
|
|
<span class="flex size-9 items-center justify-center rounded-md bg-[#e5f4ef] text-[#155a46]">
|
|
|
|
|
<Boxes size={19} />
|
|
|
|
|
</span>
|
|
|
|
|
<div>
|
|
|
|
|
<strong class="block text-[15px]">Wasmeld</strong>
|
|
|
|
|
<span class="block text-[10px] text-[#9ab0aa]">COMPONENT RUNTIME</span>
|
|
|
|
|
</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}>
|
|
|
|
|
{(item) => {
|
|
|
|
|
const Icon = item.icon;
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
class="flex h-10 shrink-0 items-center gap-3 rounded-md px-3 text-sm text-[#b8c7c3] transition-colors hover:bg-white/7 hover:text-white lg:w-full"
|
|
|
|
|
classList={{ "bg-white/10 text-white": view() === item.id }}
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => navigate(item.id)}
|
|
|
|
|
>
|
|
|
|
|
<Icon size={16} />
|
|
|
|
|
{item.label}
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
}}
|
|
|
|
|
</For>
|
|
|
|
|
</nav>
|
|
|
|
|
<div class="hidden border-t border-white/10 p-4 lg:block">
|
|
|
|
|
<div class="flex items-center justify-between text-xs">
|
|
|
|
|
<span class="text-[#9ab0aa]">连接状态</span>
|
|
|
|
|
<span
|
|
|
|
|
classList={{
|
|
|
|
|
"font-semibold text-[#76d5b4]": connection() === "online",
|
|
|
|
|
"font-semibold text-[#ffac93]": connection() === "offline",
|
|
|
|
|
"font-semibold text-[#f5cf7a]": connection() === "connecting",
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{connection() === "online" ? "在线" : connection() === "offline" ? "离线" : "连接中"}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="mt-3 flex items-center justify-between text-xs">
|
|
|
|
|
<span class="text-[#9ab0aa]">活跃 Actor</span>
|
|
|
|
|
<strong>{activeServices()}</strong>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</aside>
|
|
|
|
|
|
|
|
|
|
<header class="border-line col-start-1 flex min-w-0 items-center justify-between gap-3 border-b bg-white px-4 lg:col-start-2 lg:px-6">
|
|
|
|
|
<div class="min-w-0">
|
|
|
|
|
<span class="hidden text-xs text-muted sm:inline">运行平台 / </span>
|
|
|
|
|
<strong class="text-sm">{title()}</strong>
|
|
|
|
|
</div>
|
|
|
|
|
<label class="relative w-full max-w-80">
|
|
|
|
|
<Search
|
|
|
|
|
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"
|
|
|
|
|
value={query()}
|
|
|
|
|
aria-label="搜索"
|
|
|
|
|
placeholder="搜索服务、制品或事件"
|
|
|
|
|
onInput={(event) => setQuery(event.currentTarget.value)}
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
<section class="relative col-start-1 min-h-0 min-w-0 overflow-hidden lg:col-start-2">
|
2026-07-30 09:43:08 +08:00
|
|
|
<For each={frames()}>
|
|
|
|
|
{(frame) => (
|
|
|
|
|
<PageFrame
|
|
|
|
|
frame={frame}
|
|
|
|
|
active={view() === frame.view}
|
|
|
|
|
title={titleFor(frame.view)}
|
|
|
|
|
register={(frameView, element) => frameElements.set(frameView, element)}
|
|
|
|
|
unregister={(frameView, element) => {
|
|
|
|
|
if (frameElements.get(frameView) === element) frameElements.delete(frameView);
|
2026-07-30 09:21:55 +08:00
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2026-07-30 09:43:08 +08:00
|
|
|
</For>
|
2026-07-30 09:21:55 +08:00
|
|
|
<Show when={!frameReady()}>
|
|
|
|
|
<div class="pointer-events-none absolute inset-0 flex items-center justify-center bg-canvas">
|
|
|
|
|
<div class="flex items-center gap-2 text-sm text-muted">
|
|
|
|
|
<span class="size-2 animate-pulse rounded-full bg-brand" />
|
|
|
|
|
正在加载 {title()}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</Show>
|
|
|
|
|
<Show when={connection() === "offline"}>
|
|
|
|
|
<div class="absolute bottom-4 left-1/2 flex -translate-x-1/2 items-center gap-2 rounded-md border border-coral-strong/20 bg-coral-soft px-4 py-2 text-xs font-semibold text-coral-strong shadow-lg">
|
|
|
|
|
<WifiOff size={14} />
|
|
|
|
|
管理 API 不可用
|
|
|
|
|
</div>
|
|
|
|
|
</Show>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<footer class="border-line col-start-1 flex items-center justify-between border-t bg-white px-4 text-[10px] text-muted lg:col-start-2 lg:px-6">
|
|
|
|
|
<span class="inline-flex items-center gap-1.5">
|
|
|
|
|
<ShieldCheck size={12} />
|
|
|
|
|
{snapshot()?.runtime.status === "running" ? "Sandbox 正常" : "Runtime 已停止"}
|
|
|
|
|
</span>
|
|
|
|
|
<span>Component Model · WASI P2</span>
|
|
|
|
|
</footer>
|
|
|
|
|
|
|
|
|
|
<Show when={dialog()}>
|
|
|
|
|
{(activeDialog) => (
|
|
|
|
|
<DialogLayer
|
|
|
|
|
dialog={activeDialog()}
|
|
|
|
|
busy={dialogBusy()}
|
|
|
|
|
onClose={() => !dialogBusy() && setDialog(null)}
|
|
|
|
|
onRegister={register}
|
|
|
|
|
onPublishWit={publishWit}
|
|
|
|
|
onActivate={activate}
|
|
|
|
|
onInvoke={invoke}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</Show>
|
|
|
|
|
|
|
|
|
|
<Show when={toast()}>
|
|
|
|
|
{(message) => (
|
|
|
|
|
<div class="fixed bottom-12 right-5 z-[70] max-w-sm rounded-md bg-[#16221f] px-4 py-3 text-sm text-white shadow-xl">
|
|
|
|
|
{message()}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</Show>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function initialView(): View {
|
|
|
|
|
const requested = new URLSearchParams(window.location.search).get("view");
|
|
|
|
|
return isView(requested) ? requested : "overview";
|
|
|
|
|
}
|
2026-07-30 09:43:08 +08:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
title: string;
|
|
|
|
|
register: (view: View, element: HTMLIFrameElement) => void;
|
|
|
|
|
unregister: (view: View, element: HTMLIFrameElement) => void;
|
|
|
|
|
}) {
|
|
|
|
|
let element: HTMLIFrameElement | undefined;
|
|
|
|
|
|
|
|
|
|
onCleanup(() => {
|
|
|
|
|
if (element) props.unregister(props.frame.view, element);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<iframe
|
|
|
|
|
ref={(next) => {
|
|
|
|
|
element = next;
|
|
|
|
|
props.register(props.frame.view, next);
|
|
|
|
|
}}
|
|
|
|
|
class="size-full border-0 bg-canvas"
|
|
|
|
|
classList={{ hidden: !props.active }}
|
|
|
|
|
src={pageUrl(props.frame.view, props.frame.instance)}
|
|
|
|
|
title={props.title}
|
|
|
|
|
aria-hidden={!props.active}
|
|
|
|
|
tabIndex={props.active ? 0 : -1}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|