fix(v02): compact workbench status panels (#838)
Co-authored-by: Codex Agent <codex@hwlab.local>
This commit is contained in:
@@ -16,6 +16,8 @@ import { SessionSidebar } from "./components/sessions/SessionSidebar";
|
||||
import { SettingsView } from "./components/settings/SettingsView";
|
||||
import { SkillsView } from "./components/skills/SkillsView";
|
||||
import { GateView } from "./components/gate/GateView";
|
||||
import { WorkbenchBuildSummary } from "./components/workbench/WorkbenchBuildSummary";
|
||||
import { WorkbenchProbe } from "./components/workbench/WorkbenchProbe";
|
||||
import { fetchText } from "./services/api/client";
|
||||
import { firstNonEmptyString } from "./utils";
|
||||
|
||||
@@ -127,8 +129,14 @@ export function App(): ReactElement {
|
||||
<header className="workspace-topbar" id="workspace-topbar" aria-label="主工作区状态栏">
|
||||
<button className="workspace-topbar-button" id="left-sidebar-toggle" type="button" aria-controls="session-sidebar" aria-expanded={sessionSidebarVisible} aria-label={leftCollapsed ? "展开左侧 Session sidebar" : "折叠左侧 Session sidebar"} title={leftCollapsed ? "展开左侧 Session sidebar" : "折叠左侧 Session sidebar"} disabled={!workspaceRoute} onClick={() => setLeftCollapsed((value) => !value)}>{leftCollapsed ? "Sessions" : "隐藏 Sessions"}</button>
|
||||
<div className="workspace-topbar-status">
|
||||
<span>{routeLabels[route]}</span>
|
||||
<span className="workspace-route-label">{routeLabels[route]}</span>
|
||||
<StatePill tone={store.state.chatPending ? "pending" : "source"}>{store.state.chatPending ? "处理中" : modelLabel}</StatePill>
|
||||
{workspaceRoute ? (
|
||||
<div className="topbar-status-tools" aria-label="工作区实时状态">
|
||||
<WorkbenchProbe live={store.state.live} />
|
||||
<WorkbenchBuildSummary live={store.state.live} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<button className="workspace-topbar-button" id="right-sidebar-toggle" type="button" aria-controls="device-pod-sidebar" aria-expanded={devicePodSidebarVisible} aria-label={rightCollapsed ? "展开右侧 Device Pod 看板" : "折叠右侧 Device Pod 看板"} title={rightCollapsed ? "展开右侧 Device Pod 看板" : "折叠右侧 Device Pod 看板"} disabled={!workspaceRoute} onClick={() => setRightCollapsed((value) => !value)}>{rightCollapsed ? "Device Pod" : "隐藏 Device Pod"}</button>
|
||||
</header>
|
||||
|
||||
@@ -5,8 +5,6 @@ import { devicePodsFromLive } from "../../state/workbench";
|
||||
import type { DevicePodEvent, DevicePodInterface, DevicePodItem, DevicePodStatusResponse, LiveSurface } from "../../types/domain";
|
||||
import { formatTimestamp, jsonPreview, shortToken, toneClass } from "../../utils";
|
||||
import { StateTag } from "../shared/StateTag";
|
||||
import { WorkbenchBuildSummary } from "../workbench/WorkbenchBuildSummary";
|
||||
import { WorkbenchProbe } from "../workbench/WorkbenchProbe";
|
||||
|
||||
interface DevicePodSidebarProps {
|
||||
live: LiveSurface | null;
|
||||
@@ -26,8 +24,6 @@ export function DevicePodSidebar({ live, selectedDevicePodId, onSelect, collapse
|
||||
return (
|
||||
<aside className={`right-sidebar${collapsed ? " is-device-pod-collapsed" : ""}`} id="device-pod-sidebar" aria-label="Device Pod 摘要看板" aria-hidden={collapsed}>
|
||||
<div className="right-sidebar-content">
|
||||
<WorkbenchProbe live={live} />
|
||||
<WorkbenchBuildSummary live={live} />
|
||||
<header className="right-sidebar-head">
|
||||
<div>
|
||||
<p className="eyebrow">Device Pod</p>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { ReactElement } from "react";
|
||||
import type { HTMLAttributes, ReactElement, ReactNode } from "react";
|
||||
import type { Tone } from "../../types/domain";
|
||||
|
||||
interface StateTagProps {
|
||||
interface StateTagProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
tone: Tone | string;
|
||||
children: React.ReactNode;
|
||||
children: ReactNode;
|
||||
id?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function StateTag({ tone, children, id, title }: StateTagProps): ReactElement {
|
||||
return <span id={id} title={title} className={`state-tag tone-${tone}`}>{children}</span>;
|
||||
export function StateTag({ tone, children, id, title, className, ...attributes }: StateTagProps): ReactElement {
|
||||
return <span id={id} title={title} className={`state-tag tone-${tone}${className ? ` ${className}` : ""}`} {...attributes}>{children}</span>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface WorkbenchDialogProps {
|
||||
id?: string;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function WorkbenchDialog({ id, title, children, onClose }: WorkbenchDialogProps) {
|
||||
return (
|
||||
<div className="workbench-dialog-layer" role="presentation" onClick={onClose}>
|
||||
<section
|
||||
id={id}
|
||||
className="workbench-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header className="workbench-dialog-head">
|
||||
<h2>{title}</h2>
|
||||
<button className="workbench-dialog-close" type="button" aria-label="关闭" onClick={onClose}>×</button>
|
||||
</header>
|
||||
<div className="workbench-dialog-content">{children}</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { ReactElement } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { LiveProbePayload, LiveSurface } from "../../types/domain";
|
||||
import { formatBeijingTime, formatDuration, shortToken, toneClass } from "../../utils";
|
||||
import type { LiveBuildRecord, LiveProbePayload, LiveSurface, Tone } from "../../types/domain";
|
||||
import { asRecord, firstNonEmptyString, formatBeijingTime, formatDuration, shortToken, toneClass } from "../../utils";
|
||||
import { StateTag } from "../shared/StateTag";
|
||||
import { WorkbenchDialog } from "../shared/WorkbenchDialog";
|
||||
|
||||
interface WorkbenchBuildSummaryProps {
|
||||
live: LiveSurface | null;
|
||||
@@ -13,68 +15,228 @@ interface ServiceRow {
|
||||
label: string;
|
||||
serviceId: string | null;
|
||||
commitShort: string | null;
|
||||
actualCommit: string | null;
|
||||
actualCommitShort: string | null;
|
||||
envCommitShort: string | null;
|
||||
revisionShort: string | null;
|
||||
tag: string | null;
|
||||
envImageTag: string | null;
|
||||
imageDigestShort: string | null;
|
||||
builtAt: string | null;
|
||||
status: "ok" | "pending" | "blocked" | "source" | "warn";
|
||||
status: Tone;
|
||||
reason: string | null;
|
||||
source: string | null;
|
||||
rollout: string | null;
|
||||
}
|
||||
|
||||
export function WorkbenchBuildSummary({ live }: WorkbenchBuildSummaryProps): ReactElement {
|
||||
const data = live?.healthLive.data;
|
||||
const service = data?.service;
|
||||
const build = data?.build;
|
||||
const commit = data?.commit;
|
||||
const image = data?.image;
|
||||
const row = buildRowFromLive(service, commit, image, build);
|
||||
const overall = row.status;
|
||||
const [open, setOpen] = useState(false);
|
||||
const rows = buildRows(live);
|
||||
const latest = latestRow(live, rows);
|
||||
const overall = rows.some((row) => row.status === "ok") ? "ok" : rows.some((row) => row.status === "blocked") ? "blocked" : "pending";
|
||||
const latestText = latestSummaryText(latest);
|
||||
const countsText = countsSummary(live?.liveBuilds.data?.counts);
|
||||
|
||||
return (
|
||||
<section className={`live-build-summary live-build-summary-${overall} tone-border-${toneClass(overall)}`} data-live-build-summary>
|
||||
<header className="live-build-summary-head">
|
||||
<p className="eyebrow">最新镜像构建时间</p>
|
||||
<StateTag tone={toneClass(overall)} data-live-build-tone>{row.builtAt ? formatBeijingTime(row.builtAt, { withSeconds: true }) : "构建时间不可用"}</StateTag>
|
||||
</header>
|
||||
<ul className="live-build-list" role="list">
|
||||
<li className="live-build-row" role="listitem" data-live-build-row={row.key}>
|
||||
<div className="live-build-row-head">
|
||||
<span className={`live-build-tone tone-${toneClass(overall)}`} aria-hidden="true">●</span>
|
||||
<span className="live-build-service">{row.label}</span>
|
||||
<span className="live-build-time">{row.builtAt ? formatBeijingTime(row.builtAt, { withSeconds: true }) : "构建时间不可用"}</span>
|
||||
<section className={`live-build-summary live-build-summary-compact live-build-summary-${overall} tone-border-${toneClass(overall)}`} data-live-build-summary>
|
||||
<details id="live-build-summary" className="live-build-details" open={false}>
|
||||
<summary id="live-build-latest" className="live-build-summary-line" onClick={(event) => event.preventDefault()}>
|
||||
<button
|
||||
id="live-build-toggle"
|
||||
className="status-chip-button live-build-summary-button"
|
||||
type="button"
|
||||
aria-haspopup="dialog"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="status-chip-label">最新镜像构建时间</span>
|
||||
<StateTag tone={toneClass(overall)} data-live-build-tone>{latest.builtAt ? formatBeijingTime(latest.builtAt, { withSeconds: true }) : "构建时间不可用"}</StateTag>
|
||||
<span className="status-chip-meta">{latestText}</span>
|
||||
</button>
|
||||
</summary>
|
||||
<ul id="live-build-list" className="live-build-list" role="list" hidden>
|
||||
{rows.map((row) => <BuildRowItem key={row.key} row={row} />)}
|
||||
</ul>
|
||||
</details>
|
||||
{open ? (
|
||||
<WorkbenchDialog id="live-build-dialog" title="构建版本明细" onClose={() => setOpen(false)}>
|
||||
<div className="live-build-dialog-summary">
|
||||
<p>{latestText}</p>
|
||||
{countsText ? <p>{countsText}</p> : null}
|
||||
</div>
|
||||
<div className="live-build-meta">
|
||||
<span>tag {row.tag ?? "unknown"}</span>
|
||||
<span>commit {row.commitShort ?? "unknown"}</span>
|
||||
<span>revision {row.revisionShort ?? "unknown"}</span>
|
||||
{row.source ? <span>来源 {row.source}</span> : null}
|
||||
</div>
|
||||
{row.reason ? <p className="live-build-reason">{row.reason}</p> : null}
|
||||
</li>
|
||||
</ul>
|
||||
<ul className="live-build-list live-build-list-dialog" role="list">
|
||||
{rows.map((row) => <BuildRowItem key={row.key} row={row} />)}
|
||||
</ul>
|
||||
</WorkbenchDialog>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function buildRowFromLive(service: LiveProbePayload["service"] | undefined, commit: LiveProbePayload["commit"] | undefined, image: LiveProbePayload["image"] | undefined, build: LiveProbePayload["build"] | undefined): ServiceRow {
|
||||
const label = (service && typeof service === "object" && "id" in service && typeof service.id === "string") ? service.id : "cloud-api";
|
||||
const serviceId = (service && typeof service === "object" && "id" in service && typeof service.id === "string") ? service.id : null;
|
||||
const commitShort = (commit && typeof commit === "object" && "id" in commit && typeof commit.id === "string") ? shortToken(commit.id, 12) : null;
|
||||
const revisionShort = (commit && typeof commit === "object" && "id" in commit && typeof commit.id === "string") ? shortToken(commit.id, 12) : null;
|
||||
const tag = (image && typeof image === "object" && "tag" in image && typeof image.tag === "string") ? image.tag : null;
|
||||
const builtAt = (build && typeof build === "object" && "createdAt" in build && typeof build.createdAt === "string") ? build.createdAt : null;
|
||||
const unavailableReason = (build && typeof build === "object" && "unavailableReason" in build && typeof build.unavailableReason === "string") ? build.unavailableReason : null;
|
||||
const source = (build && typeof build === "object" && "metadataSource" in build && typeof build.metadataSource === "string") ? build.metadataSource : null;
|
||||
function buildRows(live: LiveSurface | null): ServiceRow[] {
|
||||
const services = live?.liveBuilds.data?.services;
|
||||
if (services?.length) return services.map(rowFromLiveBuildRecord);
|
||||
const data = live?.healthLive.data;
|
||||
return [buildRowFromLive(data?.service, data?.commit, data?.image, data?.build, data?.revision, "health-live")];
|
||||
}
|
||||
|
||||
function latestRow(live: LiveSurface | null, rows: ServiceRow[]): ServiceRow {
|
||||
const latest = live?.liveBuilds.data?.latest;
|
||||
if (latest) return rowFromLiveBuildRecord(latest);
|
||||
return rows.find((row) => row.builtAt) ?? rows[0] ?? emptyRow();
|
||||
}
|
||||
|
||||
function rowFromLiveBuildRecord(record: LiveBuildRecord): ServiceRow {
|
||||
const build = asRecord(record.build);
|
||||
const image = asRecord(record.image);
|
||||
const commit = asRecord(record.commit);
|
||||
const desiredState = asRecord(record.desiredState);
|
||||
const liveMetadataMatch = asRecord(build?.liveMetadataMatch);
|
||||
const metadata = asRecord(liveMetadataMatch?.metadata);
|
||||
const serviceId = firstNonEmptyString(record.serviceId, record.name);
|
||||
const label = serviceId ?? "unknown-service";
|
||||
const actualCommit = firstNonEmptyString(commit?.id, record.revision);
|
||||
const envCommit = firstNonEmptyString(metadata?.commitId, metadata?.revision);
|
||||
const builtAt = firstNonEmptyString(build?.createdAt);
|
||||
const tag = firstNonEmptyString(image?.tag);
|
||||
const reason = firstNonEmptyString(build?.unavailableReason, record.reason);
|
||||
const source = sourceLabel(firstNonEmptyString(build?.metadataSource), record.healthUrl);
|
||||
return {
|
||||
key: label,
|
||||
key: serviceId ?? `${record.kind ?? "service"}-${tag ?? label}`,
|
||||
label,
|
||||
serviceId,
|
||||
commitShort,
|
||||
revisionShort,
|
||||
commitShort: shortNullable(actualCommit, 12),
|
||||
actualCommit,
|
||||
actualCommitShort: shortNullable(actualCommit, 12),
|
||||
envCommitShort: shortNullable(envCommit, 12),
|
||||
revisionShort: shortNullable(record.revision, 12),
|
||||
tag,
|
||||
envImageTag: envImageLabel(tag, build?.source),
|
||||
imageDigestShort: shortNullable(image?.digest, 18),
|
||||
builtAt,
|
||||
status: builtAt ? "ok" : service ? "pending" : "blocked",
|
||||
reason: unavailableReason ?? null,
|
||||
source
|
||||
status: builtAt ? "ok" : reason ? "warn" : toneClass(record.status),
|
||||
reason,
|
||||
source,
|
||||
rollout: rolloutLabel(desiredState)
|
||||
};
|
||||
}
|
||||
|
||||
function buildRowFromLive(service: unknown, commit: unknown, image: unknown, build: unknown, revision: unknown, keyFallback: string): ServiceRow {
|
||||
const serviceRecord = asRecord(service);
|
||||
const commitRecord = asRecord(commit);
|
||||
const imageRecord = asRecord(image);
|
||||
const buildRecord = asRecord(build);
|
||||
const label = firstNonEmptyString(serviceRecord?.id, serviceRecord?.serviceId) ?? "cloud-api";
|
||||
const actualCommit = firstNonEmptyString(commitRecord?.id, revision);
|
||||
const builtAt = firstNonEmptyString(buildRecord?.createdAt);
|
||||
const tag = firstNonEmptyString(imageRecord?.tag);
|
||||
const reason = firstNonEmptyString(buildRecord?.unavailableReason);
|
||||
return {
|
||||
key: label ?? keyFallback,
|
||||
label,
|
||||
serviceId: label,
|
||||
commitShort: shortNullable(actualCommit, 12),
|
||||
actualCommit,
|
||||
actualCommitShort: shortNullable(actualCommit, 12),
|
||||
envCommitShort: null,
|
||||
revisionShort: shortNullable(revision, 12),
|
||||
tag,
|
||||
envImageTag: tag,
|
||||
imageDigestShort: shortNullable(imageRecord?.digest, 18),
|
||||
builtAt,
|
||||
status: builtAt ? "ok" : serviceRecord ? "pending" : "blocked",
|
||||
reason,
|
||||
source: sourceLabel(firstNonEmptyString(buildRecord?.metadataSource), "/health/live"),
|
||||
rollout: null
|
||||
};
|
||||
}
|
||||
|
||||
function BuildRowItem({ row }: { row: ServiceRow }): ReactElement {
|
||||
return (
|
||||
<li className="live-build-row" role="listitem" data-live-build-row={row.key}>
|
||||
<div className="live-build-row-head">
|
||||
<span className={`live-build-tone tone-${toneClass(row.status)}`} aria-hidden="true">●</span>
|
||||
<span className="live-build-service">{row.label}</span>
|
||||
<span className="live-build-time">{row.builtAt ? formatBeijingTime(row.builtAt, { withSeconds: true }) : "构建时间不可用"}</span>
|
||||
</div>
|
||||
<div className="live-build-meta">
|
||||
<span>tag {row.tag ?? "unknown"}</span>
|
||||
<span>env 镜像 {row.envImageTag ?? "unknown"}</span>
|
||||
<span>实际 commit {row.actualCommitShort ?? row.commitShort ?? "unknown"}</span>
|
||||
<span>commit {row.commitShort ?? "unknown"}</span>
|
||||
<span>env commit {row.envCommitShort ?? "unknown"}</span>
|
||||
<span>revision {row.revisionShort ?? "unknown"}</span>
|
||||
{row.imageDigestShort ? <span>digest {row.imageDigestShort}</span> : null}
|
||||
{row.source ? <span>来源 {row.source}</span> : null}
|
||||
{row.rollout ? <span>rollout {row.rollout}</span> : null}
|
||||
</div>
|
||||
{row.reason ? <p className="live-build-reason">{row.reason}</p> : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function latestSummaryText(row: ServiceRow): string {
|
||||
const time = row.builtAt ? `${formatBeijingTime(row.builtAt, { withSeconds: true })} 北京时间` : "构建时间不可用";
|
||||
return `最新镜像构建时间:${time} · ${row.label} · tag ${row.tag ?? "unknown"} · commit ${row.commitShort ?? "unknown"} · env ${row.envImageTag ?? row.envCommitShort ?? "unknown"} · actual ${row.actualCommitShort ?? "unknown"} · revision ${row.revisionShort ?? "unknown"}`;
|
||||
}
|
||||
|
||||
function countsSummary(counts: Record<string, unknown> | null | undefined): string | null {
|
||||
if (!counts) return null;
|
||||
const total = counts.total;
|
||||
const withBuildTime = counts.withBuildTime;
|
||||
const unavailable = counts.unavailable;
|
||||
const external = counts.external;
|
||||
return `服务 ${total ?? "unknown"} 个,可见构建时间 ${withBuildTime ?? "unknown"} 个,缺失 ${unavailable ?? "unknown"} 个,外部 ${external ?? "unknown"} 个`;
|
||||
}
|
||||
|
||||
function sourceLabel(metadataSource: string | null, healthUrl: unknown): string | null {
|
||||
const parts = [];
|
||||
if (firstNonEmptyString(healthUrl)) parts.push("live health");
|
||||
if (metadataSource) parts.push(metadataSource);
|
||||
return parts.length ? parts.join(" / ") : null;
|
||||
}
|
||||
|
||||
function rolloutLabel(desiredState: Record<string, unknown> | null): string | null {
|
||||
if (!desiredState) return null;
|
||||
const namespace = firstNonEmptyString(desiredState.namespace);
|
||||
const profile = firstNonEmptyString(desiredState.profile);
|
||||
const replicas = desiredState.replicas;
|
||||
const source = firstNonEmptyString(desiredState.source);
|
||||
return [namespace, profile, Number.isFinite(Number(replicas)) ? `${replicas} replicas` : null, source].filter(Boolean).join(" / ") || null;
|
||||
}
|
||||
|
||||
function envImageLabel(tag: string | null, source: unknown): string | null {
|
||||
const sourceText = firstNonEmptyString(source);
|
||||
const envFromSource = sourceText?.match(/@env:([^\s/]+)/u)?.[1] ?? null;
|
||||
return firstNonEmptyString(tag, envFromSource);
|
||||
}
|
||||
|
||||
function shortNullable(value: unknown, length: number): string | null {
|
||||
const text = firstNonEmptyString(value);
|
||||
return text ? shortToken(text, length) : null;
|
||||
}
|
||||
|
||||
function emptyRow(): ServiceRow {
|
||||
return {
|
||||
key: "unknown",
|
||||
label: "unknown-service",
|
||||
serviceId: null,
|
||||
commitShort: null,
|
||||
actualCommit: null,
|
||||
actualCommitShort: null,
|
||||
envCommitShort: null,
|
||||
revisionShort: null,
|
||||
tag: null,
|
||||
envImageTag: null,
|
||||
imageDigestShort: null,
|
||||
builtAt: null,
|
||||
status: "pending",
|
||||
reason: null,
|
||||
source: null,
|
||||
rollout: null
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { ReactElement } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { LiveProbePayload, LiveSurface } from "../../types/domain";
|
||||
import { formatBeijingTime, shortToken, toneClass } from "../../utils";
|
||||
import { StateTag } from "../shared/StateTag";
|
||||
import { WorkbenchDialog } from "../shared/WorkbenchDialog";
|
||||
|
||||
interface WorkbenchProbeProps {
|
||||
live: LiveSurface | null;
|
||||
@@ -27,6 +29,7 @@ interface ProbeResult {
|
||||
}
|
||||
|
||||
export function WorkbenchProbe({ live }: WorkbenchProbeProps): ReactElement {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rows: ProbeRow[] = [
|
||||
rowFromProbe("cloud-api-live", "Cloud API 实况身份", "GET", "/health/live", live?.healthLive ?? null),
|
||||
rowFromProbe("cloud-api-health", "Cloud API health", "GET", "/health", live?.health ?? null),
|
||||
@@ -34,33 +37,44 @@ export function WorkbenchProbe({ live }: WorkbenchProbeProps): ReactElement {
|
||||
rowFromProbe("adapter", "Codex app-server adapter", "POST", "/v1/rpc/system.health", live?.adapter ?? null)
|
||||
];
|
||||
const tone = rows.every((row) => row.status === "ok") ? "ok" : rows.some((row) => row.status === "blocked") ? "blocked" : "pending";
|
||||
const okCount = rows.filter((row) => row.status === "ok").length;
|
||||
const checkedAt = live?.loadedAt ? formatBeijingTime(live.loadedAt, { withSeconds: true }) : "未观测";
|
||||
return (
|
||||
<section className={`probe-card probe-card-${tone} tone-border-${toneClass(tone)}`} data-probe-card>
|
||||
<header className="probe-card-head">
|
||||
<p className="eyebrow">工作区状态</p>
|
||||
<section className={`probe-card probe-card-compact probe-card-${tone} tone-border-${toneClass(tone)}`} data-probe-card>
|
||||
<button className="status-chip-button probe-summary-button" type="button" aria-haspopup="dialog" onClick={() => setOpen(true)}>
|
||||
<span className="status-chip-label">工作区状态</span>
|
||||
<StateTag tone={toneClass(tone)} data-probe-card-tone>{tone === "ok" ? "健康" : tone === "blocked" ? "阻塞" : "等待验证"}</StateTag>
|
||||
</header>
|
||||
<ul className="probe-list" role="list">
|
||||
{rows.map((row) => (
|
||||
<li key={row.key} className="probe-row" role="listitem" data-probe-row={row.key}>
|
||||
<div className="probe-row-head">
|
||||
<span className={`probe-tone tone-${toneClass(row.status)}`} aria-hidden="true">●</span>
|
||||
<span className="probe-row-label">{row.label}</span>
|
||||
<span className={`probe-row-status tone-${toneClass(row.status)}`}>{row.status === "ok" ? "通过" : row.status === "blocked" ? "阻塞" : "等待"}</span>
|
||||
</div>
|
||||
<div className="probe-row-meta">
|
||||
<span className="probe-row-method">{row.method}</span>
|
||||
<span className="probe-row-path mono">{row.path}</span>
|
||||
<span className="probe-row-time">{row.lastCheckedAt ? formatBeijingTime(row.lastCheckedAt, { withSeconds: true }) : "未观测"}</span>
|
||||
</div>
|
||||
<p className="probe-row-detail">{row.detail}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<span className="status-chip-meta">{okCount}/{rows.length} · {checkedAt}</span>
|
||||
</button>
|
||||
{open ? (
|
||||
<WorkbenchDialog id="probe-status-dialog" title="工作区状态明细" onClose={() => setOpen(false)}>
|
||||
<ul className="probe-list probe-list-dialog" role="list">
|
||||
{rows.map((row) => <ProbeListItem key={row.key} row={row} />)}
|
||||
</ul>
|
||||
</WorkbenchDialog>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ProbeListItem({ row }: { row: ProbeRow }): ReactElement {
|
||||
return (
|
||||
<li className="probe-row" role="listitem" data-probe-row={row.key}>
|
||||
<div className="probe-row-head">
|
||||
<span className={`probe-tone tone-${toneClass(row.status)}`} aria-hidden="true">●</span>
|
||||
<span className="probe-row-label">{row.label}</span>
|
||||
<span className={`probe-row-status tone-${toneClass(row.status)}`}>{row.status === "ok" ? "通过" : row.status === "blocked" ? "阻塞" : "等待"}</span>
|
||||
</div>
|
||||
<div className="probe-row-meta">
|
||||
<span className="probe-row-method">{row.method}</span>
|
||||
<span className="probe-row-path mono">{row.path}</span>
|
||||
<span className="probe-row-time">{row.lastCheckedAt ? formatBeijingTime(row.lastCheckedAt, { withSeconds: true }) : "未观测"}</span>
|
||||
</div>
|
||||
<p className="probe-row-detail">{row.detail}</p>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function rowFromProbe(key: string, label: string, method: string, path: string, probe: ProbeResult | null): ProbeRow {
|
||||
const data = probe?.data ?? null;
|
||||
const ok = Boolean(probe?.ok) && (data?.ready === true || data?.status === "ok" || data?.status === "ready");
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
DevicePodEventsResponse,
|
||||
DevicePodListResponse,
|
||||
DevicePodStatusResponse,
|
||||
LiveBuildsPayload,
|
||||
LiveProbePayload,
|
||||
SkillsResponse,
|
||||
SkillFileResponse,
|
||||
@@ -195,6 +196,7 @@ export const api = {
|
||||
}),
|
||||
logout: (): Promise<ApiResult<{ ok?: boolean }>> => fetchJson("/auth/logout", { method: "POST", body: JSON.stringify({}) }),
|
||||
healthLive: (): Promise<ApiResult<LiveProbePayload>> => fetchJson("/health/live", { timeoutMs: 12000, timeoutName: "health/live" }),
|
||||
liveBuilds: (): Promise<ApiResult<LiveBuildsPayload>> => fetchJson("/v1/live-builds", { timeoutMs: 12000, timeoutName: "live builds" }),
|
||||
health: (): Promise<ApiResult<LiveProbePayload>> => fetchJson("/health", { timeoutMs: 12000, timeoutName: "health" }),
|
||||
restIndex: (): Promise<ApiResult<LiveProbePayload>> => fetchJson("/v1", { timeoutMs: 12000, timeoutName: "REST index" }),
|
||||
adapter: (): Promise<ApiResult<LiveProbePayload>> => fetchJson("/v1/rpc/system.health", { method: "POST", body: JSON.stringify({}), timeoutMs: 12000, timeoutName: "adapter" }),
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
DevicePodItem,
|
||||
DevicePodListResponse,
|
||||
DevicePodStatusResponse,
|
||||
LiveBuildsPayload,
|
||||
LiveSurface,
|
||||
ProviderProfile,
|
||||
SessionTab,
|
||||
@@ -158,19 +159,20 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
|
||||
|
||||
const refreshLive = useCallback(async (devicePodId?: string) => {
|
||||
const requestedDevicePodId = devicePodId ?? state.selectedDevicePodId;
|
||||
const [healthLive, health, restIndex, adapter, devicePods] = await Promise.all([
|
||||
const [healthLive, health, restIndex, adapter, devicePods, liveBuilds] = await Promise.all([
|
||||
api.healthLive(),
|
||||
api.health(),
|
||||
api.restIndex(),
|
||||
api.adapter(),
|
||||
api.devicePods()
|
||||
api.devicePods(),
|
||||
api.liveBuilds()
|
||||
]);
|
||||
const visibleDevicePods = devicePodsFromResult(devicePods);
|
||||
const selected = selectVisibleDevicePodId(devicePods.data, visibleDevicePods, requestedDevicePodId);
|
||||
const [devicePodStatus, devicePodEvents] = selected
|
||||
? await Promise.all([api.devicePodStatus(selected), api.devicePodEvents(selected)])
|
||||
: [null, null] satisfies [ApiResult<DevicePodStatusResponse> | null, ApiResult<DevicePodEventsResponse> | null];
|
||||
const live: LiveSurface = { healthLive, health, restIndex, adapter, devicePods, devicePodStatus, devicePodEvents, loadedAt: new Date().toISOString() };
|
||||
const live: LiveSurface = { healthLive, health, restIndex, adapter, devicePods, devicePodStatus, devicePodEvents, liveBuilds, loadedAt: new Date().toISOString() };
|
||||
dispatch({ type: "live:set", live, availability: availabilityFromLive(live), selectedDevicePodId: selected });
|
||||
}, [state.selectedDevicePodId]);
|
||||
|
||||
|
||||
@@ -45,8 +45,15 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
|
||||
.workspace-topbar { min-width: 0; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 8px; padding: 4px 8px; border-bottom: 1px solid var(--line); background: #fbfcf8; }
|
||||
.workspace-topbar-button { min-height: 26px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface); color: var(--ink); padding: 3px 8px; font-size: 12px; font-weight: 800; }
|
||||
.workspace-topbar-button:disabled { opacity: 0.35; }
|
||||
.workspace-topbar-status { min-width: 0; display: flex; align-items: center; justify-content: center; gap: 8px; color: var(--muted); font-size: 12px; font-weight: 800; }
|
||||
.workspace-topbar-status { min-width: 0; display: flex; align-items: center; justify-content: center; gap: 8px; color: var(--muted); font-size: 12px; font-weight: 800; overflow: hidden; }
|
||||
.workspace-route-label { flex: 0 0 auto; white-space: nowrap; }
|
||||
.workspace-topbar-pill { display: inline-flex; min-height: 22px; align-items: center; border-radius: 999px; padding: 2px 8px; font-size: 11px; font-weight: 800; }
|
||||
.topbar-status-tools { min-width: 0; display: flex; align-items: center; justify-content: center; gap: 6px; overflow: hidden; }
|
||||
.status-chip-button { max-width: min(38vw, 480px); min-height: 26px; display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface); color: var(--ink); padding: 2px 7px; font-size: 12px; font-weight: 800; overflow: hidden; white-space: nowrap; }
|
||||
.status-chip-button:hover, .status-chip-button:focus-visible { border-color: var(--accent); outline: 0; }
|
||||
.status-chip-button .state-tag { min-height: 18px; padding: 1px 6px; font-size: 11px; white-space: nowrap; }
|
||||
.status-chip-label { flex: 0 0 auto; }
|
||||
.status-chip-meta { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; }
|
||||
.workspace-body { min-width: 0; min-height: 0; display: grid; grid-template-columns: var(--session-sidebar-width) var(--resize-handle-width) minmax(420px, 1fr) var(--resize-handle-width) var(--right-sidebar-width); grid-template-rows: minmax(0, 1fr); overflow: hidden; }
|
||||
.workbench-shell.is-left-sidebar-collapsed .workspace-body { grid-template-columns: 0 0 minmax(420px, 1fr) var(--resize-handle-width) var(--right-sidebar-width); }
|
||||
.workbench-shell.is-right-sidebar-collapsed .workspace-body { grid-template-columns: var(--session-sidebar-width) var(--resize-handle-width) minmax(420px, 1fr) 0 0; }
|
||||
@@ -131,8 +138,10 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
|
||||
.message-runtime-summary { color: var(--muted); font-size: 12px; }
|
||||
.message-session-summary { color: var(--muted); font-size: 12px; }
|
||||
.probe-card { display: grid; gap: 8px; padding: 10px 12px; border-radius: 8px; background: #fbfcf8; }
|
||||
.probe-card-compact { min-width: 0; display: block; padding: 0; border-radius: 0; background: transparent; }
|
||||
.probe-card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.probe-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; }
|
||||
.probe-list-dialog { max-height: min(56vh, 560px); overflow: auto; padding-right: 2px; }
|
||||
.probe-row { display: grid; gap: 4px; padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface); }
|
||||
.probe-row-head { display: flex; align-items: center; gap: 8px; }
|
||||
.probe-tone { font-size: 12px; }
|
||||
@@ -144,8 +153,17 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
|
||||
.probe-row-time { color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
.probe-row-detail { margin: 0; font-size: 12px; overflow-wrap: anywhere; }
|
||||
.live-build-summary { display: grid; gap: 8px; padding: 10px 12px; border-radius: 8px; background: #fbfcf8; }
|
||||
.live-build-summary-compact { min-width: 0; display: block; padding: 0; border-radius: 0; background: transparent; }
|
||||
.live-build-details { min-width: 0; display: block; }
|
||||
.live-build-details > summary { min-width: 0; display: block; list-style: none; }
|
||||
.live-build-details > summary::-webkit-details-marker { display: none; }
|
||||
.live-build-summary-line { cursor: default; }
|
||||
.live-build-summary-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.live-build-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; }
|
||||
.live-build-list[hidden] { display: none; }
|
||||
.live-build-list-dialog { max-height: min(58vh, 620px); overflow: auto; padding-right: 2px; }
|
||||
.live-build-dialog-summary { display: grid; gap: 4px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 6px; background: #fbfcf8; }
|
||||
.live-build-dialog-summary p { margin: 0; color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
|
||||
.live-build-row { display: grid; gap: 4px; padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface); }
|
||||
.live-build-row-head { display: flex; align-items: center; gap: 8px; }
|
||||
.live-build-tone { font-size: 12px; }
|
||||
@@ -153,6 +171,12 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
|
||||
.live-build-time { margin-left: auto; color: var(--muted); font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
.live-build-meta { display: flex; flex-wrap: wrap; gap: 10px; font-size: 12px; color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
.live-build-reason { margin: 0; font-size: 12px; color: var(--bad); }
|
||||
.workbench-dialog-layer { position: fixed; inset: 0; z-index: 70; display: grid; place-items: center; padding: 16px; background: rgba(15, 22, 20, 0.42); }
|
||||
.workbench-dialog { width: min(760px, 94vw); max-height: min(86vh, 820px); display: grid; grid-template-rows: auto minmax(0, 1fr); border: 1px solid var(--line); border-radius: 8px; background: var(--surface); color: var(--ink); box-shadow: 0 24px 60px rgba(15, 22, 20, 0.34); overflow: hidden; }
|
||||
.workbench-dialog-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px 14px; border-bottom: 1px solid var(--line); background: #fbfcf8; }
|
||||
.workbench-dialog-head h2 { margin: 0; font-size: 16px; }
|
||||
.workbench-dialog-close { width: 30px; height: 30px; display: inline-grid; place-items: center; border: 1px solid var(--line); border-radius: 6px; background: var(--surface); color: var(--ink); font-size: 20px; line-height: 1; }
|
||||
.workbench-dialog-content { min-height: 0; display: grid; gap: 10px; padding: 12px 14px; overflow: auto; }
|
||||
.gate-raw { margin-top: 12px; }
|
||||
.gate-raw pre { margin: 4px 0 0; max-height: 240px; overflow: auto; background: #f3f5f1; padding: 8px; border-radius: 6px; }
|
||||
.gate-table tbody tr[data-status-key="blocked"] td:first-child { border-left: 3px solid var(--bad); padding-left: 5px; }
|
||||
@@ -223,7 +247,8 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
|
||||
.command-drafts-toggle { white-space: nowrap; }
|
||||
|
||||
.right-sidebar { min-width: 0; min-height: 0; display: grid; grid-template-rows: minmax(0, 1fr); gap: 0; padding: 0; background: #fbfcf8; border-left: 1px solid var(--line); overflow: hidden; }
|
||||
.right-sidebar-content { min-width: 0; min-height: 0; display: grid; grid-template-rows: auto auto auto auto minmax(0, 1fr); gap: 10px; padding: 14px; overflow-y: hidden; }
|
||||
.right-sidebar-content { min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 10px; padding: 14px; overflow-x: hidden; overflow-y: auto; scrollbar-gutter: stable; }
|
||||
.right-sidebar-content > * { flex: 0 0 auto; min-width: 0; }
|
||||
.is-right-sidebar-collapsed .right-sidebar-content { display: none; }
|
||||
.is-right-sidebar-collapsed .right-sidebar-resize { display: none; }
|
||||
.is-right-sidebar-collapsed .right-sidebar { display: none; }
|
||||
@@ -237,8 +262,8 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
|
||||
.device-pod-meta div { border: 1px solid var(--line); border-radius: 6px; padding: 8px; background: #fff; }
|
||||
.device-pod-meta dt { font-weight: 800; }
|
||||
.device-pod-meta dd { margin: 3px 0 0; overflow-wrap: anywhere; }
|
||||
.device-pod-workspace { min-height: 0; display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 10px; overflow: hidden; }
|
||||
.device-event-panel { min-height: 0; display: grid; grid-template-rows: auto minmax(132px, 1fr); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; background: #fff; }
|
||||
.device-pod-workspace { min-height: 360px; display: grid; grid-template-rows: auto minmax(220px, 1fr); gap: 10px; overflow: visible; }
|
||||
.device-event-panel { min-height: 240px; display: grid; grid-template-rows: auto minmax(132px, 1fr); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; background: #fff; }
|
||||
.device-event-panel header { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 10px; border-bottom: 1px solid var(--line); font-weight: 800; }
|
||||
.device-event-scroll { min-height: 0; overflow: auto; overscroll-behavior: contain; padding: 10px; background: #17201c; color: #e9f5e8; }
|
||||
#device-event-text { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; }
|
||||
@@ -294,6 +319,10 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
|
||||
.conversation-panel { padding: 10px; }
|
||||
.conversation-panel .panel-title-actions { width: 100%; justify-content: flex-start; }
|
||||
.code-agent-debug-summary .code-agent-summary-capability { display: none; }
|
||||
.workspace-route-label, .workspace-topbar-pill { display: none; }
|
||||
.topbar-status-tools { justify-content: flex-start; overflow-x: auto; }
|
||||
.status-chip-button { max-width: 44vw; }
|
||||
.status-chip-meta { display: none; }
|
||||
.workspace-topbar-button { max-width: 96px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.command-bar { grid-template-columns: minmax(0, 1fr) auto auto; grid-template-areas: "profile drafts drafts" "input input input" "send send clear"; align-items: stretch; }
|
||||
.command-bar .agent-timeout-control { display: none; }
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { LiveBuildsPayload } from "./live-builds";
|
||||
export type { LiveBuildBuild, LiveBuildCommit, LiveBuildImage, LiveBuildRecord, LiveBuildsPayload } from "./live-builds";
|
||||
|
||||
export type Tone = "ok" | "source" | "pending" | "blocked" | "error" | "warn" | "dev-live" | "dry-run";
|
||||
export type RouteId = "workspace" | "skills" | "gate" | "help" | "settings";
|
||||
export type AuthState = "checking" | "login" | "authenticated";
|
||||
@@ -43,6 +46,7 @@ export interface LiveSurface {
|
||||
devicePods: ApiResult<DevicePodListResponse>;
|
||||
devicePodStatus: ApiResult<DevicePodStatusResponse> | null;
|
||||
devicePodEvents: ApiResult<DevicePodEventsResponse> | null;
|
||||
liveBuilds: ApiResult<LiveBuildsPayload>;
|
||||
loadedAt: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
export interface LiveBuildsPayload {
|
||||
kind?: string;
|
||||
generatedAt?: string;
|
||||
source?: Record<string, unknown> | null;
|
||||
latest?: LiveBuildRecord | null;
|
||||
counts?: Record<string, unknown> | null;
|
||||
services?: LiveBuildRecord[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface LiveBuildRecord {
|
||||
serviceId?: string;
|
||||
name?: string;
|
||||
kind?: string;
|
||||
status?: string;
|
||||
reason?: string | null;
|
||||
healthUrl?: string | null;
|
||||
httpStatus?: number | null;
|
||||
desiredState?: Record<string, unknown> | null;
|
||||
build?: LiveBuildBuild | null;
|
||||
image?: LiveBuildImage | null;
|
||||
commit?: LiveBuildCommit | null;
|
||||
runtime?: Record<string, unknown> | null;
|
||||
revision?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface LiveBuildBuild {
|
||||
createdAt?: string | null;
|
||||
source?: string | null;
|
||||
metadataSource?: string | null;
|
||||
unavailableReason?: string | null;
|
||||
liveMetadataMatch?: Record<string, unknown> | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface LiveBuildImage {
|
||||
tag?: string | null;
|
||||
digest?: string | null;
|
||||
repository?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface LiveBuildCommit {
|
||||
id?: string | null;
|
||||
short?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
Reference in New Issue
Block a user