fix: 收敛 AgentRun 观察页主审阻塞

This commit is contained in:
root
2026-07-15 05:43:59 +02:00
parent cbefb988e4
commit 6bc6ff4f53
17 changed files with 489 additions and 297 deletions
+2
View File
@@ -622,6 +622,7 @@
"reconnectDelayMs",
"batchMaxEvents",
"liveBufferLimit",
"identityWindowLimit",
"reducerEventLimit",
"reducerEntityLimit",
"treeNodeLimit",
@@ -640,6 +641,7 @@
"reconnectDelayMs": { "type": "integer", "minimum": 100 },
"batchMaxEvents": { "type": "integer", "minimum": 1 },
"liveBufferLimit": { "type": "integer", "minimum": 1 },
"identityWindowLimit": { "type": "integer", "minimum": 1 },
"reducerEventLimit": { "type": "integer", "minimum": 1 },
"reducerEntityLimit": { "type": "integer", "minimum": 1 },
"treeNodeLimit": { "type": "integer", "minimum": 1 },
+1
View File
@@ -387,6 +387,7 @@ lanes:
reconnectDelayMs: 1500
batchMaxEvents: 100
liveBufferLimit: 2000
identityWindowLimit: 12000
reducerEventLimit: 2000
reducerEntityLimit: 500
treeNodeLimit: 500
+9
View File
@@ -1310,6 +1310,9 @@ export async function openKafkaEventStream({ env = process.env, stream = "hwlab"
function mapAgentRunSourceEventToHwlabEvent({ input, sourceEvent, payload, run, command, traceId, sessionId, sourceSeq, runId, commandId }) {
const type = firstText(sourceEvent.type, payload.type, input.eventType) || "event";
const task = objectValue(input.task ?? payload.task);
const runner = objectValue(input.runner ?? payload.runner);
const runnerCapacity = objectValue(runner.capacity ?? payload.runnerCapacity);
const base = {
traceId,
sessionId,
@@ -1318,8 +1321,14 @@ function mapAgentRunSourceEventToHwlabEvent({ input, sourceEvent, payload, run,
runId,
commandId,
taskId: firstText(payload.taskId, sourceEvent.taskId),
taskStatus: firstText(task.status, payload.taskStatus, payload.taskState, sourceEvent.taskStatus),
attemptId: firstText(command.attemptId, sourceEvent.attemptId, payload.attemptId),
attemptStatus: firstText(payload.attemptStatus, payload.attemptState, sourceEvent.attemptStatus),
runnerId: firstText(command.runnerId, sourceEvent.runnerId, payload.runnerId),
runnerCapacityUsed: integerValue(runnerCapacity.used ?? runner.capacityUsed ?? payload.runnerCapacityUsed),
runnerCapacityTotal: integerValue(runnerCapacity.total ?? runner.capacityTotal ?? payload.runnerCapacityTotal),
nodeId: firstText(runner.nodeId, payload.nodeId, input.nodeId),
lane: firstText(runner.lane, payload.lane, input.lane),
backend: firstText(run.backendProfile, payload.backendProfile, payload.providerProfile),
providerId: firstText(run.providerId, payload.providerId),
projectId: firstText(run.projectId, payload.projectId),
+7 -1
View File
@@ -76,6 +76,7 @@ export async function handleAgentObserverHttp(request, response, options = {}) {
const handoff = createWorkbenchKafkaRefreshHandoff({
allowUnscoped: true,
liveBufferLimit: policy.liveBufferLimit,
identityWindowLimit: policy.identityWindowLimit,
subscribeLive: (listener) => bridge.subscribeLiveHwlabEvents(listener),
queryRetention: ({ signal }) => bridge.queryHwlabEventRetention({
limit: policy.retentionEventLimit,
@@ -121,14 +122,19 @@ export async function handleAgentObserverHttp(request, response, options = {}) {
}
function agentObserverPolicy(env) {
return {
const policy = {
retentionEventLimit: positiveInteger(env.HWLAB_AGENT_OBSERVER_RETENTION_EVENT_LIMIT, "HWLAB_AGENT_OBSERVER_RETENTION_EVENT_LIMIT"),
retentionScanLimit: positiveInteger(env.HWLAB_AGENT_OBSERVER_RETENTION_SCAN_LIMIT, "HWLAB_AGENT_OBSERVER_RETENTION_SCAN_LIMIT"),
retentionTimeoutMs: positiveInteger(env.HWLAB_AGENT_OBSERVER_RETENTION_TIMEOUT_MS, "HWLAB_AGENT_OBSERVER_RETENTION_TIMEOUT_MS"),
retentionGroupPrefix: requiredText(env.HWLAB_AGENT_OBSERVER_RETENTION_GROUP_PREFIX, "HWLAB_AGENT_OBSERVER_RETENTION_GROUP_PREFIX"),
liveBufferLimit: positiveInteger(env.HWLAB_AGENT_OBSERVER_LIVE_BUFFER_LIMIT, "HWLAB_AGENT_OBSERVER_LIVE_BUFFER_LIMIT"),
identityWindowLimit: positiveInteger(env.HWLAB_AGENT_OBSERVER_IDENTITY_WINDOW_LIMIT, "HWLAB_AGENT_OBSERVER_IDENTITY_WINDOW_LIMIT"),
heartbeatMs: positiveInteger(env.HWLAB_AGENT_OBSERVER_SSE_HEARTBEAT_MS, "HWLAB_AGENT_OBSERVER_SSE_HEARTBEAT_MS")
};
if (policy.identityWindowLimit < policy.retentionEventLimit + policy.liveBufferLimit) {
throw Object.assign(new Error("HWLAB_AGENT_OBSERVER_IDENTITY_WINDOW_LIMIT must cover retentionEventLimit + liveBufferLimit."), { code: "agent_observer_config_invalid" });
}
return policy;
}
function positiveInteger(value, name) {
@@ -756,6 +756,7 @@ async function handleKafkaRefreshReplayWorkbenchRealtimeHttp(request, response,
sessionId: requestedSessionId,
traceId: requestedTraceId,
liveBufferLimit: refreshReplay.liveBufferLimit,
identityWindowLimit: refreshReplay.matchedEventLimit + refreshReplay.liveBufferLimit,
subscribeLive: (listener) => bridge.subscribeLiveHwlabEvents(listener),
queryRetention: ({ signal }) => bridge.queryHwlabEventRetention({
sessionId: requestedSessionId,
@@ -6,6 +6,7 @@ export function createWorkbenchKafkaRefreshHandoff(options = {}) {
const traceId = textValue(options.traceId);
const allowUnscoped = options.allowUnscoped === true;
const liveBufferLimit = positiveInteger(options.liveBufferLimit);
const identityWindowLimit = positiveInteger(options.identityWindowLimit) ?? liveBufferLimit;
if (!sessionId && !traceId && !allowUnscoped) throw handoffError("workbench_kafka_refresh_scope_missing", "Kafka refresh handoff requires a session or trace scope.", "idle");
if (!liveBufferLimit) throw handoffError("workbench_kafka_refresh_buffer_limit_invalid", "Kafka refresh handoff requires an explicit positive live buffer limit.", "idle");
if (typeof options.subscribeLive !== "function" || typeof options.queryRetention !== "function" || typeof options.deliverEvent !== "function" || typeof options.deliverConnected !== "function") {
@@ -16,10 +17,11 @@ export function createWorkbenchKafkaRefreshHandoff(options = {}) {
const barrierByPartition = new Map();
const replayProofByTransport = new Map();
const stableIdentityByTransport = new Map();
const seenEventIds = new Set();
const seenSourceEventIds = new Set();
const seenEventIds = new Map();
const seenSourceEventIds = new Map();
const sourceEventIdByEventId = new Map();
const eventIdBySourceEventId = new Map();
const identityWindow = [];
const abortController = new AbortController();
let phase = "idle";
let unsubscribe = null;
@@ -197,9 +199,9 @@ export function createWorkbenchKafkaRefreshHandoff(options = {}) {
scopedPartition = transport.partition;
const transportKey = transportIdentityKey(transport);
const priorProof = replayProofByTransport.get(transportKey);
if (priorProof && priorProof !== stableKey) throw handoffError("workbench_kafka_refresh_transport_conflict", "One Kafka transport identity maps to conflicting stable events.", phase);
replayProofByTransport.set(transportKey, stableKey);
if (priorProof && priorProof.stableKey !== stableKey) throw handoffError("workbench_kafka_refresh_transport_conflict", "One Kafka transport identity maps to conflicting stable events.", phase);
const deliver = rememberIdentity(transportKey, stableIdentity, phase);
replayProofByTransport.set(transportKey, stableIdentityByTransport.get(transportKey));
normalized.push({ envelope, transport, deliver });
}
return normalized;
@@ -224,7 +226,7 @@ export function createWorkbenchKafkaRefreshHandoff(options = {}) {
const transportKey = transportIdentityKey(transport);
if (compareOffsets(transport.offset, barrier) < 0) {
const replayProof = replayProofByTransport.get(transportKey);
if (replayProof !== stableKey) {
if (replayProof?.stableKey !== stableKey) {
throw handoffError("workbench_kafka_refresh_barrier_gap", "A pre-barrier shared live record was not proven by the retained replay scan.", phase, { partition: transport.partition });
}
}
@@ -234,14 +236,14 @@ export function createWorkbenchKafkaRefreshHandoff(options = {}) {
function rememberIdentity(transportKey, stableIdentity, currentPhase) {
const stableKey = stableIdentityKey(stableIdentity);
const priorStable = stableIdentityByTransport.get(transportKey);
if (priorStable && priorStable !== stableKey) {
if (priorStable && priorStable.stableKey !== stableKey) {
throw handoffError("workbench_kafka_refresh_transport_conflict", "One Kafka transport identity maps to conflicting stable events.", currentPhase);
}
const priorSourceEventId = stableIdentity.eventId ? sourceEventIdByEventId.get(stableIdentity.eventId) : null;
const priorSourceEventId = stableIdentity.eventId ? sourceEventIdByEventId.get(stableIdentity.eventId)?.sourceEventId : null;
if (priorSourceEventId && stableIdentity.sourceEventId && priorSourceEventId !== stableIdentity.sourceEventId) {
throw handoffError("workbench_kafka_refresh_stable_identity_conflict", "One outer eventId maps to conflicting sourceEventId identities.", currentPhase);
}
const priorEventId = stableIdentity.sourceEventId ? eventIdBySourceEventId.get(stableIdentity.sourceEventId) : null;
const priorEventId = stableIdentity.sourceEventId ? eventIdBySourceEventId.get(stableIdentity.sourceEventId)?.eventId : null;
if (priorEventId && stableIdentity.eventId && priorEventId !== stableIdentity.eventId) {
throw handoffError("workbench_kafka_refresh_stable_identity_conflict", "One outer sourceEventId maps to conflicting eventId identities.", currentPhase);
}
@@ -249,15 +251,18 @@ export function createWorkbenchKafkaRefreshHandoff(options = {}) {
(stableIdentity.eventId && seenEventIds.has(stableIdentity.eventId))
|| (stableIdentity.sourceEventId && seenSourceEventIds.has(stableIdentity.sourceEventId))
);
stableIdentityByTransport.set(transportKey, stableKey);
const entry = { transportKey, stableKey, eventId: stableIdentity.eventId, sourceEventId: stableIdentity.sourceEventId };
stableIdentityByTransport.set(transportKey, entry);
if (stableIdentity.eventId) {
seenEventIds.add(stableIdentity.eventId);
if (stableIdentity.sourceEventId) sourceEventIdByEventId.set(stableIdentity.eventId, stableIdentity.sourceEventId);
seenEventIds.set(stableIdentity.eventId, entry);
if (stableIdentity.sourceEventId) sourceEventIdByEventId.set(stableIdentity.eventId, entry);
}
if (stableIdentity.sourceEventId) {
seenSourceEventIds.add(stableIdentity.sourceEventId);
if (stableIdentity.eventId) eventIdBySourceEventId.set(stableIdentity.sourceEventId, stableIdentity.eventId);
seenSourceEventIds.set(stableIdentity.sourceEventId, entry);
if (stableIdentity.eventId) eventIdBySourceEventId.set(stableIdentity.sourceEventId, entry);
}
identityWindow.push(entry);
trimIdentityWindow();
if (duplicate) {
counts.deduplicated += 1;
return false;
@@ -265,6 +270,19 @@ export function createWorkbenchKafkaRefreshHandoff(options = {}) {
return true;
}
function trimIdentityWindow() {
while (identityWindow.length > identityWindowLimit) {
const entry = identityWindow.shift();
if (!entry) return;
if (stableIdentityByTransport.get(entry.transportKey) === entry) stableIdentityByTransport.delete(entry.transportKey);
if (replayProofByTransport.get(entry.transportKey) === entry) replayProofByTransport.delete(entry.transportKey);
if (entry.eventId && seenEventIds.get(entry.eventId) === entry) seenEventIds.delete(entry.eventId);
if (entry.sourceEventId && seenSourceEventIds.get(entry.sourceEventId) === entry) seenSourceEventIds.delete(entry.sourceEventId);
if (entry.eventId && sourceEventIdByEventId.get(entry.eventId) === entry) sourceEventIdByEventId.delete(entry.eventId);
if (entry.sourceEventId && eventIdBySourceEventId.get(entry.sourceEventId) === entry) eventIdBySourceEventId.delete(entry.sourceEventId);
}
}
function deliverSerial(envelope, transport, delivery) {
const run = async () => {
if (failure || phase === "failed" || phase === "closed") throw failure ?? handoffError("workbench_kafka_refresh_stopped", "Kafka refresh delivery stopped before the queued business event could be written.", phase);
@@ -294,6 +312,7 @@ export function createWorkbenchKafkaRefreshHandoff(options = {}) {
topicPartitions: scopedPartition === null ? [] : [scopedPartition],
barrier: [...barrierByPartition.entries()].map(([partition, endOffset]) => ({ partition, endOffset })).sort((left, right) => left.partition - right.partition),
counts: { ...counts },
identityWindowLimit,
valuesRedacted: true
};
}
@@ -431,6 +431,7 @@ function agentObserverRuntimeConfigFromEnv() {
"HWLAB_AGENT_OBSERVER_RECONNECT_DELAY_MS",
"HWLAB_AGENT_OBSERVER_BATCH_MAX_EVENTS",
"HWLAB_AGENT_OBSERVER_LIVE_BUFFER_LIMIT",
"HWLAB_AGENT_OBSERVER_IDENTITY_WINDOW_LIMIT",
"HWLAB_AGENT_OBSERVER_REDUCER_EVENT_LIMIT",
"HWLAB_AGENT_OBSERVER_REDUCER_ENTITY_LIMIT",
"HWLAB_AGENT_OBSERVER_TREE_NODE_LIMIT",
@@ -447,6 +448,7 @@ function agentObserverRuntimeConfigFromEnv() {
reconnectDelayMs: requiredPositiveWorkbenchInteger(process.env.HWLAB_AGENT_OBSERVER_RECONNECT_DELAY_MS, "HWLAB_AGENT_OBSERVER_RECONNECT_DELAY_MS"),
batchMaxEvents: requiredPositiveWorkbenchInteger(process.env.HWLAB_AGENT_OBSERVER_BATCH_MAX_EVENTS, "HWLAB_AGENT_OBSERVER_BATCH_MAX_EVENTS"),
liveBufferLimit: requiredPositiveWorkbenchInteger(process.env.HWLAB_AGENT_OBSERVER_LIVE_BUFFER_LIMIT, "HWLAB_AGENT_OBSERVER_LIVE_BUFFER_LIMIT"),
identityWindowLimit: requiredPositiveWorkbenchInteger(process.env.HWLAB_AGENT_OBSERVER_IDENTITY_WINDOW_LIMIT, "HWLAB_AGENT_OBSERVER_IDENTITY_WINDOW_LIMIT"),
reducerEventLimit: requiredPositiveWorkbenchInteger(process.env.HWLAB_AGENT_OBSERVER_REDUCER_EVENT_LIMIT, "HWLAB_AGENT_OBSERVER_REDUCER_EVENT_LIMIT"),
reducerEntityLimit: requiredPositiveWorkbenchInteger(process.env.HWLAB_AGENT_OBSERVER_REDUCER_ENTITY_LIMIT, "HWLAB_AGENT_OBSERVER_REDUCER_ENTITY_LIMIT"),
treeNodeLimit: requiredPositiveWorkbenchInteger(process.env.HWLAB_AGENT_OBSERVER_TREE_NODE_LIMIT, "HWLAB_AGENT_OBSERVER_TREE_NODE_LIMIT"),
+1
View File
@@ -1574,6 +1574,7 @@ function transformWorkloads({ workloads, deploy, catalog, source, sourceBranch =
upsertEnv(container.env, "HWLAB_AGENT_OBSERVER_RECONNECT_DELAY_MS", String(agentObserverPolicy.reconnectDelayMs));
upsertEnv(container.env, "HWLAB_AGENT_OBSERVER_BATCH_MAX_EVENTS", String(agentObserverPolicy.batchMaxEvents));
upsertEnv(container.env, "HWLAB_AGENT_OBSERVER_LIVE_BUFFER_LIMIT", String(agentObserverPolicy.liveBufferLimit));
upsertEnv(container.env, "HWLAB_AGENT_OBSERVER_IDENTITY_WINDOW_LIMIT", String(agentObserverPolicy.identityWindowLimit));
upsertEnv(container.env, "HWLAB_AGENT_OBSERVER_REDUCER_EVENT_LIMIT", String(agentObserverPolicy.reducerEventLimit));
upsertEnv(container.env, "HWLAB_AGENT_OBSERVER_REDUCER_ENTITY_LIMIT", String(agentObserverPolicy.reducerEntityLimit));
upsertEnv(container.env, "HWLAB_AGENT_OBSERVER_TREE_NODE_LIMIT", String(agentObserverPolicy.treeNodeLimit));
@@ -0,0 +1,97 @@
<script setup lang="ts">
import { computed } from "vue";
import InspectorPanel from "@/components/console/InspectorPanel.vue";
import StatusBadge from "@/components/common/StatusBadge.vue";
import { agentObserverStatusToken, type AgentObserverEvent, type AgentObserverRun } from "@/stores/agent-observer";
type InspectorTab = "overview" | "relations" | "events" | "logs" | "raw";
const props = defineProps<{
run: AgentObserverRun;
events: AgentObserverEvent[];
logs: AgentObserverEvent[];
inEventWindow: boolean;
tab: InspectorTab;
}>();
const emit = defineEmits<{ close: []; "update:tab": [value: InspectorTab] }>();
const tabs: Array<{ value: InspectorTab; label: string }> = [
{ value: "overview", label: "概览" },
{ value: "relations", label: "关系" },
{ value: "events", label: "事件" },
{ value: "logs", label: "日志" },
{ value: "raw", label: "原始事实" }
];
const taskToken = computed(() => agentObserverStatusToken(props.run.taskStatus));
const runToken = computed(() => agentObserverStatusToken(props.run.runStatus));
const commandToken = computed(() => agentObserverStatusToken(props.run.commandState));
const attemptToken = computed(() => agentObserverStatusToken(props.run.attemptStatus));
const deepLink = computed(() => `/agents/runs/${encodeURIComponent(props.run.id)}?view=table`);
const rawFacts = computed(() => JSON.stringify({ run: props.run, events: props.events }, null, 2));
function unknown(value: string | number | null): string | number {
return value === null || value === "" ? "未知" : value;
}
function formatTime(value: string | null): string {
if (!value) return "未知";
const timestamp = Date.parse(value);
return Number.isFinite(timestamp) ? new Date(timestamp).toLocaleString("zh-CN") : value;
}
function duration(): string {
if (!props.run.startedAt) return "未知";
const end = props.run.finishedAt ? Date.parse(props.run.finishedAt) : Date.now();
const start = Date.parse(props.run.startedAt);
if (!Number.isFinite(start) || !Number.isFinite(end)) return "未知";
const seconds = Math.max(0, Math.floor((end - start) / 1000));
if (seconds < 60) return `${seconds}`;
if (seconds < 3600) return `${Math.floor(seconds / 60)} 分钟`;
return `${Math.floor(seconds / 3600)} 小时 ${Math.floor((seconds % 3600) / 60)} 分钟`;
}
</script>
<template>
<InspectorPanel :title="run.id" :subtitle="`${runToken.label} · ${run.phase || '阶段未知'}`" @close="emit('close')">
<template #tabs>
<button v-for="item in tabs" :key="item.value" type="button" role="tab" :aria-selected="tab === item.value" @click="emit('update:tab', item.value)">{{ item.label }}</button>
</template>
<p v-if="!inEventWindow" class="agent-observer-window-warning" role="status">所选实体不在当前有界 Kafka 事件窗口保留稳定深链与最后确认实体事实不静默改选</p>
<div v-if="tab === 'overview'" class="agent-observer-inspector-section">
<div class="agent-observer-status-matrix">
<span><small>Task</small><StatusBadge :status="taskToken.tone" :label="taskToken.label" /></span>
<span><small>Run</small><StatusBadge :status="runToken.tone" :label="runToken.label" /></span>
<span><small>Command</small><StatusBadge :status="commandToken.tone" :label="commandToken.label" /></span>
<span><small>Attempt</small><StatusBadge :status="attemptToken.tone" :label="attemptToken.label" /></span>
</div>
<dl class="agent-observer-inspector-grid">
<div><dt>阶段</dt><dd>{{ unknown(run.phase) }}</dd></div><div><dt>错误分类</dt><dd>{{ unknown(run.errorCode) }}</dd></div>
<div><dt>开始</dt><dd>{{ formatTime(run.startedAt) }}</dd></div><div><dt>结束</dt><dd>{{ formatTime(run.finishedAt) }}</dd></div>
<div><dt>耗时</dt><dd>{{ duration() }}</dd></div><div><dt>最后活动</dt><dd>{{ formatTime(run.updatedAt) }}</dd></div>
<div><dt>最终事实</dt><dd>{{ run.final ? "已确认" : "未知" }}</dd></div><div><dt>结果</dt><dd>{{ unknown(run.result) }}</dd></div>
</dl>
</div>
<dl v-else-if="tab === 'relations'" class="agent-observer-inspector-grid">
<div><dt>Task</dt><dd class="mono">{{ unknown(run.taskId) }}</dd></div><div><dt>Run</dt><dd class="mono">{{ run.id }}</dd></div>
<div><dt>Command</dt><dd class="mono">{{ unknown(run.commandId) }}</dd></div><div><dt>Attempt</dt><dd class="mono">{{ unknown(run.attemptId) }}</dd></div>
<div><dt>Session</dt><dd class="mono">{{ unknown(run.sessionId) }}</dd></div><div><dt>Trace</dt><dd class="mono">{{ unknown(run.traceId) }}</dd></div>
<div><dt>Project</dt><dd>{{ unknown(run.projectId) }}</dd></div><div><dt>Workspace</dt><dd class="mono agent-observer-break">{{ unknown(run.workspace) }}</dd></div>
<div><dt>Runner</dt><dd class="mono">{{ unknown(run.runnerId) }}</dd></div><div><dt>Node / Lane</dt><dd>{{ unknown(run.nodeId) }} / {{ unknown(run.lane) }}</dd></div>
<div class="agent-observer-inspector-wide"><dt>稳定深链</dt><dd><a :href="deepLink" class="mono agent-observer-break">{{ deepLink }}</a></dd></div>
</dl>
<ol v-else-if="tab === 'events'" class="agent-observer-event-tail">
<li v-for="event in events" :key="event.id"><span>{{ formatTime(event.createdAt) }}</span><strong>{{ event.label }}</strong><small>{{ event.message || event.status || "未知" }}</small></li>
<li v-if="events.length === 0">当前有界 Kafka 事件窗口无可显示事件</li>
</ol>
<ol v-else-if="tab === 'logs'" class="agent-observer-event-tail agent-observer-log-tail">
<li v-for="event in logs" :key="event.id"><span>{{ formatTime(event.createdAt) }}</span><strong>{{ event.label }}</strong><pre>{{ event.message || "未知" }}</pre></li>
<li v-if="logs.length === 0">正式事件未提供日志事实</li>
</ol>
<pre v-else class="agent-observer-raw-facts">{{ rawFacts }}</pre>
</InspectorPanel>
</template>
@@ -11,16 +11,18 @@ const props = withDefaults(defineProps<{
placement?: "left" | "right";
closeOnBackdrop?: boolean;
busy?: boolean;
surfaceClass?: string;
}>(), {
description: "",
placement: "right",
closeOnBackdrop: true,
busy: false
busy: false,
surfaceClass: ""
});
defineEmits<{ close: [] }>();
</script>
<template>
<OverlaySurface kind="drawer" :open="open" :title="title" :description="description" :placement="placement" :close-on-backdrop="closeOnBackdrop" :busy="busy" @close="$emit('close')"><slot /><template v-if="$slots.footer" #footer><slot name="footer" /></template></OverlaySurface>
<OverlaySurface kind="drawer" :open="open" :title="title" :description="description" :placement="placement" :close-on-backdrop="closeOnBackdrop" :busy="busy" :surface-class="surfaceClass" @close="$emit('close')"><slot /><template v-if="$slots.footer" #footer><slot name="footer" /></template></OverlaySurface>
</template>
@@ -18,6 +18,7 @@ const props = withDefaults(defineProps<{
closeOnBackdrop?: boolean;
busy?: boolean;
initialFocus?: string;
surfaceClass?: string;
}>(), {
description: "",
kind: "dialog",
@@ -26,7 +27,8 @@ const props = withDefaults(defineProps<{
fullscreen: false,
closeOnBackdrop: true,
busy: false,
initialFocus: ""
initialFocus: "",
surfaceClass: ""
});
const emit = defineEmits<{ close: [] }>();
const surface = ref<HTMLElement | null>(null);
@@ -56,7 +58,7 @@ function close(): void {
:is="kind === 'drawer' ? 'aside' : 'section'"
ref="surface"
class="console-overlay-surface"
:class="{ 'console-drawer': kind === 'drawer' }"
:class="[{ 'console-drawer': kind === 'drawer' }, surfaceClass]"
:data-placement="kind === 'drawer' ? placement : undefined"
:data-wide="kind === 'dialog' && wide ? 'true' : undefined"
:data-fullscreen="kind === 'dialog' && fullscreen ? 'true' : undefined"
@@ -19,10 +19,14 @@ const props = withDefaults(defineProps<{
overscan?: number;
windowLimit?: number;
ariaLabel?: string;
rootRole?: string;
itemRole?: string;
}>(), {
height: 420,
overscan: 5,
ariaLabel: "虚拟列表"
ariaLabel: "虚拟列表",
rootRole: "list",
itemRole: "listitem"
});
const scrollTop = ref(0);
@@ -87,13 +91,25 @@ function onScroll(event: Event): void {
scrollTop.value = target.scrollTop;
viewportHeight.value = target.clientHeight;
}
async function scrollToIndex(index: number, align: "nearest" | "start" | "end" = "nearest"): Promise<void> {
const target = Math.max(0, Math.min(props.items.length - 1, index));
const top = target * props.itemHeight;
const bottom = top + props.itemHeight;
const viewportBottom = scrollTop.value + viewportHeight.value;
if (align === "start" || top < scrollTop.value) root.value?.scrollTo({ top });
else if (align === "end" || bottom > viewportBottom) root.value?.scrollTo({ top: Math.max(0, bottom - viewportHeight.value) });
await nextTick();
}
defineExpose({ scrollToIndex });
</script>
<template>
<div ref="root" class="console-virtual-list" :style="rootStyle" role="list" :aria-label="ariaLabel" @scroll.passive="onScroll">
<div ref="root" class="console-virtual-list" :style="rootStyle" :role="rootRole" :aria-label="ariaLabel" @scroll.passive="onScroll">
<div class="console-virtual-list-spacer" :style="{ height: `${totalHeight}px` }">
<div class="console-virtual-list-window" :style="{ transform: `translateY(${windowOffset}px)` }">
<div v-for="(item, localIndex) in visibleItems" :key="itemKey(item)" role="listitem" :style="{ height: `${itemHeight}px` }">
<div v-for="(item, localIndex) in visibleItems" :key="itemKey(item)" :role="itemRole" :style="{ height: `${itemHeight}px` }">
<slot :item="item" :index="startIndex + localIndex" />
</div>
</div>
@@ -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),
@@ -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<AgentObserverRuntimeConfig | null>(null);
const expandedRelationIds = ref<string[]>([]);
const collapsedRelationIds = ref<string[]>([]);
const seenEventIds = new Set<string>();
const seenIdentity = new Map<string, object>();
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<string, { used: number; total: number }>();
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";
+134 -62
View File
@@ -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; }
}
+1
View File
@@ -73,6 +73,7 @@ declare global {
reconnectDelayMs?: number;
batchMaxEvents?: number;
liveBufferLimit?: number;
identityWindowLimit?: number;
reducerEventLimit?: number;
reducerEntityLimit?: number;
treeNodeLimit?: number;
@@ -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<void> };
const route = useRoute();
const router = useRouter();
const observer = useAgentObserverStore();
const inspectorTab = ref<"summary" | "events" | "lineage">("summary");
const inspectorTab = ref<InspectorTab>("overview");
const urlApplying = ref(false);
const filtersOpen = ref(false);
const filterTrigger = ref<HTMLButtonElement | null>(null);
const filterDrawer = ref<HTMLElement | null>(null);
const treeRows = computed(() => observer.relationRows.slice(0, observer.config?.treeNodeLimit ?? observer.relationRows.length));
const compactInspector = ref(false);
const treeList = ref<VirtualListHandle | null>(null);
const activeRelationId = ref<string | null>(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<void> {
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<void> {
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<HTMLElement>('[role="treeitem"]')] : [];
const index = items.indexOf(current);
async function focusRelation(index: number, align: "nearest" | "start" | "end" = "nearest"): Promise<void> {
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<HTMLElement>(`[data-relation-id="${CSS.escape(row.id)}"]`)?.focus();
}
async function onRelationKeydown(event: KeyboardEvent, row: AgentObserverRelationRow, index: number): Promise<void> {
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<string, string>): Record<string, string> {
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<string, string>): Record<string, string> { return Object.fromEntries(Object.entries(value).filter(([, item]) => item)); }
</script>
<template>
<section class="agent-observer-page" :data-density="observer.density">
<PageCommandBar eyebrow="智能体管理" title="AgentRun 运行观察" description="纯 Kafka retention replay → live表格、卡片、关系树和 Inspector 共享同一 reducer、查询与选择。" sticky>
<PageCommandBar eyebrow="智能体管理" title="AgentRun 运行观察" description="纯 Kafka retention replay → live三种视图与统一 Inspector 共享同一 orderedIds、筛选、排序和选择。" sticky>
<template #actions>
<span class="agent-observer-stream" :data-phase="observer.phase" aria-live="polite">
<Activity :size="15" aria-hidden="true" />
<strong>{{ streamLabel }}</strong>
<span>{{ ageLabel }}</span>
</span>
<span class="agent-observer-stream" :data-phase="observer.phase" aria-live="polite"><Activity :size="15" aria-hidden="true" /><strong>{{ streamLabel }}</strong><span>{{ ageLabel }}</span></span>
<span v-if="observer.config" class="agent-observer-target mono">{{ observer.config.nodeId }}/{{ observer.config.lane }}</span>
<button class="btn btn-secondary" type="button" @click="observer.reconnect"><RefreshCw :size="15" aria-hidden="true" />重连</button>
</template>
<template #filters>
<label class="agent-observer-search">
<Search :size="16" aria-hidden="true" />
<span class="sr-only">搜索 runtask session</span>
<input v-model="observer.search" type="search" placeholder="搜索 run / task / session / error" />
<button v-if="observer.search" type="button" title="清除搜索" aria-label="清除搜索" @click="observer.search = ''"><X :size="14" /></button>
</label>
<select v-model="observer.statusFilter" aria-label="运行状态">
<option value="all">全部状态</option><option value="running">运行中</option><option value="waiting">等待</option><option value="failed">失败</option><option value="terminal">终态</option>
</select>
<div class="agent-observer-segment" role="group" aria-label="视图">
<button type="button" :aria-pressed="observer.view === 'table'" title="表格" @click="setView('table')"><List :size="16" /><span>表格</span></button>
<button type="button" :aria-pressed="observer.view === 'cards'" title="卡片" @click="setView('cards')"><LayoutGrid :size="16" /><span>卡片</span></button>
<button type="button" :aria-pressed="observer.view === 'tree'" title="关系树" @click="setView('tree')"><Network :size="16" /><span></span></button>
</div>
<button ref="filterTrigger" class="agent-observer-filter-trigger" type="button" aria-haspopup="dialog" :aria-expanded="filtersOpen" @click="openFilters"><SlidersHorizontal :size="16" />筛选</button>
<label class="agent-observer-search"><Search :size="16" aria-hidden="true" /><span class="sr-only">搜索 runtask session</span><input v-model="observer.search" type="search" placeholder="搜索 run / task / session / error" /><button v-if="observer.search" type="button" title="清除搜索" aria-label="清除搜索" @click="observer.search = ''"><X :size="14" /></button></label>
<div class="agent-observer-secondary-filters">
<select v-model="observer.projectFilter" aria-label="项目或 Workspace">
<option value="all">全部项目 / Workspace</option><option v-for="scope in observer.scopeOptions" :key="scope.value" :value="scope.value">{{ scope.label }}</option>
</select>
<select v-model="observer.agentFilter" aria-label="Agent 或 Profile">
<option value="all">全部 Agent</option><option v-for="agent in observer.agents" :key="agent" :value="agent">{{ agent }}</option>
</select>
<select v-model="observer.timeWindow" aria-label="时间窗口"><option value="all">全部窗口</option><option value="15m">最近 15 分钟</option><option value="1h">最近 1 小时</option><option value="6h">最近 6 小时</option></select>
<select v-model="observer.sort" aria-label="排序">
<option value="updated-desc">最近活动</option><option value="updated-asc">最早活动</option><option value="status-asc">状态</option><option value="id-asc">Run ID</option>
</select>
<button class="agent-observer-density" type="button" @click="observer.density = observer.density === 'compact' ? 'standard' : 'compact'"><Columns3 :size="15" />{{ observer.density === "compact" ? "紧凑" : "标准" }}</button>
<select v-model="observer.statusFilter" aria-label="运行状态"><option value="all">全部状态</option><option value="running">运行中</option><option value="waiting">等待</option><option value="failed">失败</option><option value="terminal">终态</option></select>
<select v-model="observer.projectFilter" aria-label="项目或 Workspace"><option value="all">全部 Project / Workspace</option><option v-for="item in observer.scopeOptions" :key="item.value" :value="item.value">{{ item.label }}</option></select>
<select v-model="observer.agentFilter" aria-label="Agent 或 Profile"><option value="all">全部 Agent / Profile</option><option v-for="item in observer.agents" :key="item" :value="item">{{ item }}</option></select>
<select v-model="observer.timeWindow" aria-label="时间窗口"><option value="all">全部窗口</option><option value="15m">15 分钟</option><option value="1h">1 小时</option><option value="6h">6 小时</option></select>
<select v-model="observer.sort" aria-label="排序"><option value="updated-desc">最近活动</option><option value="updated-asc">最早活动</option><option value="status-asc">状态</option><option value="id-asc">ID</option></select>
</div>
<button class="agent-observer-filter-trigger" type="button" @click="filtersOpen = true"><SlidersHorizontal :size="15" />筛选</button>
<div class="agent-observer-segment" aria-label="视图切换"><button type="button" :aria-pressed="observer.view === 'table'" @click="observer.view = 'table'"><List :size="15" />表格</button><button type="button" :aria-pressed="observer.view === 'cards'" @click="observer.view = 'cards'"><LayoutGrid :size="15" />卡片</button><button type="button" :aria-pressed="observer.view === 'tree'" @click="observer.view = 'tree'"><Network :size="15" /></button></div>
<button class="agent-observer-density" type="button" @click="observer.density = observer.density === 'compact' ? 'standard' : 'compact'"><Columns3 :size="15" />{{ observer.density === "compact" ? "紧凑" : "标准" }}</button>
</template>
</PageCommandBar>
<div v-if="filtersOpen" class="agent-observer-filter-backdrop" @click.self="closeFilters">
<aside ref="filterDrawer" class="agent-observer-filter-drawer" role="dialog" aria-modal="true" aria-labelledby="agent-observer-filter-title" tabindex="-1" @keydown.esc="closeFilters">
<header><strong id="agent-observer-filter-title">更多筛选</strong><button type="button" aria-label="关闭筛选" @click="closeFilters"><X :size="18" /></button></header>
<label>项目 / Workspace<select v-model="observer.projectFilter"><option value="all">全部项目 / Workspace</option><option v-for="scope in observer.scopeOptions" :key="scope.value" :value="scope.value">{{ scope.label }}</option></select></label>
<label>Agent / Profile<select v-model="observer.agentFilter"><option value="all">全部 Agent</option><option v-for="agent in observer.agents" :key="agent" :value="agent">{{ agent }}</option></select></label>
<label>时间窗口<select v-model="observer.timeWindow"><option value="all">全部窗口</option><option value="15m">最近 15 分钟</option><option value="1h">最近 1 小时</option><option value="6h">最近 6 小时</option></select></label>
<label>排序<select v-model="observer.sort"><option value="updated-desc">最近活动</option><option value="updated-asc">最早活动</option><option value="status-asc">状态</option><option value="id-asc">Run ID</option></select></label>
<button class="agent-observer-density" type="button" @click="observer.density = observer.density === 'compact' ? 'standard' : 'compact'"><Columns3 :size="15" />密度{{ observer.density === "compact" ? "紧凑" : "标准" }}</button>
<button class="btn btn-primary" type="button" @click="closeFilters">完成</button>
</aside>
</div>
<BaseDrawer :open="filtersOpen" title="AgentRun 筛选" description="与桌面命令栏共享同一查询状态。" surface-class="agent-observer-filter-drawer" @close="filtersOpen = false">
<div class="agent-observer-drawer-fields"><label>运行状态<select v-model="observer.statusFilter"><option value="all">全部状态</option><option value="running">运行中</option><option value="waiting">等待</option><option value="failed">失败</option><option value="terminal">终态</option></select></label><label>项目 / Workspace<select v-model="observer.projectFilter"><option value="all">全部</option><option v-for="item in observer.scopeOptions" :key="item.value" :value="item.value">{{ item.label }}</option></select></label><label>Agent / Profile<select v-model="observer.agentFilter"><option value="all">全部</option><option v-for="item in observer.agents" :key="item" :value="item">{{ item }}</option></select></label><label>时间窗口<select v-model="observer.timeWindow"><option value="all">全部</option><option value="15m">15 分钟</option><option value="1h">1 小时</option><option value="6h">6 小时</option></select></label></div>
</BaseDrawer>
<div class="agent-observer-status-strip" aria-label="运行摘要">
<span><strong>{{ observer.stats.running }}</strong>运行</span>
<span><strong>{{ observer.stats.waiting }}</strong>等待</span>
<span data-tone="danger"><strong>{{ observer.stats.failed }}</strong>失败</span>
<span><strong>{{ observer.stats.terminal }}</strong>终态</span>
<span><strong>{{ observer.stats.runners }}</strong>Runner</span>
<span><strong>{{ observer.stats.total }}</strong>筛选结果</span>
<span v-if="observer.view === 'tree' && treeRows.length < observer.relationRows.length" class="agent-observer-window-note">关系树当前事件窗口 {{ treeRows.length }}/{{ observer.relationRows.length }} 节点</span>
<div class="agent-observer-status-strip" aria-label="运行状态摘要">
<span><strong>{{ observer.stats.running }}</strong>运行</span><span><strong>{{ observer.stats.waiting }}</strong>等待</span><span data-tone="danger"><strong>{{ observer.stats.failed }}</strong>失败</span><span><strong>{{ observer.stats.stale }}</strong>陈旧</span><span><strong>{{ observer.stats.terminal }}</strong>终态</span><span><strong>{{ observer.stats.runners }}</strong>Runner</span><span><strong>{{ observer.stats.runnerCapacityKnown ? `${observer.stats.runnerCapacityUsed}/${observer.stats.runnerCapacityTotal}` : "未知" }}</strong>容量</span><span><strong>{{ observer.stats.total }}</strong>结果</span>
</div>
<div v-if="observer.error" class="agent-observer-inline-error" role="status">{{ observer.error }}</div>
<div v-if="selectedMissing" class="agent-observer-window-warning" role="status">深链所选实体不在当前 reducer 实体窗口选择保持不变等待正式 Kafka 事件重新进入窗口</div>
<div class="agent-observer-workspace" :class="{ 'has-inspector': observer.selectedRun }">
<div class="agent-observer-workspace" :class="{ 'has-inspector': observer.selectedRun && !compactInspector }">
<main class="agent-observer-content">
<div v-if="observer.phase === 'replay' && observer.runs.length === 0" class="agent-observer-skeleton" aria-label="正在回放 Kafka retention">
<span v-for="index in 8" :key="index" />
</div>
<div v-else-if="observer.visibleRuns.length === 0" class="agent-observer-empty">
<Activity :size="28" aria-hidden="true" />
<strong>当前事件窗口没有匹配运行</strong>
<span>保留最后确认投影可清除筛选或等待 Kafka live 事件</span>
<button class="btn btn-secondary" type="button" @click="observer.search = ''; observer.statusFilter = 'all'; observer.projectFilter = 'all'; observer.agentFilter = 'all'">清除筛选</button>
</div>
<div v-if="observer.phase === 'replay' && observer.runs.length === 0" class="agent-observer-skeleton" aria-label="正在回放 Kafka retention"><span v-for="index in 8" :key="index" /></div>
<div v-else-if="observer.visibleRuns.length === 0" class="agent-observer-empty"><Activity :size="28" aria-hidden="true" /><strong>当前事件窗口没有匹配运行</strong><span>保留最后确认投影;可清除筛选或等待 Kafka live 事件。</span><button class="btn btn-secondary" type="button" @click="observer.search = ''; observer.statusFilter = 'all'; observer.projectFilter = 'all'; observer.agentFilter = 'all'">清除筛选</button></div>
<section v-else-if="observer.view === 'table'" class="agent-observer-table" role="grid" aria-label="AgentRun 运行表格">
<div class="agent-observer-table-head" role="row">
<span role="columnheader">状态</span><span role="columnheader">运行</span><span role="columnheader">Agent</span><span role="columnheader">上下文</span><span role="columnheader">当前阶段</span><span role="columnheader">活动</span><span role="columnheader">Runner</span><span role="columnheader">结果</span>
</div>
<VirtualList :items="observer.visibleRuns" :item-key="rowKey" :item-height="observer.density === 'compact' ? 54 : 68" height="100%" :overscan="8" :window-limit="observer.config?.virtualListWindowLimit" aria-label="AgentRun 虚拟表格行">
<template #default="{ item: run }">
<div class="agent-observer-table-row" role="row" tabindex="0" :aria-selected="observer.selectedRunId === run.id" :data-selected="observer.selectedRunId === run.id" :data-recent="Date.now() - run.changedAtMs < (observer.config?.changeHighlightMs ?? 0)" @click="selectRun(run)" @dblclick="openRun(run)" @keydown.enter="openRun(run)">
<span role="gridcell"><StatusBadge :status="statusTone(run.runStatus)" :label="run.runStatus" /></span>
<span role="gridcell"><strong class="mono">{{ shortId(run.id) }}</strong><small>{{ shortId(run.taskId, "task 未提供") }} · {{ shortId(run.attemptId, "attempt —") }}</small></span>
<span role="gridcell"><strong>{{ run.backend || run.providerId || "—" }}</strong><small>{{ run.commandType || "AgentRun" }}</small></span>
<span role="gridcell"><strong>{{ run.projectId || "—" }}</strong><small :title="run.workspace || ''">{{ shortId(run.workspace) }} · {{ shortId(run.sessionId) }}</small></span>
<span role="gridcell"><strong>{{ run.phase }}</strong><small>command {{ run.commandState || "未提供" }}</small></span>
<span role="gridcell"><strong>{{ duration(run) }}</strong><small>{{ formatTime(run.updatedAt) }}</small></span>
<span role="gridcell"><strong class="mono">{{ shortId(run.runnerId) }}</strong><small>{{ shortId(run.commandId) }}</small></span>
<span role="gridcell"><strong>{{ run.errorCode || run.result || "—" }}</strong><small :title="run.lastMessage || ''">{{ shortId(run.lastMessage) }}</small></span>
</div>
</template>
<div class="agent-observer-table-head" role="row"><span role="columnheader">Task / Run / Command</span><span role="columnheader">运行</span><span role="columnheader">上下文</span><span role="columnheader">阶段</span><span role="columnheader">活动</span><span role="columnheader">Runner</span><span role="columnheader">结果</span></div>
<VirtualList :items="observer.visibleRuns" :item-key="rowKey" :item-height="observer.density === 'compact' ? 62 : 76" height="100%" :overscan="8" :window-limit="observer.config?.virtualListWindowLimit" root-role="presentation" item-role="presentation" aria-label="AgentRun 虚拟表格行">
<template #default="{ item: run }"><div class="agent-observer-table-row" role="row" tabindex="0" :aria-selected="observer.selectedRunId === run.id" :data-selected="observer.selectedRunId === run.id" :data-recent="Date.now() - run.changedAtMs < (observer.config?.changeHighlightMs ?? 0)" @click="selectRun(run)" @dblclick="openRun(run)" @keydown.enter="openRun(run)"><span role="gridcell" class="agent-observer-status-stack"><StatusBadge :status="status(run.taskStatus).tone" :label="`T ${status(run.taskStatus).label}`" /><StatusBadge :status="status(run.runStatus).tone" :label="`R ${status(run.runStatus).label}`" /><StatusBadge :status="status(run.commandState).tone" :label="`C ${status(run.commandState).label}`" /></span><span role="gridcell"><strong class="mono" :title="run.id">{{ shortId(run.id) }}</strong><small>{{ shortId(run.taskId) }} · {{ shortId(run.attemptId) }}</small></span><span role="gridcell"><strong>{{ run.projectId || "未知" }}</strong><small :title="run.workspace || ''">{{ shortId(run.workspace) }} · {{ shortId(run.sessionId) }}</small></span><span role="gridcell"><strong>{{ run.phase || "未知" }}</strong><small>{{ run.backend || run.providerId || "未知" }}</small></span><span role="gridcell"><strong>{{ duration(run) }}</strong><small>{{ formatTime(run.updatedAt) }}</small></span><span role="gridcell"><strong class="mono">{{ shortId(run.runnerId) }}</strong><small>{{ run.nodeId || "未知" }}/{{ run.lane || "未知" }}</small></span><span role="gridcell"><strong>{{ run.errorCode || run.result || "未知" }}</strong><small :title="run.lastMessage || ''">{{ shortId(run.lastMessage) }}</small></span></div></template>
</VirtualList>
</section>
<section v-else-if="observer.view === 'cards'" class="agent-observer-card-list" aria-label="AgentRun 卡片">
<article v-for="run in observer.visibleRuns" :key="run.id" class="agent-observer-card" tabindex="0" :data-status="statusTone(run.runStatus)" :data-selected="observer.selectedRunId === run.id" @click="selectRun(run)" @keydown.enter="openRun(run)">
<header><StatusBadge :status="statusTone(run.runStatus)" :label="run.runStatus" /><span>{{ duration(run) }}</span></header>
<h2 class="mono">{{ shortId(run.id) }}</h2>
<p>{{ shortId(run.taskId, "task 未提供") }} · {{ shortId(run.sessionId) }} · {{ run.projectId || "—" }}</p>
<dl><div><dt>Agent</dt><dd>{{ run.backend || run.providerId || "—" }}</dd></div><div><dt>Runner</dt><dd class="mono">{{ shortId(run.runnerId) }}</dd></div><div><dt>阶段</dt><dd>{{ run.phase }}</dd></div><div><dt>活动</dt><dd>{{ formatTime(run.updatedAt) }}</dd></div></dl>
<footer>{{ run.errorCode || run.lastMessage || "等待后续 Kafka 事件" }}</footer>
</article>
<section v-else-if="observer.view === 'cards'" class="agent-observer-card-list" aria-label="AgentRun 虚拟卡片窗口">
<VirtualList :items="observer.visibleRuns" :item-key="rowKey" :item-height="observer.density === 'compact' ? 158 : 184" height="100%" :overscan="5" :window-limit="observer.config?.virtualListWindowLimit" root-role="list" item-role="listitem" aria-label="所有 AgentRun 卡片结果">
<template #default="{ item: run }"><article class="agent-observer-card" tabindex="0" :data-status="status(run.runStatus).tone" :data-selected="observer.selectedRunId === run.id" @click="selectRun(run)" @keydown.enter="openRun(run)"><header><span class="agent-observer-status-stack"><StatusBadge :status="status(run.taskStatus).tone" :label="`Task ${status(run.taskStatus).label}`" /><StatusBadge :status="status(run.runStatus).tone" :label="`Run ${status(run.runStatus).label}`" /><StatusBadge :status="status(run.commandState).tone" :label="`Command ${status(run.commandState).label}`" /></span><span>{{ duration(run) }}</span></header><h2 class="mono" :title="run.id">{{ shortId(run.id) }}</h2><p :title="[run.taskId, run.sessionId, run.workspace].filter(Boolean).join(' · ')">{{ shortId(run.taskId) }} · {{ shortId(run.sessionId) }} · {{ shortId(run.workspace) }}</p><dl><div><dt>Agent</dt><dd>{{ run.backend || run.providerId || "未知" }}</dd></div><div><dt>Runner</dt><dd class="mono">{{ shortId(run.runnerId) }}</dd></div><div><dt>阶段</dt><dd>{{ run.phase || "未知" }}</dd></div><div><dt>活动</dt><dd>{{ formatTime(run.updatedAt) }}</dd></div></dl><footer :title="run.lastMessage || ''">{{ run.errorCode || run.lastMessage || "正式事件未提供结果" }}</footer></article></template>
</VirtualList>
</section>
<section v-else class="agent-observer-tree" role="tree" aria-label="AgentRun 关系树">
<VirtualList :items="treeRows" :item-key="relationKey" :item-height="observer.density === 'compact' ? 44 : 54" height="100%" :overscan="8" :window-limit="observer.config?.virtualListWindowLimit" aria-label="AgentRun 关系树节点">
<template #default="{ item: node }">
<div class="agent-observer-tree-row" role="treeitem" :data-relation-id="node.id" :aria-level="node.depth + 1" :aria-posinset="node.position" :aria-setsize="node.setSize" :aria-expanded="node.childCount > 0 ? node.expanded : undefined" :aria-selected="Boolean(node.runId && observer.selectedRunId === node.runId)" tabindex="0" :style="{ paddingInlineStart: `${14 + node.depth * 22}px` }" @click="activateRelation(node)" @keydown="onRelationKeydown($event, node)">
<button v-if="node.childCount > 0" class="agent-observer-tree-toggle" type="button" :aria-label="node.expanded ? '折叠' : '展开'" tabindex="-1" @click.stop="observer.toggleRelation(node.id, !node.expanded)"><ChevronRight :size="15" :data-expanded="node.expanded" /></button>
<span v-else class="agent-observer-tree-spacer" aria-hidden="true" />
<StatusBadge v-if="node.status" :status="statusTone(node.status)" :label="node.status" />
<span v-else class="agent-observer-relation-kind">{{ node.kind }}</span>
<strong class="mono" :title="node.label">{{ shortId(node.label) }}</strong>
<small :title="node.detail || ''">{{ node.detail || `${node.childCount} 个子节点` }}</small>
</div>
</template>
<VirtualList ref="treeList" :items="treeRows" :item-key="relationKey" :item-height="44" height="100%" :overscan="8" :window-limit="observer.config?.virtualListWindowLimit" root-role="presentation" item-role="presentation" aria-label="AgentRun 虚拟关系树">
<template #default="{ item: node, index }"><div class="agent-observer-tree-row" role="treeitem" :data-relation-id="node.id" :data-tree-index="index" :aria-level="node.depth + 1" :aria-posinset="node.position" :aria-setsize="node.setSize" :aria-expanded="node.childCount > 0 ? node.expanded : undefined" :aria-selected="observer.selectedRunId === node.runId" :tabindex="activeRelationId === node.id || (!activeRelationId && index === 0) ? 0 : -1" :style="{ '--tree-depth': node.depth }" @focus="activeRelationId = node.id" @click="activateRelation(node)" @keydown="onRelationKeydown($event, node, index)"><button v-if="node.childCount > 0" class="agent-observer-tree-toggle" type="button" :aria-label="node.expanded ? '折叠' : '展开'" tabindex="-1" @click.stop="observer.toggleRelation(node.id, !node.expanded)"><ChevronRight :size="15" :data-expanded="node.expanded" /></button><span v-else class="agent-observer-tree-spacer" aria-hidden="true" /><StatusBadge v-if="node.status" :status="status(node.status).tone" :label="status(node.status).label" /><span v-else class="agent-observer-relation-kind">{{ node.kind }}</span><strong class="mono" :title="node.label">{{ shortId(node.label) }}</strong><small :title="node.detail || ''">{{ node.detail || `${node.childCount} 个子节点` }}</small></div></template>
</VirtualList>
</section>
</main>
<InspectorPanel v-if="observer.selectedRun" class="agent-observer-inspector" :title="shortId(observer.selectedRun.id)" :subtitle="`${observer.selectedRun.runStatus} · ${observer.selectedRun.phase}`" @close="closeInspector">
<template #tabs>
<button type="button" role="tab" :aria-selected="inspectorTab === 'summary'" @click="inspectorTab = 'summary'">摘要</button>
<button type="button" role="tab" :aria-selected="inspectorTab === 'events'" @click="inspectorTab = 'events'">事件</button>
<button type="button" role="tab" :aria-selected="inspectorTab === 'lineage'" @click="inspectorTab = 'lineage'">Lineage</button>
</template>
<dl v-if="inspectorTab === 'summary'" class="agent-observer-inspector-grid">
<div><dt>Run</dt><dd class="mono">{{ observer.selectedRun.id }}</dd></div><div><dt>Task</dt><dd class="mono">{{ observer.selectedRun.taskId || "未提供" }}</dd></div><div><dt>Command</dt><dd class="mono">{{ observer.selectedRun.commandId || "—" }}</dd></div><div><dt>Attempt</dt><dd class="mono">{{ observer.selectedRun.attemptId || "—" }}</dd></div><div><dt>Runner</dt><dd class="mono">{{ observer.selectedRun.runnerId || "—" }}</dd></div><div><dt>Agent/Profile</dt><dd>{{ observer.selectedRun.backend || observer.selectedRun.providerId || "—" }}</dd></div><div><dt>Project</dt><dd>{{ observer.selectedRun.projectId || "—" }}</dd></div><div><dt>Workspace</dt><dd class="mono">{{ observer.selectedRun.workspace || "事件未提供" }}</dd></div><div><dt>结果</dt><dd>{{ observer.selectedRun.errorCode || observer.selectedRun.result || "运行中" }}</dd></div>
</dl>
<ol v-else-if="inspectorTab === 'events'" class="agent-observer-event-tail">
<li v-for="event in observer.selectedEvents" :key="event.id"><span>{{ formatTime(event.createdAt) }}</span><strong>{{ event.label }}</strong><small>{{ event.message || event.status }}</small></li>
</ol>
<dl v-else class="agent-observer-inspector-grid">
<div><dt>Session</dt><dd class="mono">{{ observer.selectedRun.sessionId || "—" }}</dd></div><div><dt>Trace</dt><dd class="mono">{{ observer.selectedRun.traceId || "—" }}</dd></div><div><dt>Run</dt><dd class="mono">{{ observer.selectedRun.id }}</dd></div><div><dt>Command</dt><dd class="mono">{{ observer.selectedRun.commandId || "—" }}</dd></div><div><dt>Source seq</dt><dd>{{ observer.selectedRun.lastSourceSeq ?? "—" }}</dd></div><div><dt>Authority</dt><dd class="mono">hwlab.event.v1</dd></div>
</dl>
</InspectorPanel>
<AgentRunInspector v-if="observer.selectedRun && !compactInspector" class="agent-observer-inspector" :run="observer.selectedRun" :events="observer.selectedEvents" :logs="observer.selectedLogs" :in-event-window="observer.selectedInEventWindow" :tab="inspectorTab" @update:tab="inspectorTab = $event" @close="closeInspector" />
</div>
<BaseDrawer :open="Boolean(observer.selectedRun && compactInspector)" title="AgentRun Inspector" description="选择与页签在桌面、Drawer 和 bottom sheet 间保持。" surface-class="agent-observer-inspector-drawer" @close="closeInspector">
<AgentRunInspector v-if="observer.selectedRun" :run="observer.selectedRun" :events="observer.selectedEvents" :logs="observer.selectedLogs" :in-event-window="observer.selectedInEventWindow" :tab="inspectorTab" @update:tab="inspectorTab = $event" @close="closeInspector" />
</BaseDrawer>
</section>
</template>