fix: 收敛 AgentRun 观察页主审阻塞
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -387,6 +387,7 @@ lanes:
|
||||
reconnectDelayMs: 1500
|
||||
batchMaxEvents: 100
|
||||
liveBufferLimit: 2000
|
||||
identityWindowLimit: 12000
|
||||
reducerEventLimit: 2000
|
||||
reducerEntityLimit: 500
|
||||
treeNodeLimit: 500
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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">搜索 run、task 或 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">搜索 run、task 或 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>
|
||||
|
||||
Reference in New Issue
Block a user