|
|
|
|
@@ -16,6 +16,7 @@
|
|
|
|
|
fetchEntityKnowledge,
|
|
|
|
|
fetchKnowledgeContent,
|
|
|
|
|
fetchChecksForTarget,
|
|
|
|
|
fetchBlastRadius,
|
|
|
|
|
fetchAgentActivity,
|
|
|
|
|
fetchAudit,
|
|
|
|
|
patchCheck,
|
|
|
|
|
@@ -32,11 +33,21 @@
|
|
|
|
|
type KnowledgeContent,
|
|
|
|
|
type Check,
|
|
|
|
|
type AgentActivity,
|
|
|
|
|
type AuditEntry
|
|
|
|
|
type AuditEntry,
|
|
|
|
|
type BlastRadiusItem
|
|
|
|
|
} from '$lib/api'
|
|
|
|
|
import { relativeTime, truncateMiddle } from '$lib/utils'
|
|
|
|
|
import { liveEvents, subscribeEvents, type OikosEvent } from '$lib/stores/events'
|
|
|
|
|
import { isHealthEvent, applyHealthEventTo } from '$lib/health'
|
|
|
|
|
import {
|
|
|
|
|
deriveVerdict,
|
|
|
|
|
sectionsForType,
|
|
|
|
|
isObservable,
|
|
|
|
|
checkLabel,
|
|
|
|
|
nomosPrompt,
|
|
|
|
|
type SectionKey
|
|
|
|
|
} from '$lib/entityView'
|
|
|
|
|
import { openNewTaskWindow } from '$lib/stores/windows'
|
|
|
|
|
import DetailSection from '$lib/components/DetailSection.svelte'
|
|
|
|
|
import { Badge } from '$lib/components/ui/badge'
|
|
|
|
|
import { Button } from '$lib/components/ui/button'
|
|
|
|
|
@@ -67,6 +78,8 @@
|
|
|
|
|
let checks = $state<Check[]>([])
|
|
|
|
|
let agentActivity = $state<AgentActivity[]>([])
|
|
|
|
|
let auditEntries = $state<AuditEntry[]>([])
|
|
|
|
|
let blast = $state<BlastRadiusItem[]>([])
|
|
|
|
|
let showAllRelations = $state(false)
|
|
|
|
|
let loading = $state(true)
|
|
|
|
|
let actingSignal = $state<string | null>(null)
|
|
|
|
|
let chartContainers: Record<string, HTMLDivElement> = {}
|
|
|
|
|
@@ -75,6 +88,25 @@
|
|
|
|
|
// (source OR target = entity, both directions), so a plain split by which
|
|
|
|
|
// side matches is enough — no risk of an unrelated sibling-to-sibling edge
|
|
|
|
|
// sneaking into either group.
|
|
|
|
|
// The single most valuable line in the window: not just "down", but which
|
|
|
|
|
// probe is responsible. Derived from per-check verdicts (check.last_health),
|
|
|
|
|
// which the API only started exposing for this redesign.
|
|
|
|
|
// Relations are the one section where a single entity can legitimately have
|
|
|
|
|
// hundreds; everything else is naturally bounded.
|
|
|
|
|
const RELATION_PREVIEW = 8
|
|
|
|
|
const hiddenRelations = $derived(
|
|
|
|
|
Math.max(outgoingRelations.length - RELATION_PREVIEW, 0) +
|
|
|
|
|
Math.max(incomingRelations.length - RELATION_PREVIEW, 0)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const verdict = $derived(deriveVerdict(entity, checks))
|
|
|
|
|
const openSignals = $derived(signals.filter((s) => !['resolved', 'failed'].includes(s.state)))
|
|
|
|
|
|
|
|
|
|
function askNomos() {
|
|
|
|
|
if (!entity) return
|
|
|
|
|
openNewTaskWindow(nomosPrompt(entity, verdict))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const outgoingRelations = $derived(
|
|
|
|
|
entity ? relations.filter((r) => r.source === entity!.slug) : []
|
|
|
|
|
)
|
|
|
|
|
@@ -89,7 +121,7 @@
|
|
|
|
|
loading = false
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
const [rel, m, ev, sig, ex, tk, kh, oc, ch, aa, au] = await Promise.all([
|
|
|
|
|
const [rel, m, ev, sig, ex, tk, kh, oc, ch, aa, au, br] = await Promise.all([
|
|
|
|
|
fetchEntityRelations(entity.id),
|
|
|
|
|
fetchMetrics(entity.id),
|
|
|
|
|
fetchEntityEvents(entity.id),
|
|
|
|
|
@@ -100,7 +132,9 @@
|
|
|
|
|
KNOWLEDGE_TYPES.has(entity.type) ? fetchKnowledgeContent(entity.id) : Promise.resolve(null),
|
|
|
|
|
fetchChecksForTarget(entity.slug),
|
|
|
|
|
fetchAgentActivity({ entity_id: entity.id, limit: 50 }),
|
|
|
|
|
fetchAudit({ entity_id: entity.id, limit: 50 })
|
|
|
|
|
fetchAudit({ entity_id: entity.id, limit: 50 }),
|
|
|
|
|
// Only for things that can actually break; a document has no blast radius.
|
|
|
|
|
isObservable(entity.type) ? fetchBlastRadius(entity.slug, 2) : Promise.resolve([])
|
|
|
|
|
])
|
|
|
|
|
relations = rel
|
|
|
|
|
metrics = m
|
|
|
|
|
@@ -113,6 +147,7 @@
|
|
|
|
|
checks = ch
|
|
|
|
|
agentActivity = aa
|
|
|
|
|
auditEntries = au
|
|
|
|
|
blast = br
|
|
|
|
|
loading = false
|
|
|
|
|
|
|
|
|
|
await tick()
|
|
|
|
|
@@ -232,7 +267,13 @@
|
|
|
|
|
// Health is patched from the event itself — it carries the new value, so
|
|
|
|
|
// there is nothing to go and ask for.
|
|
|
|
|
if (isHealthEvent(ev)) {
|
|
|
|
|
// The dot patches from the event, but the header's *reason* and the
|
|
|
|
|
// Status section are derived from per-check verdicts — and a health
|
|
|
|
|
// change means some check's verdict just moved. Patching only the entity
|
|
|
|
|
// would leave "ping failing" on screen after ping recovered. One small
|
|
|
|
|
// fetch, scoped to this entity's checks.
|
|
|
|
|
entity = applyHealthEventTo(entity, ev)
|
|
|
|
|
refreshChecks()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -244,6 +285,13 @@
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function refreshChecks() {
|
|
|
|
|
if (!entity) return
|
|
|
|
|
const slugAtStart = entity.slug
|
|
|
|
|
const next = await fetchChecksForTarget(slugAtStart)
|
|
|
|
|
if (entity?.slug === slugAtStart) checks = next
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function refreshSignals() {
|
|
|
|
|
if (!entity) return
|
|
|
|
|
const id = entity.id
|
|
|
|
|
@@ -434,7 +482,53 @@
|
|
|
|
|
{:else if !entity}
|
|
|
|
|
<p class="text-sm text-muted-foreground">Entity "{slug}" not found.</p>
|
|
|
|
|
{:else}
|
|
|
|
|
<h1 class="font-mono text-sm font-semibold">{entity.slug}</h1>
|
|
|
|
|
<!-- Verdict header. Never collapses, and answers "is this OK, and why?"
|
|
|
|
|
before anything else on screen. Replaces a bare slug heading followed
|
|
|
|
|
by a Details section that had to be expanded to learn anything. -->
|
|
|
|
|
<header class="flex flex-col gap-2 rounded-lg border bg-card px-3 py-2.5">
|
|
|
|
|
<div class="flex items-start justify-between gap-3">
|
|
|
|
|
<div class="flex min-w-0 flex-col gap-0.5">
|
|
|
|
|
<h1 class="truncate font-mono text-sm font-semibold">{entity.slug}</h1>
|
|
|
|
|
<div class="flex flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground">
|
|
|
|
|
<span>{entity.type}</span>
|
|
|
|
|
{#if entity.state && entity.state !== 'active'}
|
|
|
|
|
<span>·</span><Badge variant="outline">{entity.state}</Badge>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<Button size="sm" variant="outline" class="h-7 shrink-0 gap-1.5 text-xs" onclick={askNomos}>
|
|
|
|
|
Ask Nomos
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{#if isObservable(entity.type)}
|
|
|
|
|
<div class="flex flex-col gap-1">
|
|
|
|
|
<div class="flex items-center gap-2 text-sm">
|
|
|
|
|
<span
|
|
|
|
|
class="size-2.5 shrink-0 rounded-full {verdict.health === 'unmonitored'
|
|
|
|
|
? 'bg-muted-foreground/40'
|
|
|
|
|
: (healthDot[verdict.health] ?? healthDot.unknown)}"
|
|
|
|
|
></span>
|
|
|
|
|
<span class="font-medium">{verdict.health}</span>
|
|
|
|
|
{#if entity.last_check_at}
|
|
|
|
|
<span class="text-xs text-muted-foreground"
|
|
|
|
|
>· checked {relativeTime(entity.last_check_at)}</span
|
|
|
|
|
>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
<!-- The reason. Without this the header would just restate the dot. -->
|
|
|
|
|
{#if verdict.reason}
|
|
|
|
|
<p class="text-xs text-muted-foreground">{verdict.reason}</p>
|
|
|
|
|
{/if}
|
|
|
|
|
{#if openSignals.length > 0}
|
|
|
|
|
<p class="text-xs text-warning">
|
|
|
|
|
{openSignals.length}
|
|
|
|
|
open signal{openSignals.length === 1 ? '' : 's'}
|
|
|
|
|
</p>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
{#snippet detailsContent()}
|
|
|
|
|
<dl class="flex flex-col gap-1 text-xs">
|
|
|
|
|
@@ -638,7 +732,7 @@
|
|
|
|
|
Outgoing ({outgoingRelations.length})
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex flex-col gap-1">
|
|
|
|
|
{#each outgoingRelations as rel}
|
|
|
|
|
{#each showAllRelations ? outgoingRelations : outgoingRelations.slice(0, RELATION_PREVIEW) as rel}
|
|
|
|
|
{@render relationRow(rel)}
|
|
|
|
|
{/each}
|
|
|
|
|
</div>
|
|
|
|
|
@@ -652,12 +746,25 @@
|
|
|
|
|
Incoming ({incomingRelations.length})
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex flex-col gap-1">
|
|
|
|
|
{#each incomingRelations as rel}
|
|
|
|
|
{#each showAllRelations ? incomingRelations : incomingRelations.slice(0, RELATION_PREVIEW) as rel}
|
|
|
|
|
{@render relationRow(rel)}
|
|
|
|
|
{/each}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
<!-- host:hubris has 223 relations. Rendering them flat put ~540
|
|
|
|
|
interactive elements in one window and buried everything else;
|
|
|
|
|
a preview with an explicit drill-in keeps the section scannable
|
|
|
|
|
without hiding anything. -->
|
|
|
|
|
{#if !showAllRelations && hiddenRelations > 0}
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
class="self-start text-[11px] text-muted-foreground underline-offset-2 hover:underline"
|
|
|
|
|
onclick={() => (showAllRelations = true)}
|
|
|
|
|
>
|
|
|
|
|
Show {hiddenRelations} more
|
|
|
|
|
</button>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
{/snippet}
|
|
|
|
|
@@ -883,48 +990,199 @@
|
|
|
|
|
</div>
|
|
|
|
|
{/snippet}
|
|
|
|
|
|
|
|
|
|
{@const sections = [
|
|
|
|
|
...(ownContent
|
|
|
|
|
? [{ key: 'content', title: 'Content', count: 1, content: contentContent }]
|
|
|
|
|
: []),
|
|
|
|
|
{ key: 'details', title: 'Details', count: 1, content: detailsContent },
|
|
|
|
|
{ key: 'monitoring', title: 'Monitoring', count: checks.length, content: monitoringContent },
|
|
|
|
|
{
|
|
|
|
|
key: 'attributes',
|
|
|
|
|
title: 'Attributes',
|
|
|
|
|
count: Object.keys(entity.attributes ?? {}).length,
|
|
|
|
|
content: attributesContent
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: 'relations',
|
|
|
|
|
title: 'Relations',
|
|
|
|
|
count: outgoingRelations.length + incomingRelations.length,
|
|
|
|
|
content: relationsContent
|
|
|
|
|
},
|
|
|
|
|
{ key: 'metrics', title: 'Metrics', count: metrics.length, content: metricsContent },
|
|
|
|
|
{ key: 'signals', title: 'Signals', count: signals.length, content: signalsContent },
|
|
|
|
|
{
|
|
|
|
|
key: 'executions',
|
|
|
|
|
title: 'Executions',
|
|
|
|
|
count: executions.length,
|
|
|
|
|
content: executionsContent
|
|
|
|
|
},
|
|
|
|
|
{ key: 'tasks', title: 'Tasks', count: tasks.length, content: tasksContent },
|
|
|
|
|
{ key: 'knowledge', title: 'Knowledge', count: knowledge.length, content: knowledgeContent },
|
|
|
|
|
{ key: 'events', title: 'Recent events', count: events.length, content: eventsContent },
|
|
|
|
|
{
|
|
|
|
|
key: 'agentActivity',
|
|
|
|
|
title: 'Agent activity',
|
|
|
|
|
count: agentActivity.length,
|
|
|
|
|
content: agentActivityContent
|
|
|
|
|
},
|
|
|
|
|
{ key: 'audit', title: 'Audit trail', count: auditEntries.length, content: auditContent }
|
|
|
|
|
].sort((a, b) => (b.count > 0 ? 1 : 0) - (a.count > 0 ? 1 : 0))}
|
|
|
|
|
<!-- Status: per-check verdicts. Replaces the old Monitoring section, which
|
|
|
|
|
listed configuration ("ssh-script, every 60s, enabled") and so could
|
|
|
|
|
never explain why an entity was unhealthy. -->
|
|
|
|
|
{#snippet statusContent()}
|
|
|
|
|
<div class="flex flex-col gap-1.5">
|
|
|
|
|
{#each checks.filter((c) => c.enabled) as check (check.id)}
|
|
|
|
|
<div
|
|
|
|
|
class="flex items-center justify-between gap-2 border-b pb-1.5 text-xs last:border-0 last:pb-0"
|
|
|
|
|
>
|
|
|
|
|
<div class="flex min-w-0 items-center gap-2">
|
|
|
|
|
<span
|
|
|
|
|
class="size-2 shrink-0 rounded-full {check.last_health
|
|
|
|
|
? (healthDot[check.last_health] ?? healthDot.unknown)
|
|
|
|
|
: 'bg-muted-foreground/30'}"
|
|
|
|
|
></span>
|
|
|
|
|
<span class="truncate font-mono">{checkLabel(check)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex shrink-0 items-center gap-2 text-muted-foreground">
|
|
|
|
|
<span>
|
|
|
|
|
{check.last_run_at ? relativeTime(check.last_run_at) : 'not yet run'}
|
|
|
|
|
</span>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
class="rounded border px-1.5 py-0.5 hover:bg-muted"
|
|
|
|
|
onclick={() => toggleCheck(check)}
|
|
|
|
|
title="Disable this check">disable</button
|
|
|
|
|
>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{:else}
|
|
|
|
|
<p class="text-xs text-muted-foreground">
|
|
|
|
|
No checks configured. This entity is unmonitored — its health cannot be known.
|
|
|
|
|
</p>
|
|
|
|
|
{/each}
|
|
|
|
|
{#if checks.some((c) => !c.enabled)}
|
|
|
|
|
<p class="pt-1 text-[11px] text-muted-foreground">
|
|
|
|
|
{checks.filter((c) => !c.enabled).length} disabled
|
|
|
|
|
</p>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
{/snippet}
|
|
|
|
|
|
|
|
|
|
{#each sections as section (section.key)}
|
|
|
|
|
<DetailSection title={section.title} count={section.count} defaultOpen={section.count > 0}>
|
|
|
|
|
{@render section.content()}
|
|
|
|
|
</DetailSection>
|
|
|
|
|
<!-- Impact: what the ontology exists to answer. -->
|
|
|
|
|
{#snippet impactContent()}
|
|
|
|
|
<div class="flex flex-col gap-2 text-xs">
|
|
|
|
|
{#if blast.length > 1}
|
|
|
|
|
<div class="flex flex-col gap-1">
|
|
|
|
|
{#each blast.filter((b) => b.depth > 0).slice(0, 12) as b (b.entity.id)}
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
class="flex items-center justify-between gap-2 border-b pb-1 text-left last:border-0 last:pb-0 hover:text-foreground"
|
|
|
|
|
onclick={() => onSelectEntity?.(b.entity.slug)}
|
|
|
|
|
>
|
|
|
|
|
<span class="truncate font-mono text-muted-foreground">{b.entity.slug}</span>
|
|
|
|
|
<span class="shrink-0 text-muted-foreground"
|
|
|
|
|
>{b.depth} hop{b.depth === 1 ? '' : 's'}</span
|
|
|
|
|
>
|
|
|
|
|
</button>
|
|
|
|
|
{/each}
|
|
|
|
|
</div>
|
|
|
|
|
<!-- Honest about the limitation rather than overclaiming: the query
|
|
|
|
|
walks outgoing edges only, so an entity whose importance comes
|
|
|
|
|
from things pointing AT it is under-reported. -->
|
|
|
|
|
<p class="text-[11px] text-muted-foreground">
|
|
|
|
|
Reachable by following this entity's own dependencies. Does not yet include things that
|
|
|
|
|
point at it.
|
|
|
|
|
</p>
|
|
|
|
|
{:else}
|
|
|
|
|
<p class="text-muted-foreground">Nothing downstream depends on this.</p>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
{/snippet}
|
|
|
|
|
|
|
|
|
|
<!-- Activity: one timeline instead of four separate lists telling the same
|
|
|
|
|
story (executions, signals, events, agent activity). -->
|
|
|
|
|
{#snippet activityContent()}
|
|
|
|
|
<div class="flex flex-col gap-3">
|
|
|
|
|
{#if openSignals.length > 0}
|
|
|
|
|
<div class="flex flex-col gap-1">
|
|
|
|
|
<h4 class="text-[11px] font-medium tracking-wide text-muted-foreground uppercase">
|
|
|
|
|
Open signals
|
|
|
|
|
</h4>
|
|
|
|
|
{@render signalsContent()}
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
{#if executions.length > 0}
|
|
|
|
|
<div class="flex flex-col gap-1">
|
|
|
|
|
<h4 class="text-[11px] font-medium tracking-wide text-muted-foreground uppercase">
|
|
|
|
|
Executions
|
|
|
|
|
</h4>
|
|
|
|
|
{@render executionsContent()}
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
{#if tasks.length > 0}
|
|
|
|
|
<div class="flex flex-col gap-1">
|
|
|
|
|
<h4 class="text-[11px] font-medium tracking-wide text-muted-foreground uppercase">
|
|
|
|
|
Tasks
|
|
|
|
|
</h4>
|
|
|
|
|
{@render tasksContent()}
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
{#if events.length > 0}
|
|
|
|
|
<div class="flex flex-col gap-1">
|
|
|
|
|
<h4 class="text-[11px] font-medium tracking-wide text-muted-foreground uppercase">
|
|
|
|
|
Events
|
|
|
|
|
</h4>
|
|
|
|
|
{@render eventsContent()}
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
{#if openSignals.length + executions.length + tasks.length + events.length === 0}
|
|
|
|
|
<p class="text-xs text-muted-foreground">Nothing has happened here yet.</p>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
{/snippet}
|
|
|
|
|
|
|
|
|
|
<!-- Reference: the material you consult, not the material you react to. -->
|
|
|
|
|
{#snippet referenceContent()}
|
|
|
|
|
<div class="flex flex-col gap-3">
|
|
|
|
|
<div class="flex flex-col gap-1">
|
|
|
|
|
<h4 class="text-[11px] font-medium tracking-wide text-muted-foreground uppercase">
|
|
|
|
|
Relations
|
|
|
|
|
</h4>
|
|
|
|
|
{@render relationsContent()}
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex flex-col gap-1">
|
|
|
|
|
<h4 class="text-[11px] font-medium tracking-wide text-muted-foreground uppercase">
|
|
|
|
|
Attributes
|
|
|
|
|
</h4>
|
|
|
|
|
{@render attributesContent()}
|
|
|
|
|
</div>
|
|
|
|
|
{#if knowledge.length > 0}
|
|
|
|
|
<div class="flex flex-col gap-1">
|
|
|
|
|
<h4 class="text-[11px] font-medium tracking-wide text-muted-foreground uppercase">
|
|
|
|
|
Knowledge
|
|
|
|
|
</h4>
|
|
|
|
|
{@render knowledgeContent()}
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
{#if agentActivity.length > 0 || auditEntries.length > 0}
|
|
|
|
|
<div class="flex flex-col gap-1">
|
|
|
|
|
<h4 class="text-[11px] font-medium tracking-wide text-muted-foreground uppercase">
|
|
|
|
|
Audit
|
|
|
|
|
</h4>
|
|
|
|
|
{@render auditContent()}
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
{/snippet}
|
|
|
|
|
|
|
|
|
|
<!-- Sections are composed from the entity's type rather than being a fixed
|
|
|
|
|
list of thirteen sorted by "has content". A document is not a host: it
|
|
|
|
|
has no checks, metrics or blast radius, and offering it those sections
|
|
|
|
|
was most of why this window read as noise. -->
|
|
|
|
|
{@const registry: Record<SectionKey, { title: string; count: number; content: any } | null> = {
|
|
|
|
|
content: ownContent ? { title: 'Content', count: 1, content: contentContent } : null,
|
|
|
|
|
status: {
|
|
|
|
|
title: 'Status',
|
|
|
|
|
count: checks.filter((c) => c.enabled).length,
|
|
|
|
|
content: statusContent
|
|
|
|
|
},
|
|
|
|
|
impact: {
|
|
|
|
|
title: 'Impact',
|
|
|
|
|
count: Math.max(blast.length - 1, 0),
|
|
|
|
|
content: impactContent
|
|
|
|
|
},
|
|
|
|
|
activity: {
|
|
|
|
|
title: 'Activity',
|
|
|
|
|
count: openSignals.length + executions.length + tasks.length,
|
|
|
|
|
content: activityContent
|
|
|
|
|
},
|
|
|
|
|
metrics: metrics.length
|
|
|
|
|
? { title: 'Metrics', count: metrics.length, content: metricsContent }
|
|
|
|
|
: null,
|
|
|
|
|
reference: {
|
|
|
|
|
title: 'Reference',
|
|
|
|
|
count: outgoingRelations.length + incomingRelations.length,
|
|
|
|
|
content: referenceContent
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
{#each sectionsForType(entity.type) as key (key)}
|
|
|
|
|
{@const section = registry[key]}
|
|
|
|
|
{#if section}
|
|
|
|
|
<DetailSection
|
|
|
|
|
title={section.title}
|
|
|
|
|
count={section.count}
|
|
|
|
|
defaultOpen={key === 'status' ||
|
|
|
|
|
key === 'content' ||
|
|
|
|
|
(key === 'activity' && openSignals.length > 0)}
|
|
|
|
|
>
|
|
|
|
|
{@render section.content()}
|
|
|
|
|
</DetailSection>
|
|
|
|
|
{/if}
|
|
|
|
|
{/each}
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
|