-
diff --git a/web/hwlab-cloud-web/src/config/agent-observer.ts b/web/hwlab-cloud-web/src/config/agent-observer.ts
index f2d5c928..58a14c7d 100644
--- a/web/hwlab-cloud-web/src/config/agent-observer.ts
+++ b/web/hwlab-cloud-web/src/config/agent-observer.ts
@@ -4,6 +4,7 @@ export interface AgentObserverRuntimeConfig {
reconnectDelayMs: number;
batchMaxEvents: number;
liveBufferLimit: number;
+ identityWindowLimit: number;
reducerEventLimit: number;
reducerEntityLimit: number;
treeNodeLimit: number;
@@ -23,6 +24,7 @@ export function agentObserverRuntimeConfig(): AgentObserverRuntimeConfig | null
reconnectDelayMs: positive(value.reconnectDelayMs),
batchMaxEvents: positive(value.batchMaxEvents),
liveBufferLimit: positive(value.liveBufferLimit),
+ identityWindowLimit: positive(value.identityWindowLimit),
reducerEventLimit: positive(value.reducerEventLimit),
reducerEntityLimit: positive(value.reducerEntityLimit),
treeNodeLimit: positive(value.treeNodeLimit),
diff --git a/web/hwlab-cloud-web/src/stores/agent-observer.ts b/web/hwlab-cloud-web/src/stores/agent-observer.ts
index d329bf4f..801bbdfe 100644
--- a/web/hwlab-cloud-web/src/stores/agent-observer.ts
+++ b/web/hwlab-cloud-web/src/stores/agent-observer.ts
@@ -28,9 +28,15 @@ export interface AgentObserverEvent {
export interface AgentObserverRun {
id: string;
taskId: string | null;
+ taskStatus: string | null;
commandId: string | null;
attemptId: string | null;
+ attemptStatus: string | null;
runnerId: string | null;
+ runnerCapacityUsed: number | null;
+ runnerCapacityTotal: number | null;
+ nodeId: string | null;
+ lane: string | null;
sessionId: string | null;
traceId: string | null;
projectId: string | null;
@@ -42,9 +48,11 @@ export interface AgentObserverRun {
commandType: string | null;
phase: string;
result: string | null;
+ final: boolean;
errorCode: string | null;
lastMessage: string | null;
startedAt: string | null;
+ finishedAt: string | null;
updatedAt: string | null;
lastSourceSeq: number | null;
eventIds: string[];
@@ -53,6 +61,12 @@ export interface AgentObserverRun {
export type AgentObserverRelationKind = "scope" | "session" | "task" | "run" | "attempt" | "command";
+export interface AgentObserverStatusToken {
+ value: string;
+ label: string;
+ tone: "failed" | "completed" | "canceled" | "pending" | "running" | "unknown";
+}
+
export interface AgentObserverRelationRow {
id: string;
kind: AgentObserverRelationKind;
@@ -100,7 +114,8 @@ export const useAgentObserverStore = defineStore("agentObserver", () => {
const config = ref
(null);
const expandedRelationIds = ref([]);
const collapsedRelationIds = ref([]);
- const seenEventIds = new Set();
+ const seenIdentity = new Map();
+ const identityWindow: Array<{ token: object; keys: string[] }> = [];
const queue: unknown[] = [];
let source: EventSource | null = null;
let reconnectTimer: number | null = null;
@@ -135,15 +150,32 @@ export const useAgentObserverStore = defineStore("agentObserver", () => {
const ids = new Set(run.eventIds.slice(-config.value.inspectorEventTailLimit));
return events.value.filter((event) => ids.has(event.id));
});
+ const selectedLogs = computed(() => selectedEvents.value.filter((event) => ["output", "tool", "error"].includes(event.type) || event.label.includes(":output:")));
+ const selectedInEventWindow = computed(() => {
+ if (!selectedRunId.value) return true;
+ const run = selectedRun.value;
+ if (!run) return false;
+ const retained = new Set(events.value.map((event) => event.id));
+ return run.eventIds.some((id) => retained.has(id));
+ });
const stats = computed(() => {
const values = visibleRuns.value;
+ const capacityByRunner = new Map();
+ for (const run of values) {
+ if (!run.runnerId || run.runnerCapacityUsed === null || run.runnerCapacityTotal === null || capacityByRunner.has(run.runnerId)) continue;
+ capacityByRunner.set(run.runnerId, { used: run.runnerCapacityUsed, total: run.runnerCapacityTotal });
+ }
return {
total: values.length,
running: values.filter((run) => statusGroup(run.runStatus) === "running").length,
waiting: values.filter((run) => statusGroup(run.runStatus) === "waiting").length,
failed: values.filter((run) => statusGroup(run.runStatus) === "failed").length,
terminal: values.filter((run) => statusGroup(run.runStatus) === "terminal").length,
- runners: new Set(values.map((run) => run.runnerId).filter(Boolean)).size
+ stale: values.filter((run) => run.updatedAt && Date.now() - Date.parse(run.updatedAt) > (config.value?.staleAfterMs ?? Number.MAX_SAFE_INTEGER)).length,
+ runners: new Set(values.map((run) => run.runnerId).filter(Boolean)).size,
+ runnerCapacityUsed: [...capacityByRunner.values()].reduce((sum, capacity) => sum + capacity.used, 0),
+ runnerCapacityTotal: [...capacityByRunner.values()].reduce((sum, capacity) => sum + capacity.total, 0),
+ runnerCapacityKnown: capacityByRunner.size > 0
};
});
const dataAgeMs = computed(() => lastEventAt.value ? Math.max(0, nowMs.value - Date.parse(lastEventAt.value)) : null);
@@ -247,14 +279,15 @@ export const useAgentObserverStore = defineStore("agentObserver", () => {
const body = asRecord(envelope?.event);
const context = asRecord(envelope?.context);
const runId = nonEmptyString(envelope?.runId) ?? nonEmptyString(body?.runId);
- const eventId = nonEmptyString(envelope?.eventId) ?? nonEmptyString(envelope?.sourceEventId);
- if (!runId || !eventId || seenEventIds.has(eventId)) return;
- seenEventIds.add(eventId);
+ const outerEventId = nonEmptyString(envelope?.eventId);
+ const sourceEventId = nonEmptyString(envelope?.sourceEventId);
+ const eventId = outerEventId ?? sourceEventId;
+ if (!runId || !eventId || rememberIdentity(outerEventId, sourceEventId)) return;
const createdAt = nonEmptyString(body?.createdAt) ?? nonEmptyString(envelope?.producedAt);
const sourceSeq = integer(body?.sourceSeq) ?? integer(context?.sourceSeq);
const event: AgentObserverEvent = {
id: eventId,
- sourceEventId: nonEmptyString(envelope?.sourceEventId),
+ sourceEventId,
runId,
commandId: nonEmptyString(envelope?.commandId) ?? nonEmptyString(body?.commandId),
sessionId: nonEmptyString(envelope?.sessionId) ?? nonEmptyString(envelope?.hwlabSessionId),
@@ -274,7 +307,8 @@ export const useAgentObserverStore = defineStore("agentObserver", () => {
}
const previous = entities.value[runId];
- const sequenceIsCurrent = !previous || previous.lastSourceSeq === null || (sourceSeq !== null && sourceSeq >= previous.lastSourceSeq);
+ const sequenceIsCurrent = !previous
+ || (sourceSeq !== null && (previous.lastSourceSeq === null || sourceSeq > previous.lastSourceSeq));
lastEventAt.value = latestTimestamp(lastEventAt.value, createdAt) ?? new Date().toISOString();
nowMs.value = Date.now();
if (previous && !sequenceIsCurrent) return;
@@ -283,9 +317,15 @@ export const useAgentObserverStore = defineStore("agentObserver", () => {
const next: AgentObserverRun = {
id: runId,
taskId: nonEmptyString(body?.taskId) ?? previous?.taskId ?? null,
+ taskStatus: nonEmptyString(body?.taskStatus) ?? nonEmptyString(body?.taskState) ?? previous?.taskStatus ?? null,
commandId: event.commandId ?? previous?.commandId ?? null,
attemptId: nonEmptyString(body?.attemptId) ?? previous?.attemptId ?? null,
+ attemptStatus: nonEmptyString(body?.attemptStatus) ?? nonEmptyString(body?.attemptState) ?? previous?.attemptStatus ?? null,
runnerId: nonEmptyString(body?.runnerId) ?? previous?.runnerId ?? null,
+ runnerCapacityUsed: integer(body?.runnerCapacityUsed) ?? integer(asRecord(body?.runnerCapacity)?.used) ?? previous?.runnerCapacityUsed ?? null,
+ runnerCapacityTotal: integer(body?.runnerCapacityTotal) ?? integer(asRecord(body?.runnerCapacity)?.total) ?? previous?.runnerCapacityTotal ?? null,
+ nodeId: nonEmptyString(body?.nodeId) ?? previous?.nodeId ?? null,
+ lane: nonEmptyString(body?.lane) ?? previous?.lane ?? null,
sessionId: event.sessionId ?? previous?.sessionId ?? null,
traceId: event.traceId ?? previous?.traceId ?? null,
projectId: nonEmptyString(body?.projectId) ?? nonEmptyString(context?.projectId) ?? previous?.projectId ?? null,
@@ -297,9 +337,11 @@ export const useAgentObserverStore = defineStore("agentObserver", () => {
commandType: nonEmptyString(body?.commandType) ?? previous?.commandType ?? null,
phase: event.label.replace(/^agentrun:/u, ""),
result: event.terminal ? (event.message || terminalStatus) : (previous?.result ?? null),
+ final: previous?.final === true || body?.final === true || body?.replyAuthority === true || event.terminal,
errorCode: nonEmptyString(body?.errorCode) ?? nonEmptyString(body?.failureKind) ?? previous?.errorCode ?? null,
lastMessage: event.message || (previous?.lastMessage ?? null),
startedAt: previous?.startedAt ?? createdAt,
+ finishedAt: event.terminal ? (createdAt ?? previous?.finishedAt ?? null) : (previous?.finishedAt ?? null),
updatedAt: createdAt ?? previous?.updatedAt ?? null,
lastSourceSeq: sourceSeq ?? previous?.lastSourceSeq ?? null,
eventIds: [...(previous?.eventIds ?? []), eventId].slice(-config.value.inspectorEventTailLimit),
@@ -310,6 +352,21 @@ export const useAgentObserverStore = defineStore("agentObserver", () => {
trimEntities();
}
+ function rememberIdentity(eventId: string | null, sourceEventId: string | null): boolean {
+ if (!config.value) return true;
+ const keys = [eventId ? `event:${eventId}` : null, sourceEventId ? `source:${sourceEventId}` : null].filter((key): key is string => Boolean(key));
+ if (keys.some((key) => seenIdentity.has(key))) return true;
+ const token = {};
+ for (const key of keys) seenIdentity.set(key, token);
+ identityWindow.push({ token, keys });
+ while (identityWindow.length > config.value.identityWindowLimit) {
+ const expired = identityWindow.shift();
+ if (!expired) break;
+ for (const key of expired.keys) if (seenIdentity.get(key) === expired.token) seenIdentity.delete(key);
+ }
+ return false;
+ }
+
function trimEntities(): void {
if (!config.value || orderedIds.value.length <= config.value.reducerEntityLimit) return;
const keep = orderedIds.value.slice(0, config.value.reducerEntityLimit);
@@ -335,7 +392,7 @@ export const useAgentObserverStore = defineStore("agentObserver", () => {
}
return {
- entities, orderedIds, events, runs, visibleRuns, selectedRun, selectedEvents, selectedRunId,
+ entities, orderedIds, events, runs, visibleRuns, selectedRun, selectedEvents, selectedLogs, selectedInEventWindow, selectedRunId,
view, density, search, statusFilter, projectFilter, agentFilter, timeWindow, sort, phase, lastEventAt,
connectedAt, error, config, projects, scopeOptions, agents, stats, dataAgeMs, relationRows,
connect, disconnect, reconnect, selectRun, toggleRelation
@@ -409,7 +466,7 @@ function buildRelationRows(
detail: null,
parentId: parent.id,
runId: null,
- status: null
+ status: run.taskStatus
});
path.push(parent.id);
}
@@ -433,7 +490,7 @@ function buildRelationRows(
detail: null,
parentId: parent.id,
runId: run.id,
- status: run.runStatus
+ status: run.attemptStatus
});
path.push(parent.id);
}
@@ -502,6 +559,16 @@ function normalizeRunStatus(runStatus: string | null, terminalStatus: string | n
return runStatus ?? eventStatus ?? previous;
}
+export function agentObserverStatusToken(status: string | null): AgentObserverStatusToken {
+ const value = status?.trim().toLowerCase() || "unknown";
+ if (["failed", "error", "rejected"].includes(value)) return { value, label: status || "未知", tone: "failed" };
+ if (["completed", "succeeded"].includes(value)) return { value, label: status || "未知", tone: "completed" };
+ if (["canceled", "cancelled"].includes(value)) return { value, label: status || "未知", tone: "canceled" };
+ if (["queued", "pending", "waiting", "admitted"].includes(value)) return { value, label: status || "未知", tone: "pending" };
+ if (["running", "active", "started", "in_progress"].includes(value)) return { value, label: status || "未知", tone: "running" };
+ return { value, label: status || "未知", tone: "unknown" };
+}
+
function statusGroup(status: string): string {
const value = status.toLowerCase();
if (["failed", "error", "rejected"].includes(value)) return "failed";
diff --git a/web/hwlab-cloud-web/src/styles/agent-observer.css b/web/hwlab-cloud-web/src/styles/agent-observer.css
index 5260f955..833c9c13 100644
--- a/web/hwlab-cloud-web/src/styles/agent-observer.css
+++ b/web/hwlab-cloud-web/src/styles/agent-observer.css
@@ -1,85 +1,157 @@
-.agent-observer-page { height: calc(100dvh - var(--platform-topbar-height, 56px)); min-height: 0; display: grid; grid-template-rows: auto auto auto minmax(0, 1fr); overflow: hidden; }
+.agent-observer-page {
+ width: 100%;
+ max-width: 100%;
+ height: calc(100dvh - var(--platform-topbar-height, 56px));
+ min-width: 0;
+ min-height: 0;
+ display: grid;
+ grid-template-rows: auto auto auto auto minmax(0, 1fr);
+ overflow: hidden;
+}
+
.agent-observer-page .page-command-bar { padding-block: 12px; }
-.agent-observer-page .page-command-description { max-width: 74ch; }
-.agent-observer-page .page-command-filters { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
-.agent-observer-page select, .agent-observer-search { min-height: 34px; border: 1px solid var(--console-border); background: var(--console-surface); color: var(--console-text); border-radius: 6px; }
-.agent-observer-page select { padding: 0 28px 0 9px; }
-.agent-observer-search { display: flex; align-items: center; gap: 7px; padding: 0 8px; min-width: min(330px, 100%); }
-.agent-observer-search input { width: 100%; min-width: 100px; border: 0; outline: 0; background: transparent; color: inherit; }
+.agent-observer-page .page-command-description { max-width: 78ch; }
+.agent-observer-page .page-command-filters { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; min-width: 0; }
+.agent-observer-page select,
+.agent-observer-search { min-height: 34px; border: 1px solid var(--console-border); border-radius: 6px; background: var(--console-surface); color: var(--console-text); }
+.agent-observer-page select { max-width: 230px; padding: 0 28px 0 9px; }
+.agent-observer-search { min-width: min(330px, 100%); display: flex; align-items: center; gap: 7px; padding: 0 8px; }
+.agent-observer-search input { width: 100%; min-width: 80px; border: 0; outline: 0; background: transparent; color: inherit; }
.agent-observer-search button { border: 0; background: transparent; color: var(--console-muted); display: inline-flex; }
-.agent-observer-stream { min-height: 32px; display: inline-flex; align-items: center; gap: 7px; padding: 0 9px; border: 1px solid var(--console-border); border-radius: 6px; background: var(--console-surface); font-size: 12px; }
+.agent-observer-stream,
+.agent-observer-target { min-height: 32px; display: inline-flex; align-items: center; gap: 7px; padding: 0 9px; border: 1px solid var(--console-border); border-radius: 6px; background: var(--console-surface); font-size: 12px; }
.agent-observer-stream[data-phase="live"] svg { color: var(--console-success); }
-.agent-observer-stream[data-phase="error"], .agent-observer-stream[data-phase="stale"] { border-color: color-mix(in srgb, var(--console-danger) 45%, var(--console-border)); }
-.agent-observer-target { display: inline-flex; align-items: center; min-height: 32px; padding: 0 8px; border: 1px solid var(--console-border); border-radius: 6px; color: var(--console-muted); font-size: 11px; }
+.agent-observer-stream[data-phase="error"],
+.agent-observer-stream[data-phase="stale"] { border-color: color-mix(in srgb, var(--console-danger) 45%, var(--console-border)); }
+.agent-observer-target { color: var(--console-muted); font-size: 11px; }
+.agent-observer-secondary-filters { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
+.agent-observer-filter-trigger { display: none; min-height: 34px; align-items: center; gap: 6px; padding: 0 10px; border: 1px solid var(--console-border); border-radius: 6px; background: var(--console-surface); color: var(--console-text); }
.agent-observer-segment { display: inline-flex; border: 1px solid var(--console-border); border-radius: 6px; overflow: hidden; }
-.agent-observer-segment button, .agent-observer-density { min-height: 32px; display: inline-flex; align-items: center; gap: 5px; border: 0; border-right: 1px solid var(--console-border); padding: 0 9px; background: var(--console-surface); color: var(--console-muted); }
+.agent-observer-segment button,
+.agent-observer-density { min-height: 32px; display: inline-flex; align-items: center; gap: 5px; padding: 0 9px; border: 0; border-right: 1px solid var(--console-border); background: var(--console-surface); color: var(--console-muted); }
.agent-observer-segment button:last-child { border-right: 0; }
.agent-observer-segment button[aria-pressed="true"] { background: var(--console-accent-soft); color: var(--console-accent); }
.agent-observer-density { border: 1px solid var(--console-border); border-radius: 6px; }
-.agent-observer-secondary-filters { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
-.agent-observer-filter-trigger { display: none; min-height: 34px; align-items: center; gap: 6px; padding: 0 10px; border: 1px solid var(--console-border); border-radius: 6px; background: var(--console-surface); color: var(--console-text); }
-.agent-observer-filter-backdrop { position: fixed; z-index: 70; inset: 0; display: flex; justify-content: flex-end; background: rgb(0 0 0 / .38); }
-.agent-observer-filter-drawer { width: min(360px, 92vw); height: 100%; padding: 16px; display: grid; align-content: start; gap: 14px; background: var(--console-surface); box-shadow: -16px 0 42px rgb(0 0 0 / .22); }
-.agent-observer-filter-drawer header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
-.agent-observer-filter-drawer header button { display: inline-flex; border: 0; background: transparent; color: var(--console-muted); }
-.agent-observer-filter-drawer label { display: grid; gap: 6px; color: var(--console-muted); font-size: 12px; }
-.agent-observer-filter-drawer select, .agent-observer-filter-drawer .agent-observer-density { width: 100%; min-height: 40px; }
-.agent-observer-status-strip { min-height: 38px; display: flex; align-items: center; gap: 0; overflow-x: auto; border-block: 1px solid var(--console-border); background: var(--console-surface-muted); }
-.agent-observer-status-strip > span { flex: 0 0 auto; display: inline-flex; align-items: baseline; gap: 5px; padding: 0 13px; border-right: 1px solid var(--console-border); font-size: 12px; color: var(--console-muted); }
+
+.agent-observer-status-strip { min-width: 0; min-height: 38px; display: flex; align-items: center; overflow-x: auto; border-block: 1px solid var(--console-border); background: var(--console-surface-muted); }
+.agent-observer-status-strip > span { flex: 0 0 auto; display: inline-flex; align-items: baseline; gap: 5px; padding: 0 13px; border-right: 1px solid var(--console-border); color: var(--console-muted); font-size: 12px; }
.agent-observer-status-strip strong { color: var(--console-text); font-size: 14px; }
.agent-observer-status-strip [data-tone="danger"] strong { color: var(--console-danger); }
-.agent-observer-window-note { margin-left: auto; }
-.agent-observer-inline-error { padding: 7px 13px; border-bottom: 1px solid color-mix(in srgb, var(--console-danger) 35%, var(--console-border)); background: color-mix(in srgb, var(--console-danger) 8%, var(--console-surface)); color: var(--console-danger); font-size: 12px; }
-.agent-observer-workspace { min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr); overflow: hidden; }
-.agent-observer-workspace.has-inspector { grid-template-columns: minmax(0, 1fr) minmax(300px, 380px); }
+.agent-observer-inline-error,
+.agent-observer-window-warning { padding: 7px 13px; border-bottom: 1px solid color-mix(in srgb, var(--console-danger) 35%, var(--console-border)); background: color-mix(in srgb, var(--console-danger) 8%, var(--console-surface)); color: var(--console-danger); font-size: 12px; overflow-wrap: anywhere; }
+
+.agent-observer-workspace { min-width: 0; min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr); overflow: hidden; }
+.agent-observer-workspace.has-inspector { grid-template-columns: minmax(0, 1fr) minmax(340px, 420px); }
.agent-observer-content { min-width: 0; min-height: 0; overflow: hidden; background: var(--console-surface); }
.agent-observer-skeleton { height: 100%; padding: 12px; display: grid; align-content: start; gap: 7px; }
-.agent-observer-skeleton span { height: 48px; border: 1px solid var(--console-border); background: linear-gradient(90deg, var(--console-surface-muted), var(--console-surface), var(--console-surface-muted)); background-size: 200% 100%; animation: agent-observer-shimmer 1.4s linear infinite; }
-@keyframes agent-observer-shimmer { to { background-position: -200% 0; } }
-.agent-observer-empty { height: 100%; display: grid; place-content: center; justify-items: center; gap: 9px; color: var(--console-muted); text-align: center; padding: 24px; }
+.agent-observer-skeleton span { height: 48px; border-radius: 4px; background: var(--console-surface-muted); }
+.agent-observer-empty { height: 100%; display: grid; place-content: center; justify-items: center; gap: 8px; padding: 24px; color: var(--console-muted); text-align: center; }
.agent-observer-empty strong { color: var(--console-text); }
-.agent-observer-table { height: 100%; min-height: 0; display: grid; grid-template-rows: auto minmax(0, 1fr); }
-.agent-observer-table-head, .agent-observer-table-row { display: grid; grid-template-columns: 110px minmax(180px, 1.25fr) minmax(120px, .8fr) minmax(180px, 1.1fr) minmax(145px, .9fr) 105px minmax(125px, .8fr) minmax(170px, 1fr); align-items: center; column-gap: 10px; min-width: 1180px; }
-.agent-observer-table-head { position: sticky; top: 0; z-index: 2; min-height: 36px; padding: 0 12px; border-bottom: 1px solid var(--console-border); background: var(--console-surface-muted); color: var(--console-muted); font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; }
-.agent-observer-table .console-virtual-list { overflow: auto; }
-.agent-observer-table-row { height: 100%; padding: 0 12px; border-bottom: 1px solid var(--console-border-subtle, var(--console-border)); cursor: default; outline: 0; }
-.agent-observer-table-row:hover, .agent-observer-table-row[data-selected="true"] { background: var(--console-accent-soft); }
+
+.agent-observer-table { height: 100%; min-width: 0; display: grid; grid-template-rows: 38px minmax(0, 1fr); overflow: hidden; }
+.agent-observer-table-head,
+.agent-observer-table-row { min-width: 980px; display: grid; grid-template-columns: 230px minmax(145px, 1.1fr) minmax(170px, 1.2fr) minmax(120px, .8fr) 110px 145px minmax(150px, 1fr); align-items: center; gap: 10px; padding-inline: 12px; }
+.agent-observer-table-head { border-bottom: 1px solid var(--console-border); background: var(--console-surface-muted); color: var(--console-muted); font-size: 11px; font-weight: 700; }
+.agent-observer-table .console-virtual-list { min-width: 0; overflow: auto; }
+.agent-observer-table-row { height: calc(100% - 1px); border-bottom: 1px solid var(--console-border); outline: 0; cursor: default; }
+.agent-observer-table-row:hover,
+.agent-observer-table-row[data-selected="true"] { background: var(--console-accent-soft); }
+.agent-observer-table-row:focus-visible { box-shadow: inset 0 0 0 2px var(--console-accent); }
.agent-observer-table-row[data-recent="true"] { box-shadow: inset 3px 0 var(--console-accent); }
.agent-observer-table-row > span { min-width: 0; display: grid; gap: 2px; overflow: hidden; }
-.agent-observer-table-row strong, .agent-observer-table-row small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.agent-observer-table-row strong,
+.agent-observer-table-row small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.agent-observer-table-row strong { font-size: 12px; font-weight: 650; }
.agent-observer-table-row small { color: var(--console-muted); font-size: 11px; }
-.mono { font-family: var(--console-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); }
-.agent-observer-card-list { height: 100%; overflow: auto; padding: 10px; display: grid; grid-template-columns: repeat(3, minmax(240px, 1fr)); align-content: start; gap: 8px; }
-.agent-observer-card { min-height: 150px; padding: 10px 12px; border: 1px solid var(--console-border); border-left: 4px solid var(--console-border-strong, var(--console-border)); background: var(--console-surface); display: grid; gap: 6px; overflow: hidden; }
+.agent-observer-status-stack { display: flex !important; align-items: center; gap: 4px !important; flex-wrap: wrap; }
+
+.agent-observer-card-list { height: 100%; min-height: 0; padding: 8px; }
+.agent-observer-card-list .console-virtual-list { overflow: auto; }
+.agent-observer-card { height: calc(100% - 8px); margin: 4px; padding: 10px 12px; border: 1px solid var(--console-border); border-left: 4px solid var(--console-border-strong, var(--console-border)); background: var(--console-surface); display: grid; gap: 6px; overflow: hidden; outline: 0; }
.agent-observer-card[data-status="running"] { border-left-color: var(--console-accent); }
.agent-observer-card[data-status="failed"] { border-left-color: var(--console-danger); }
.agent-observer-card[data-status="completed"] { border-left-color: var(--console-success); }
-.agent-observer-card[data-selected="true"] { outline: 2px solid var(--console-accent); outline-offset: -2px; }
-.agent-observer-card header, .agent-observer-card footer { display: flex; justify-content: space-between; gap: 10px; color: var(--console-muted); font-size: 11px; }
-.agent-observer-card h2, .agent-observer-card p { margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.agent-observer-card h2 { font-size: 14px; }.agent-observer-card p { color: var(--console-muted); font-size: 12px; }
+.agent-observer-card[data-selected="true"] { box-shadow: inset 0 0 0 2px var(--console-accent); }
+.agent-observer-card:focus-visible { outline: 2px solid var(--console-accent); outline-offset: -2px; }
+.agent-observer-card header { min-width: 0; display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; color: var(--console-muted); font-size: 11px; }
+.agent-observer-card h2,
+.agent-observer-card p,
+.agent-observer-card footer { margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.agent-observer-card h2 { font-size: 14px; }
+.agent-observer-card p,
+.agent-observer-card footer { color: var(--console-muted); font-size: 12px; }
.agent-observer-card dl { margin: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
-.agent-observer-card dl div { min-width: 0; }.agent-observer-card dt { color: var(--console-muted); font-size: 10px; }.agent-observer-card dd { margin: 2px 0 0; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.agent-observer-card footer { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; border-top: 1px solid var(--console-border); padding-top: 6px; }
-.agent-observer-tree { height: 100%; min-height: 0; padding: 8px 0; }
+.agent-observer-card dl div { min-width: 0; }
+.agent-observer-card dt { color: var(--console-muted); font-size: 10px; }
+.agent-observer-card dd { margin: 2px 0 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
+.agent-observer-card footer { padding-top: 6px; border-top: 1px solid var(--console-border); }
+
+.agent-observer-tree { height: 100%; min-width: 0; min-height: 0; padding-block: 8px; }
.agent-observer-tree .console-virtual-list { overflow: auto; }
-.agent-observer-tree-row { position: relative; height: calc(100% - 2px); margin: 0 10px; padding-inline-end: 10px; display: grid; grid-template-columns: 18px 100px minmax(220px, 1fr) minmax(180px, .8fr); align-items: center; gap: 9px; border-bottom: 1px solid var(--console-border); font-size: 12px; min-width: 620px; outline: 0; }
-.agent-observer-tree-row:hover, .agent-observer-tree-row[aria-selected="true"] { background: var(--console-accent-soft); }
+.agent-observer-tree-row { position: relative; height: calc(100% - 2px); min-width: 720px; margin-inline: 10px; padding-inline: calc(10px + (var(--tree-depth, 0) * 18px)) 10px; display: grid; grid-template-columns: 18px 110px minmax(220px, 1fr) minmax(180px, .8fr); align-items: center; gap: 9px; border-bottom: 1px solid var(--console-border); outline: 0; font-size: 12px; }
+.agent-observer-tree-row:hover,
+.agent-observer-tree-row[aria-selected="true"] { background: var(--console-accent-soft); }
.agent-observer-tree-row:focus-visible { box-shadow: inset 0 0 0 2px var(--console-accent); }
-.agent-observer-tree-row::before { content: ""; position: absolute; left: max(8px, calc(8px + (var(--tree-depth, 0) * 22px))); top: 0; bottom: 0; border-left: 1px solid var(--console-border-subtle, var(--console-border)); }
.agent-observer-tree-toggle { width: 18px; height: 18px; padding: 0; display: inline-grid; place-items: center; border: 0; background: transparent; color: var(--console-muted); }
-.agent-observer-tree-toggle svg { transition: transform .14s ease; }.agent-observer-tree-toggle svg[data-expanded="true"] { transform: rotate(90deg); }
-.agent-observer-tree-spacer { width: 18px; }.agent-observer-relation-kind { color: var(--console-muted); font-size: 10px; font-weight: 700; text-transform: uppercase; }
-.agent-observer-tree-row > span, .agent-observer-tree-row strong, .agent-observer-tree-row small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.agent-observer-inspector { min-height: 0; border-left: 1px solid var(--console-border); }
-.agent-observer-inspector-grid { margin: 0; display: grid; gap: 0; }
-.agent-observer-inspector-grid div { display: grid; grid-template-columns: 92px minmax(0, 1fr); gap: 10px; padding: 9px 0; border-bottom: 1px solid var(--console-border); }
-.agent-observer-inspector-grid dt { color: var(--console-muted); font-size: 11px; }.agent-observer-inspector-grid dd { min-width: 0; margin: 0; overflow-wrap: anywhere; font-size: 12px; }
-.agent-observer-event-tail { list-style: none; margin: 0; padding: 0; display: grid; gap: 0; }
-.agent-observer-event-tail li { display: grid; grid-template-columns: 70px 1fr; gap: 4px 8px; padding: 9px 0; border-bottom: 1px solid var(--console-border); }
-.agent-observer-event-tail span { color: var(--console-muted); font-size: 10px; }.agent-observer-event-tail strong { font-size: 11px; }.agent-observer-event-tail small { grid-column: 2; color: var(--console-muted); overflow-wrap: anywhere; }
-@media (max-width: 1200px) { .agent-observer-card-list { grid-template-columns: repeat(2, minmax(240px, 1fr)); } }
-@media (max-width: 960px) { .agent-observer-workspace.has-inspector { grid-template-columns: minmax(0, 1fr); }.agent-observer-inspector { position: fixed; z-index: 40; inset: 64px 0 0 auto; width: min(420px, 92vw); background: var(--console-surface); box-shadow: -12px 0 36px rgb(0 0 0 / .16); }.agent-observer-page .page-command-filters { display: grid; grid-template-columns: minmax(210px, 1fr) auto auto; }.agent-observer-secondary-filters { grid-column: 1 / -1; }.agent-observer-search { min-width: 0; } }
-@media (max-width: 560px) { .agent-observer-page { height: calc(100dvh - 52px); }.agent-observer-page .page-command-description, .agent-observer-target { display: none; }.agent-observer-page .page-command-filters { grid-template-columns: repeat(3, auto); }.agent-observer-search { grid-column: 1 / -1; }.agent-observer-secondary-filters { display: none; }.agent-observer-filter-trigger { display: inline-flex; }.agent-observer-segment span { display: none; }.agent-observer-status-strip > span:nth-child(n+5) { display: none; }.agent-observer-workspace.has-inspector { display: block; }.agent-observer-inspector { inset: auto 0 0; width: 100%; height: min(68dvh, 620px); border-left: 0; border-top: 1px solid var(--console-border); box-shadow: 0 -14px 36px rgb(0 0 0 / .18); }.agent-observer-card-list { grid-template-columns: minmax(0, 1fr); }.agent-observer-card dl { grid-template-columns: repeat(2, minmax(0, 1fr)); }.agent-observer-tree-row { grid-template-columns: 18px 84px minmax(190px, 1fr); min-width: 430px; }.agent-observer-tree-row small { display: none; } }
-@media (prefers-reduced-motion: reduce) { .agent-observer-skeleton span { animation: none; }.agent-observer-table-row[data-recent="true"] { box-shadow: inset 2px 0 var(--console-accent); } }
+.agent-observer-tree-toggle svg { transition: transform .14s ease; }
+.agent-observer-tree-toggle svg[data-expanded="true"] { transform: rotate(90deg); }
+.agent-observer-tree-row strong,
+.agent-observer-tree-row small { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.agent-observer-tree-row small,
+.agent-observer-relation-kind { color: var(--console-muted); }
+
+.agent-observer-inspector { min-width: 0; min-height: 0; overflow: auto; border-left: 1px solid var(--console-border); background: var(--console-surface); }
+.agent-observer-inspector-section { display: grid; gap: 14px; }
+.agent-observer-status-matrix { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
+.agent-observer-status-matrix > span { min-width: 0; display: grid; gap: 5px; padding: 8px; border: 1px solid var(--console-border); }
+.agent-observer-status-matrix small { color: var(--console-muted); }
+.agent-observer-inspector-grid { margin: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; background: var(--console-border); }
+.agent-observer-inspector-grid div { min-width: 0; padding: 9px; background: var(--console-surface); }
+.agent-observer-inspector-grid dt { color: var(--console-muted); font-size: 10px; text-transform: uppercase; }
+.agent-observer-inspector-grid dd { margin: 3px 0 0; overflow-wrap: anywhere; font-size: 12px; }
+.agent-observer-inspector-wide { grid-column: 1 / -1; }
+.agent-observer-event-tail { margin: 0; padding: 0; display: grid; list-style: none; }
+.agent-observer-event-tail li { min-width: 0; display: grid; grid-template-columns: 118px minmax(130px, .7fr) minmax(0, 1fr); gap: 8px; padding: 8px 0; border-bottom: 1px solid var(--console-border); font-size: 11px; }
+.agent-observer-event-tail span,
+.agent-observer-event-tail small { color: var(--console-muted); overflow-wrap: anywhere; }
+.agent-observer-log-tail li { grid-template-columns: 118px minmax(130px, .5fr) minmax(0, 1fr); }
+.agent-observer-log-tail pre,
+.agent-observer-raw-facts { max-width: 100%; margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; font: 11px/1.55 var(--console-font-mono, ui-monospace, monospace); }
+.agent-observer-raw-facts { padding: 10px; overflow: auto; border: 1px solid var(--console-border); background: var(--console-surface-muted); }
+.agent-observer-break { overflow-wrap: anywhere; word-break: break-word; }
+.mono { font-family: var(--console-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); }
+
+.agent-observer-drawer-fields { display: grid; gap: 14px; }
+.agent-observer-drawer-fields label { display: grid; gap: 6px; color: var(--console-muted); font-size: 12px; }
+.agent-observer-drawer-fields select { width: 100%; max-width: none; }
+.agent-observer-inspector-drawer { width: min(560px, calc(100vw - 24px)); }
+.agent-observer-inspector-drawer .console-overlay-body { padding: 0; }
+.agent-observer-inspector-drawer .operations-inspector-panel { min-height: 0; }
+
+@media (max-width: 1199px) {
+ .agent-observer-secondary-filters { display: none; }
+ .agent-observer-filter-trigger { display: inline-flex; }
+ .agent-observer-workspace.has-inspector { grid-template-columns: minmax(0, 1fr); }
+}
+
+@media (max-width: 720px) {
+ .agent-observer-page { grid-template-rows: auto auto auto auto minmax(0, 1fr); }
+ .agent-observer-search { min-width: 100%; }
+ .agent-observer-segment button { padding-inline: 7px; }
+ .agent-observer-card dl { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .agent-observer-inspector-grid,
+ .agent-observer-status-matrix { grid-template-columns: minmax(0, 1fr); }
+ .agent-observer-inspector-wide { grid-column: auto; }
+ .agent-observer-event-tail li,
+ .agent-observer-log-tail li { grid-template-columns: minmax(0, 1fr); }
+}
+
+@media (max-width: 520px) {
+ .console-drawer-backdrop:has(.agent-observer-inspector-drawer) { place-items: end stretch; }
+ .console-drawer.agent-observer-inspector-drawer { width: 100vw; height: min(82dvh, 720px); max-height: 82dvh; align-self: end; border: 1px solid var(--console-border-strong); border-bottom: 0; border-radius: 14px 14px 0 0; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .agent-observer-tree-toggle svg { transition: none; }
+ .agent-observer-table-row[data-recent="true"] { box-shadow: none; }
+}
diff --git a/web/hwlab-cloud-web/src/types/global.d.ts b/web/hwlab-cloud-web/src/types/global.d.ts
index f4e6ca38..ad587014 100644
--- a/web/hwlab-cloud-web/src/types/global.d.ts
+++ b/web/hwlab-cloud-web/src/types/global.d.ts
@@ -73,6 +73,7 @@ declare global {
reconnectDelayMs?: number;
batchMaxEvents?: number;
liveBufferLimit?: number;
+ identityWindowLimit?: number;
reducerEventLimit?: number;
reducerEntityLimit?: number;
treeNodeLimit?: number;
diff --git a/web/hwlab-cloud-web/src/views/agents/AgentRunsView.vue b/web/hwlab-cloud-web/src/views/agents/AgentRunsView.vue
index f993aa97..3f6a8e82 100644
--- a/web/hwlab-cloud-web/src/views/agents/AgentRunsView.vue
+++ b/web/hwlab-cloud-web/src/views/agents/AgentRunsView.vue
@@ -2,36 +2,57 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Activity, ChevronRight, Columns3, LayoutGrid, List, Network, RefreshCw, Search, SlidersHorizontal, X } from "lucide-vue-next";
-import PageCommandBar from "@/components/layout/PageCommandBar.vue";
-import InspectorPanel from "@/components/console/InspectorPanel.vue";
+import AgentRunInspector from "@/components/agents/AgentRunInspector.vue";
+import BaseDrawer from "@/components/common/BaseDrawer.vue";
import StatusBadge from "@/components/common/StatusBadge.vue";
import VirtualList from "@/components/common/VirtualList.vue";
-import { useAgentObserverStore, type AgentObserverRelationRow, type AgentObserverRun, type AgentObserverView } from "@/stores/agent-observer";
+import PageCommandBar from "@/components/layout/PageCommandBar.vue";
+import { agentObserverStatusToken, useAgentObserverStore, type AgentObserverRelationRow, type AgentObserverRun, type AgentObserverView } from "@/stores/agent-observer";
+
+type InspectorTab = "overview" | "relations" | "events" | "logs" | "raw";
+type VirtualListHandle = { scrollToIndex: (index: number, align?: "nearest" | "start" | "end") => Promise };
const route = useRoute();
const router = useRouter();
const observer = useAgentObserverStore();
-const inspectorTab = ref<"summary" | "events" | "lineage">("summary");
+const inspectorTab = ref("overview");
const urlApplying = ref(false);
const filtersOpen = ref(false);
-const filterTrigger = ref(null);
-const filterDrawer = ref(null);
-const treeRows = computed(() => observer.relationRows.slice(0, observer.config?.treeNodeLimit ?? observer.relationRows.length));
+const compactInspector = ref(false);
+const treeList = ref(null);
+const activeRelationId = ref(null);
+const treeRows = computed(() => observer.relationRows);
const ageLabel = computed(() => formatAge(observer.dataAgeMs));
const streamLabel = computed(() => ({ idle: "未连接", replay: "回放", live: "Live", reconnecting: "重连中", stale: "陈旧", error: "传输错误" }[observer.phase]));
+const selectedMissing = computed(() => Boolean(observer.selectedRunId && !observer.selectedRun));
+let inspectorMedia: MediaQueryList | null = null;
onMounted(() => {
applyRouteState();
observer.connect();
+ inspectorMedia = window.matchMedia("(max-width: 1199px)");
+ updateInspectorMode();
+ inspectorMedia.addEventListener("change", updateInspectorMode);
});
-onBeforeUnmount(() => observer.disconnect());
+onBeforeUnmount(() => {
+ observer.disconnect();
+ inspectorMedia?.removeEventListener("change", updateInspectorMode);
+});
watch(() => route.fullPath, applyRouteState);
watch(
() => [observer.view, observer.density, observer.search, observer.statusFilter, observer.projectFilter, observer.agentFilter, observer.timeWindow, observer.sort, observer.selectedRunId],
() => { if (!urlApplying.value) void syncUrl(); }
);
+watch(treeRows, (rows) => {
+ if (rows.length === 0) activeRelationId.value = null;
+ else if (!activeRelationId.value || !rows.some((row) => row.id === activeRelationId.value)) activeRelationId.value = rows[0]?.id ?? null;
+});
+
+function updateInspectorMode(): void {
+ compactInspector.value = inspectorMedia?.matches ?? false;
+}
function applyRouteState(): void {
urlApplying.value = true;
@@ -58,10 +79,9 @@ async function syncUrl(): Promise {
window: observer.timeWindow === "all" ? "" : observer.timeWindow,
sort: observer.sort === "updated-desc" ? "" : observer.sort
});
- const location = observer.selectedRunId
+ await router.replace(observer.selectedRunId
? { name: "AgentRunDetail", params: { runId: observer.selectedRunId }, query }
- : { name: "AgentRuns", query };
- await router.replace(location);
+ : { name: "AgentRuns", query });
}
function selectRun(run: AgentObserverRun): void {
@@ -77,58 +97,41 @@ function closeInspector(): void {
observer.selectRun(null);
}
-function setView(value: AgentObserverView): void {
- observer.view = value;
-}
-
-async function openFilters(): Promise {
- filtersOpen.value = true;
- await nextTick();
- filterDrawer.value?.focus();
-}
-
-function closeFilters(): void {
- filtersOpen.value = false;
- queueMicrotask(() => filterTrigger.value?.focus());
-}
-
-function rowKey(run: AgentObserverRun): string {
- return run.id;
-}
-
-function relationKey(row: AgentObserverRelationRow): string {
- return row.id;
-}
-
function activateRelation(row: AgentObserverRelationRow): void {
+ activeRelationId.value = row.id;
if (row.childCount > 0 && !row.runId) observer.toggleRelation(row.id, !row.expanded);
if (row.runId) observer.selectRun(row.runId);
}
-function onRelationKeydown(event: KeyboardEvent, row: AgentObserverRelationRow): void {
- const current = event.currentTarget;
- if (!(current instanceof HTMLElement)) return;
- const tree = current.closest('[role="tree"]');
- const items = tree ? [...tree.querySelectorAll('[role="treeitem"]')] : [];
- const index = items.indexOf(current);
+async function focusRelation(index: number, align: "nearest" | "start" | "end" = "nearest"): Promise {
+ const target = Math.max(0, Math.min(treeRows.value.length - 1, index));
+ const row = treeRows.value[target];
+ if (!row) return;
+ activeRelationId.value = row.id;
+ await treeList.value?.scrollToIndex(target, align);
+ await nextTick();
+ document.querySelector(`[data-relation-id="${CSS.escape(row.id)}"]`)?.focus();
+}
+
+async function onRelationKeydown(event: KeyboardEvent, row: AgentObserverRelationRow, index: number): Promise {
if (event.key === "ArrowRight" && row.childCount > 0) {
event.preventDefault();
- if (!row.expanded) observer.toggleRelation(row.id, true);
- else items[index + 1]?.focus();
+ if (!row.expanded) {
+ observer.toggleRelation(row.id, true);
+ await nextTick();
+ } else await focusRelation(index + 1);
return;
}
if (event.key === "ArrowLeft") {
event.preventDefault();
if (row.childCount > 0 && row.expanded) observer.toggleRelation(row.id, false);
- else if (row.parentId) items.find((item) => item.dataset.relationId === row.parentId)?.focus();
+ else if (row.parentId) await focusRelation(treeRows.value.findIndex((candidate) => candidate.id === row.parentId));
return;
}
- if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Home" || event.key === "End") {
+ if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
event.preventDefault();
- const target = event.key === "Home" ? items[0]
- : event.key === "End" ? items.at(-1)
- : items[index + (event.key === "ArrowDown" ? 1 : -1)];
- target?.focus();
+ const target = event.key === "Home" ? 0 : event.key === "End" ? treeRows.value.length - 1 : index + (event.key === "ArrowDown" ? 1 : -1);
+ await focusRelation(target, event.key === "Home" ? "start" : event.key === "End" ? "end" : "nearest");
return;
}
if (event.key === "Enter" || event.key === " ") {
@@ -137,195 +140,84 @@ function onRelationKeydown(event: KeyboardEvent, row: AgentObserverRelationRow):
}
}
-function statusTone(status: string): string {
- if (["failed", "error", "rejected"].includes(status)) return "failed";
- if (["completed", "succeeded"].includes(status)) return "completed";
- if (["canceled", "cancelled"].includes(status)) return "canceled";
- if (["queued", "pending", "waiting", "admitted"].includes(status)) return "pending";
- return "running";
+function status(status: string | null) {
+ return agentObserverStatusToken(status);
}
-function shortId(value: string | null, fallback = "—"): string {
- if (!value) return fallback;
- return value.length > 24 ? `${value.slice(0, 11)}…${value.slice(-8)}` : value;
-}
-
-function formatTime(value: string | null): string {
- if (!value) return "—";
- const date = new Date(value);
- if (Number.isNaN(date.getTime())) return value;
- return new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", second: "2-digit" }).format(date);
-}
-
-function duration(run: AgentObserverRun): string {
- if (!run.startedAt) return "—";
- const end = ["completed", "failed", "canceled", "cancelled"].includes(run.runStatus) && run.updatedAt ? Date.parse(run.updatedAt) : Date.now();
- return formatAge(Math.max(0, end - Date.parse(run.startedAt)));
-}
-
-function formatAge(value: number | null): string {
- if (value === null || !Number.isFinite(value)) return "无数据";
- if (value < 1000) return "刚刚";
- if (value < 60_000) return `${Math.floor(value / 1000)} 秒`;
- if (value < 3_600_000) return `${Math.floor(value / 60_000)} 分钟`;
- return `${Math.floor(value / 3_600_000)} 小时`;
-}
-
-function parseView(value: unknown): AgentObserverView {
- return value === "cards" || value === "tree" ? value : "table";
-}
-
-function textQuery(value: unknown): string {
- return typeof value === "string" ? value : "";
-}
-
-function compactQuery(value: Record): Record {
- return Object.fromEntries(Object.entries(value).filter(([, item]) => item));
-}
+function rowKey(run: AgentObserverRun): string { return run.id; }
+function relationKey(row: AgentObserverRelationRow): string { return row.id; }
+function shortId(value: string | null, fallback = "未知"): string { return !value ? fallback : value.length > 28 ? `${value.slice(0, 13)}…${value.slice(-10)}` : value; }
+function formatTime(value: string | null): string { const time = value ? Date.parse(value) : Number.NaN; return Number.isFinite(time) ? new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", second: "2-digit" }).format(time) : "未知"; }
+function duration(run: AgentObserverRun): string { const start = run.startedAt ? Date.parse(run.startedAt) : Number.NaN; const end = run.finishedAt ? Date.parse(run.finishedAt) : Date.now(); return Number.isFinite(start) && Number.isFinite(end) ? formatAge(Math.max(0, end - start)) : "未知"; }
+function formatAge(value: number | null): string { if (value === null || !Number.isFinite(value)) return "无数据"; if (value < 1000) return "刚刚"; if (value < 60_000) return `${Math.floor(value / 1000)} 秒`; if (value < 3_600_000) return `${Math.floor(value / 60_000)} 分钟`; return `${Math.floor(value / 3_600_000)} 小时`; }
+function parseView(value: unknown): AgentObserverView { return value === "cards" || value === "tree" ? value : "table"; }
+function textQuery(value: unknown): string { return typeof value === "string" ? value : ""; }
+function compactQuery(value: Record): Record { return Object.fromEntries(Object.entries(value).filter(([, item]) => item)); }
-
+
-
-
- {{ streamLabel }}
- {{ ageLabel }}
-
+ {{ streamLabel }}{{ ageLabel }}
{{ observer.config.nodeId }}/{{ observer.config.lane }}
-
-
-
-
-
-
-
-
+
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
+
+
+
-
-
{{ observer.stats.running }}运行中
-
{{ observer.stats.waiting }}等待
-
{{ observer.stats.failed }}失败
-
{{ observer.stats.terminal }}终态
-
{{ observer.stats.runners }}Runner
-
{{ observer.stats.total }}筛选结果
-
关系树当前事件窗口 {{ treeRows.length }}/{{ observer.relationRows.length }} 节点
+
+ {{ observer.stats.running }}运行{{ observer.stats.waiting }}等待{{ observer.stats.failed }}失败{{ observer.stats.stale }}陈旧{{ observer.stats.terminal }}终态{{ observer.stats.runners }}Runner{{ observer.stats.runnerCapacityKnown ? `${observer.stats.runnerCapacityUsed}/${observer.stats.runnerCapacityTotal}` : "未知" }}容量{{ observer.stats.total }}结果
-
{{ observer.error }}
+
深链所选实体不在当前 reducer 实体窗口;选择保持不变,等待正式 Kafka 事件重新进入窗口。
-
+
-
-
-
-
-
-
当前事件窗口没有匹配运行
-
保留最后确认投影;可清除筛选或等待 Kafka live 事件。
-
-
+
+ 当前事件窗口没有匹配运行保留最后确认投影;可清除筛选或等待 Kafka live 事件。
-
- 状态运行Agent上下文当前阶段活动Runner结果
-
-
-
-
-
- {{ shortId(run.id) }}{{ shortId(run.taskId, "task 未提供") }} · {{ shortId(run.attemptId, "attempt —") }}
- {{ run.backend || run.providerId || "—" }}{{ run.commandType || "AgentRun" }}
- {{ run.projectId || "—" }}{{ shortId(run.workspace) }} · {{ shortId(run.sessionId) }}
- {{ run.phase }}command {{ run.commandState || "未提供" }}
- {{ duration(run) }}{{ formatTime(run.updatedAt) }}
- {{ shortId(run.runnerId) }}{{ shortId(run.commandId) }}
- {{ run.errorCode || run.result || "—" }}{{ shortId(run.lastMessage) }}
-
-
+ Task / Run / Command运行上下文阶段活动Runner结果
+
+ {{ shortId(run.id) }}{{ shortId(run.taskId) }} · {{ shortId(run.attemptId) }}{{ run.projectId || "未知" }}{{ shortId(run.workspace) }} · {{ shortId(run.sessionId) }}{{ run.phase || "未知" }}{{ run.backend || run.providerId || "未知" }}{{ duration(run) }}{{ formatTime(run.updatedAt) }}{{ shortId(run.runnerId) }}{{ run.nodeId || "未知" }}/{{ run.lane || "未知" }}{{ run.errorCode || run.result || "未知" }}{{ shortId(run.lastMessage) }}
-
-
-
- {{ shortId(run.id) }}
- {{ shortId(run.taskId, "task 未提供") }} · {{ shortId(run.sessionId) }} · {{ run.projectId || "—" }}
- - Agent
- {{ run.backend || run.providerId || "—" }}
- Runner
- {{ shortId(run.runnerId) }}
- 阶段
- {{ run.phase }}
- 活动
- {{ formatTime(run.updatedAt) }}
-
-
+
+
+ {{ shortId(run.id) }}
{{ shortId(run.taskId) }} · {{ shortId(run.sessionId) }} · {{ shortId(run.workspace) }}
- Agent
- {{ run.backend || run.providerId || "未知" }}
- Runner
- {{ shortId(run.runnerId) }}
- 阶段
- {{ run.phase || "未知" }}
- 活动
- {{ formatTime(run.updatedAt) }}
+
-
-
-
-
-
-
- {{ node.kind }}
- {{ shortId(node.label) }}
- {{ node.detail || `${node.childCount} 个子节点` }}
-
-
+
+ {{ node.kind }}{{ shortId(node.label) }}{{ node.detail || `${node.childCount} 个子节点` }}
-
-
-
-
-
-
-
- - Run
- {{ observer.selectedRun.id }}
- Task
- {{ observer.selectedRun.taskId || "未提供" }}
- Command
- {{ observer.selectedRun.commandId || "—" }}
- Attempt
- {{ observer.selectedRun.attemptId || "—" }}
- Runner
- {{ observer.selectedRun.runnerId || "—" }}
- Agent/Profile
- {{ observer.selectedRun.backend || observer.selectedRun.providerId || "—" }}
- Project
- {{ observer.selectedRun.projectId || "—" }}
- Workspace
- {{ observer.selectedRun.workspace || "事件未提供" }}
- 结果
- {{ observer.selectedRun.errorCode || observer.selectedRun.result || "运行中" }}
-
-
- - {{ formatTime(event.createdAt) }}{{ event.label }}{{ event.message || event.status }}
-
-
- - Session
- {{ observer.selectedRun.sessionId || "—" }}
- Trace
- {{ observer.selectedRun.traceId || "—" }}
- Run
- {{ observer.selectedRun.id }}
- Command
- {{ observer.selectedRun.commandId || "—" }}
- Source seq
- {{ observer.selectedRun.lastSourceSeq ?? "—" }}
- Authority
- hwlab.event.v1
-
-
+
+
+
+
+