<script lang="ts">
import { onDestroy, onMount } from "svelte";
import {
Activity,
ChevronDown,
ChevronRight,
CircleAlert,
CircleCheck,
CircleSlash,
Clock,
TriangleAlert,
X,
} from "lucide-svelte";
import type {
CapabilityHealthObservation,
CapabilityHealthStatus,
WorkItemDiagnostic,
CandidateRevisionDiagnostic,
DiagnosticItem,
} from "../lib/protocol-v2";
import { resolveCapabilityHealth } from "../lib/protocol-v2";
export let health: CapabilityHealthObservation[] = [];
export let workItems: WorkItemDiagnostic[] = [];
export let candidate: CandidateRevisionDiagnostic | null = null;
export let diagnostics: DiagnosticItem[] = [];
export let rejectedCount = 0;
export let lastRejection: { seq: number; reason: string } | undefined = undefined;
export let onClose: () => void = () => {};
let now = Date.now();
let timer: ReturnType<typeof setInterval>;
let openDiags: Record<string, boolean> = {};
onMount(() => {
timer = setInterval(() => {
now = Date.now();
}, 1000);
});
onDestroy(() => {
if (timer) clearInterval(timer);
});
function toggleDiag(id: string) {
openDiags[id] = !openDiags[id];
}
function formatAge(timestampMs: number, currentMs: number): string {
const diffSec = Math.max(0, Math.floor((currentMs - timestampMs) / 1000));
if (diffSec < 60) return `${diffSec}s ago`;
const diffMin = Math.floor(diffSec / 60);
if (diffMin < 60) return `${diffMin}m ago`;
const diffHr = Math.floor(diffMin / 60);
return `${diffHr}h ago`;
}
function formatValidCountdown(validUntilMs: number | undefined, currentMs: number): string {
if (!validUntilMs) return "no ttl";
const diffSec = Math.floor((validUntilMs - currentMs) / 1000);
if (diffSec <= 0) return `expired ${Math.abs(diffSec)}s ago`;
if (diffSec < 60) return `expires in ${diffSec}s`;
const diffMin = Math.floor(diffSec / 60);
return `expires in ${diffMin}m`;
}
function computeOverallHealth(observations: CapabilityHealthObservation[], nowMs: number): CapabilityHealthStatus {
if (observations.length === 0) return "unknown";
const statuses = observations.map((o) => resolveCapabilityHealth(o, nowMs));
if (statuses.some((s) => s === "unavailable")) return "unavailable";
if (statuses.some((s) => s === "degraded" || s === "stale")) return "degraded";
if (statuses.every((s) => s === "healthy")) return "healthy";
return "unknown";
}
function summaryChipClass(status: CapabilityHealthStatus): string {
switch (status) {
case "healthy":
return "chip-ok";
case "degraded":
case "stale":
return "chip-warn";
case "unavailable":
return "chip-err";
case "unknown":
default:
return "";
}
}
function statusChipClass(word: string): string {
switch (word) {
case "ok":
case "healthy":
return "chip-ok";
case "degraded":
case "stale":
return "chip-warn";
case "unavailable":
return "chip-err";
case "unknown":
default:
return "";
}
}
function workItemChipClass(status: string): string {
switch (status) {
case "completed":
return "chip-ok";
case "executing":
case "selected":
return "chip-agent";
case "deferred":
return "chip-warn";
case "failed":
return "chip-err";
default:
return "";
}
}
function candidateChipClass(status: string): string {
switch (status) {
case "active":
case "pinned":
return "chip-ok";
case "testing":
return "chip-agent";
case "rolled_back":
return "chip-err";
default:
return "";
}
}
function formatPhrase(s: string): string {
if (!s) return "";
return s.replace(/_/g, " ").replace(/\./g, " ");
}
$: overallStatus = computeOverallHealth(health, now);
</script>
<div class="capability-panel">
<div class="panel-header hair">
<div class="header-main">
<span class="panel-title">
<Activity size={13} stroke-width={1.5} class="lane-lifecycle" />
<span>diagnostics</span>
</span>
<div class="header-chips">
<span class="chip {summaryChipClass(overallStatus)}">
{#if overallStatus === "healthy"}
<CircleCheck size={13} stroke-width={1.5} />
{:else if overallStatus === "degraded" || overallStatus === "stale"}
<TriangleAlert size={13} stroke-width={1.5} />
{:else if overallStatus === "unavailable"}
<CircleSlash size={13} stroke-width={1.5} />
{:else}
<Clock size={13} stroke-width={1.5} />
{/if}
{overallStatus}
</span>
{#if rejectedCount > 0}
<span class="chip chip-err">
<CircleSlash size={13} stroke-width={1.5} />
rejected <span class="num">{rejectedCount}</span>
</span>
{/if}
</div>
<button
type="button"
class="btn btn-icon"
onclick={onClose}
title="close diagnostics"
aria-label="close diagnostics"
>
<X size={13} stroke-width={1.5} />
</button>
</div>
{#if rejectedCount > 0 && lastRejection?.reason}
<div class="rejection-detail mute">
{lastRejection.reason}
</div>
{/if}
</div>
<div class="section-header hair mute">capabilities</div>
{#if health.length === 0}
<div class="empty-row mute hair">no capability probes reported</div>
{:else}
{#each health as probe (probe.id)}
{@const effective = resolveCapabilityHealth(probe, now)}
{@const statusWord = effective === "healthy" ? "ok" : effective}
<div class="panel-row hair">
<div class="row-main">
<span class="probe-name">{probe.name}</span>
<span class="chip {statusChipClass(statusWord)}">
{#if statusWord === "ok"}
<CircleCheck size={13} stroke-width={1.5} />
{:else if statusWord === "degraded" || statusWord === "stale"}
<TriangleAlert size={13} stroke-width={1.5} />
{:else if statusWord === "unavailable"}
<CircleSlash size={13} stroke-width={1.5} />
{:else}
<Clock size={13} stroke-width={1.5} />
{/if}
{statusWord}
</span>
<span class="mute num">{formatAge(probe.observed_ms, now)}</span>
{#if probe.valid_until_ms && effective === "stale"}
<span class="warn num">({formatValidCountdown(probe.valid_until_ms, now)})</span>
{/if}
</div>
{#if probe.impact || probe.evidence}
<div class="row-sub mute">
{#if probe.impact}<span>impact: {probe.impact}</span>{/if}
{#if probe.evidence}<span>evidence: {probe.evidence}</span>{/if}
</div>
{/if}
</div>
{/each}
{/if}
<div class="section-header hair mute">work items</div>
{#if workItems.length === 0}
<div class="empty-row mute hair">no work items</div>
{:else}
{#each workItems as item (item.id)}
<div class="panel-row hair">
<div class="row-main">
<span class="item-id">{item.id}</span>
<span class="chip {workItemChipClass(item.status)}">
{#if item.status === "completed"}
<CircleCheck size={13} stroke-width={1.5} />
{:else if item.status === "executing" || item.status === "selected"}
<Activity size={13} stroke-width={1.5} />
{:else if item.status === "deferred"}
<Clock size={13} stroke-width={1.5} />
{:else if item.status === "failed"}
<CircleAlert size={13} stroke-width={1.5} />
{/if}
{formatPhrase(item.status)}
</span>
<span class="mute num">{formatAge(item.created_ms, now)}</span>
</div>
{#if item.title || item.selection_reason || item.deferred_reason}
<div class="row-sub mute">
{#if item.title}
<span>{item.title}</span>
{:else if item.selection_reason}
<span>selection: {item.selection_reason}</span>
{:else if item.deferred_reason}
<span>deferral: {item.deferred_reason}</span>
{/if}
</div>
{/if}
</div>
{/each}
{/if}
{#if candidate}
<div class="section-header hair mute">candidate revision</div>
<div class="panel-row hair">
<div class="row-main">
<span class="item-id">{candidate.candidate_id}</span>
<span class="chip {candidateChipClass(candidate.status)}">
{#if candidate.status === "active" || candidate.status === "pinned"}
<CircleCheck size={13} stroke-width={1.5} />
{:else if candidate.status === "testing"}
<Activity size={13} stroke-width={1.5} />
{:else if candidate.status === "rolled_back"}
<CircleSlash size={13} stroke-width={1.5} />
{/if}
{formatPhrase(candidate.status)}
</span>
<span class="mute num">epoch {candidate.epoch ?? 1}</span>
</div>
<div class="row-sub mute num">
<span>base:{candidate.base_revision} → target:{candidate.target_revision}</span>
</div>
{#if candidate.test_receipts && candidate.test_receipts.length > 0}
<div class="receipts-list">
{#each candidate.test_receipts as receipt}
<div class="receipt-line">
<span class="chip {receipt.passed ? 'chip-ok' : 'chip-err'}">
{#if receipt.passed}
<CircleCheck size={13} stroke-width={1.5} /> pass
{:else}
<CircleAlert size={13} stroke-width={1.5} /> fail
{/if}
</span>
<span>{receipt.suite_id}</span>
<span class="mute num">{receipt.duration_ms}ms</span>
</div>
{/each}
</div>
{/if}
</div>
{/if}
<div class="section-header hair mute">raw diagnostics</div>
{#if diagnostics.length === 0}
<div class="empty-row mute hair">no diagnostics recorded</div>
{:else}
{#each diagnostics.slice(-50).reverse() as diag (diag.id)}
<div class="panel-row hair">
<div class="row-main">
<button
type="button"
class="btn"
onclick={() => toggleDiag(diag.id)}
aria-expanded={Boolean(openDiags[diag.id])}
title={openDiags[diag.id] ? `collapse ${diag.kind}` : `expand ${diag.kind}`}
aria-label={openDiags[diag.id] ? `collapse ${diag.kind}` : `expand ${diag.kind}`}
>
{#if openDiags[diag.id]}
<ChevronDown size={13} stroke-width={1.5} />
{:else}
<ChevronRight size={13} stroke-width={1.5} />
{/if}
<span>{diag.kind}</span>
</button>
<span class="mute num">{formatAge(diag.created_ms, now)}</span>
</div>
{#if openDiags[diag.id]}
<div class="diag-detail inset">
{#if diag.summary}
<div class="diag-summary mute">{diag.summary}</div>
{/if}
<pre class="diag-raw">{JSON.stringify(diag.data, null, 2)}</pre>
</div>
{/if}
</div>
{/each}
{/if}
</div>
<style>
.capability-panel {
width: 100%;
max-width: var(--w-panel, 320px);
font-family: var(--mono);
font-size: var(--fs-2);
line-height: var(--lh);
display: flex;
flex-direction: column;
}
.panel-header {
padding: var(--s-3);
display: flex;
flex-direction: column;
gap: var(--s-2);
}
.header-main {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s-2);
flex-wrap: wrap;
}
.panel-title {
display: inline-flex;
align-items: center;
gap: var(--s-2);
font-weight: 600;
color: var(--fg);
}
.header-chips {
display: flex;
align-items: center;
gap: var(--s-2);
flex-wrap: wrap;
}
.rejection-detail {
font-size: var(--fs-1);
overflow-wrap: anywhere;
}
.section-header {
padding: var(--s-2) var(--s-3);
background: var(--bg-3);
font-size: var(--fs-1);
text-transform: lowercase;
}
.empty-row {
padding: var(--s-2) var(--s-3);
font-size: var(--fs-1);
}
.panel-row {
padding: var(--s-2) var(--s-3);
display: flex;
flex-direction: column;
gap: var(--s-1);
}
.row-main {
display: flex;
align-items: center;
gap: var(--s-2);
flex-wrap: wrap;
}
.row-sub {
font-size: var(--fs-1);
overflow-wrap: anywhere;
display: flex;
gap: var(--s-3);
flex-wrap: wrap;
}
.probe-name, .item-id {
color: var(--fg);
}
.receipts-list {
display: flex;
flex-direction: column;
gap: var(--s-1);
margin-top: var(--s-1);
}
.receipt-line {
display: flex;
align-items: center;
gap: var(--s-2);
font-size: var(--fs-1);
}
.diag-detail {
padding: var(--s-2);
margin-top: var(--s-1);
display: flex;
flex-direction: column;
gap: var(--s-2);
border-radius: var(--r);
}
.diag-summary {
font-size: var(--fs-1);
overflow-wrap: anywhere;
}
.diag-raw {
margin: 0;
font-family: var(--mono);
font-size: var(--fs-1);
line-height: var(--lh);
color: var(--fg-mute);
white-space: pre-wrap;
overflow-wrap: anywhere;
max-height: 180px;
overflow-y: auto;
}
</style>