@nwire/studio 0.12.1 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -3
- package/src/App.vue +62 -56
- package/src/components/BcCard.stories.ts +47 -0
- package/src/components/BcCard.vue +152 -0
- package/src/components/DurationBar.stories.ts +55 -0
- package/src/components/DurationBar.vue +72 -0
- package/src/components/ErrorCard.stories.ts +133 -0
- package/src/components/ErrorCard.vue +153 -0
- package/src/components/GraphCanvas.stories.ts +48 -0
- package/src/components/GraphCanvas.vue +88 -0
- package/src/components/KpiTile.stories.ts +32 -0
- package/src/components/KpiTile.vue +39 -0
- package/src/components/LiveTable.stories.ts +78 -0
- package/src/components/LiveTable.vue +186 -0
- package/src/components/MetadataInspector.stories.ts +53 -0
- package/src/components/MetadataInspector.vue +105 -0
- package/src/components/NodeCard.stories.ts +44 -0
- package/src/components/NodeCard.vue +150 -0
- package/src/components/RcaPanel.stories.ts +95 -0
- package/src/components/RcaPanel.vue +223 -0
- package/src/components/ServiceNode.vue +134 -0
- package/src/components/SourceDrawer.vue +6 -4
- package/src/components/SourcePill.vue +10 -3
- package/src/components/StatusBadge.stories.ts +33 -0
- package/src/components/StatusBadge.vue +54 -0
- package/src/components/Waterfall.stories.ts +85 -0
- package/src/components/Waterfall.vue +53 -0
- package/src/components/WaterfallRow.vue +74 -0
- package/src/components/__tests__/BcCard.test.ts +53 -0
- package/src/components/__tests__/DurationBar.test.ts +31 -0
- package/src/components/__tests__/ErrorCard.test.ts +71 -0
- package/src/components/__tests__/KpiTile.test.ts +23 -0
- package/src/components/__tests__/LiveTable.test.ts +100 -0
- package/src/components/__tests__/MetadataInspector.test.ts +38 -0
- package/src/components/__tests__/NodeCard.test.ts +116 -0
- package/src/components/__tests__/RcaPanel.test.ts +81 -0
- package/src/components/__tests__/StatusBadge.test.ts +23 -0
- package/src/components/__tests__/Waterfall.test.ts +54 -0
- package/src/components/index.ts +13 -0
- package/src/composables/__tests__/composables-context.test.ts +107 -0
- package/src/composables/__tests__/useTelemetry.test.ts +104 -0
- package/src/composables/__tests__/useTelemetryRuns.test.ts +282 -0
- package/src/composables/useDiscovery.ts +73 -0
- package/src/composables/useEndpoints.ts +94 -0
- package/src/composables/useLogTail.ts +51 -0
- package/src/composables/useManifest.ts +43 -0
- package/src/composables/useProcesses.ts +114 -0
- package/src/composables/useProject.ts +34 -0
- package/src/composables/useTelemetry.ts +270 -0
- package/src/lib/__tests__/bc-graph.test.ts +218 -0
- package/src/lib/__tests__/dispatch-form.test.ts +113 -0
- package/src/lib/__tests__/error-friendly.test.ts +198 -0
- package/src/lib/__tests__/home.test.ts +231 -0
- package/src/lib/__tests__/inspect.test.ts +160 -0
- package/src/lib/__tests__/kind-colors.test.ts +59 -0
- package/src/lib/__tests__/live-table.test.ts +194 -0
- package/src/lib/__tests__/manifest-health.test.ts +120 -0
- package/src/lib/__tests__/manifest.test.ts +87 -0
- package/src/lib/__tests__/metadata.test.ts +47 -0
- package/src/lib/__tests__/node-metrics.test.ts +144 -0
- package/src/lib/__tests__/operate.test.ts +97 -0
- package/src/lib/__tests__/pipeline-flow.test.ts +79 -0
- package/src/lib/__tests__/rca.test.ts +124 -0
- package/src/lib/__tests__/telemetry.test.ts +91 -0
- package/src/lib/__tests__/topology-graph.test.ts +331 -0
- package/src/lib/__tests__/topology-view.test.ts +154 -0
- package/src/lib/__tests__/waterfall.test.ts +165 -0
- package/src/lib/bc-graph.ts +298 -0
- package/src/lib/dispatch-form.ts +160 -0
- package/src/lib/error-friendly.ts +288 -0
- package/src/lib/home.ts +191 -0
- package/src/lib/inspect.ts +226 -0
- package/src/lib/kind-colors.ts +132 -0
- package/src/lib/live-table.ts +204 -0
- package/src/lib/manifest-health.ts +71 -0
- package/src/lib/manifest.ts +139 -0
- package/src/lib/metadata.ts +52 -0
- package/src/lib/node-metrics.ts +242 -0
- package/src/lib/operate.ts +114 -0
- package/src/lib/pipeline-flow.ts +120 -0
- package/src/lib/rca.ts +193 -0
- package/src/lib/telemetry.ts +155 -0
- package/src/lib/topology-graph.ts +551 -0
- package/src/lib/topology-view.ts +185 -0
- package/src/lib/waterfall.ts +148 -0
- package/src/main.ts +63 -29
- package/src/pages/Errors.vue +272 -0
- package/src/pages/Home.stories.ts +7 -8
- package/src/pages/Home.vue +255 -540
- package/src/pages/Hooks.stories.ts +44 -0
- package/src/pages/Hooks.vue +165 -164
- package/src/pages/Inspect.vue +240 -0
- package/src/pages/Map.vue +187 -0
- package/src/pages/Operate.vue +74 -0
- package/src/pages/Plugins.stories.ts +1 -1
- package/src/pages/Plugins.vue +174 -238
- package/src/pages/Projects.vue +62 -60
- package/src/pages/Streams.vue +344 -0
- package/src/pages/Topology.vue +318 -136
- package/src/pages/Trace.vue +174 -412
- package/src/pages/__tests__/Home.test.ts +109 -54
- package/src/pages/__tests__/Hooks.test.ts +5 -5
- package/src/pages/__tests__/Inspect.test.ts +111 -0
- package/src/pages/__tests__/Plugins.test.ts +85 -35
- package/src/pages/__tests__/Trace.test.ts +117 -0
- package/src/pages/operate/CommandsPanel.vue +186 -0
- package/src/pages/{Dispatch.vue → operate/DispatchPanel.vue} +100 -203
- package/src/pages/operate/EndpointPicker.vue +56 -0
- package/src/pages/operate/RunPanel.vue +316 -0
- package/src/server/__tests__/nwire-read.test.ts +80 -0
- package/src/server/nwire-read.ts +63 -0
- package/vite.config.ts +220 -2
- package/src/lib/__tests__/normalize-cache.test.ts +0 -105
- package/src/lib/cache.ts +0 -312
- package/src/lib/normalize-cache.ts +0 -92
- package/src/pages/Actions.vue +0 -171
- package/src/pages/Apps.vue +0 -177
- package/src/pages/Commands.vue +0 -262
- package/src/pages/Events.vue +0 -210
- package/src/pages/Live.vue +0 -249
- package/src/pages/Overview.vue +0 -161
- package/src/pages/Projections.vue +0 -148
- package/src/pages/Queries.vue +0 -148
- package/src/pages/Run.vue +0 -618
- package/src/pages/Sinks.vue +0 -124
- package/src/pages/TraceNode.vue +0 -164
- package/src/pages/Workflows.vue +0 -184
- package/src/pages/__tests__/Actions.test.ts +0 -98
- package/src/pages/__tests__/Projections.test.ts +0 -90
- package/src/pages/__tests__/Queries.test.ts +0 -86
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Inspect — the unified per-kind browser. One surface over the deep manifest
|
|
4
|
+
* (`useManifest`, native — no adapter): a kind switcher (what's present +
|
|
5
|
+
* counts) → a filterable node list → the selected node's full detail
|
|
6
|
+
* (`NodeCard`: intent · schema · emits · source) plus its graph relationships.
|
|
7
|
+
*
|
|
8
|
+
* /inspect?kind=action&id=action:moderation.submit-post
|
|
9
|
+
*
|
|
10
|
+
* Replaces the per-kind pages (Actions/Events/Queries/…); those routes
|
|
11
|
+
* redirect here. Resilient: loading / error / empty on every panel.
|
|
12
|
+
*/
|
|
13
|
+
import { computed, ref, watch, onMounted } from "vue";
|
|
14
|
+
import { useRoute, useRouter } from "vue-router";
|
|
15
|
+
import { Search, RefreshCw, Send, ArrowUpRight } from "lucide-vue-next";
|
|
16
|
+
import { useManifest } from "@/composables/useManifest";
|
|
17
|
+
import {
|
|
18
|
+
inspectKinds,
|
|
19
|
+
nodesOfKind,
|
|
20
|
+
matchesNode,
|
|
21
|
+
nodeToDetail,
|
|
22
|
+
relationships,
|
|
23
|
+
kindLabel,
|
|
24
|
+
isDispatchable,
|
|
25
|
+
} from "@/lib/inspect";
|
|
26
|
+
import { kindColor } from "@/lib/kind-colors";
|
|
27
|
+
import {
|
|
28
|
+
NodeCard,
|
|
29
|
+
StatusBadge,
|
|
30
|
+
EmptyState,
|
|
31
|
+
SourceDrawer,
|
|
32
|
+
KindBadge,
|
|
33
|
+
FilterInput,
|
|
34
|
+
} from "@/components";
|
|
35
|
+
|
|
36
|
+
const route = useRoute();
|
|
37
|
+
const router = useRouter();
|
|
38
|
+
const { manifest, view, isLoading, isFetching, isError, error, refetch } = useManifest();
|
|
39
|
+
|
|
40
|
+
const kinds = computed(() => inspectKinds(view.value));
|
|
41
|
+
|
|
42
|
+
// Active kind: from the URL, else the first present kind.
|
|
43
|
+
const activeKind = ref<string>((route.query.kind as string) ?? "");
|
|
44
|
+
watch(
|
|
45
|
+
[kinds, () => route.query.kind],
|
|
46
|
+
([list, urlKind]) => {
|
|
47
|
+
const wanted = (urlKind as string) ?? activeKind.value;
|
|
48
|
+
if (wanted && list.some((k) => k.kind === wanted)) activeKind.value = wanted;
|
|
49
|
+
else if (!list.some((k) => k.kind === activeKind.value)) activeKind.value = list[0]?.kind ?? "";
|
|
50
|
+
},
|
|
51
|
+
{ immediate: true },
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const filter = ref("");
|
|
55
|
+
const nodes = computed(() => nodesOfKind(view.value, activeKind.value));
|
|
56
|
+
const filtered = computed(() => nodes.value.filter((n) => matchesNode(n, filter.value)));
|
|
57
|
+
|
|
58
|
+
const selectedId = ref<string | null>((route.query.id as string) ?? null);
|
|
59
|
+
|
|
60
|
+
// Keep a valid selection as the kind/filter changes.
|
|
61
|
+
watch([activeKind, filtered], () => {
|
|
62
|
+
if (!selectedId.value || !filtered.value.some((n) => n.id === selectedId.value)) {
|
|
63
|
+
selectedId.value = filtered.value[0]?.id ?? null;
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
onMounted(() => {
|
|
67
|
+
if (!selectedId.value) selectedId.value = filtered.value[0]?.id ?? null;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Mirror kind + selection into the URL (shareable deep-links).
|
|
71
|
+
watch([activeKind, selectedId], ([kind, id]) => {
|
|
72
|
+
void router.replace({
|
|
73
|
+
query: { ...route.query, kind: kind || undefined, id: id || undefined },
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const selectedNode = computed(() => filtered.value.find((n) => n.id === selectedId.value) ?? null);
|
|
78
|
+
const detail = computed(() => nodeToDetail(view.value, manifest.value ?? null, selectedNode.value));
|
|
79
|
+
const relations = computed(() => relationships(view.value, selectedId.value));
|
|
80
|
+
|
|
81
|
+
function pickKind(kind: string): void {
|
|
82
|
+
activeKind.value = kind;
|
|
83
|
+
filter.value = "";
|
|
84
|
+
}
|
|
85
|
+
function goRelated(id: string): void {
|
|
86
|
+
const kind = id.slice(0, id.indexOf(":"));
|
|
87
|
+
if (kinds.value.some((k) => k.kind === kind)) {
|
|
88
|
+
activeKind.value = kind;
|
|
89
|
+
selectedId.value = id;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function dispatchSelected(): void {
|
|
93
|
+
if (detail.value) void router.push({ path: "/dispatch", query: { name: detail.value.name } });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const sourcePreview = ref<{ file: string; line?: number; column?: number } | null>(null);
|
|
97
|
+
const health = computed(() => (isFetching.value ? "live" : "ok") as "live" | "ok");
|
|
98
|
+
</script>
|
|
99
|
+
|
|
100
|
+
<template>
|
|
101
|
+
<div class="h-full flex flex-col" data-testid="inspect-page">
|
|
102
|
+
<div class="border-b border-zinc-800 px-4 py-3 flex items-center gap-3">
|
|
103
|
+
<h1 class="font-semibold text-lg">Inspect</h1>
|
|
104
|
+
<StatusBadge :status="health" :label="isFetching ? 'refreshing' : 'manifest'" />
|
|
105
|
+
<button
|
|
106
|
+
class="ml-auto p-1.5 rounded hover:bg-zinc-800 text-zinc-400 hover:text-zinc-100"
|
|
107
|
+
title="Refetch manifest"
|
|
108
|
+
data-testid="inspect-refetch"
|
|
109
|
+
@click="() => refetch()"
|
|
110
|
+
>
|
|
111
|
+
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': isFetching }" />
|
|
112
|
+
</button>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
<div v-if="isError" class="p-6">
|
|
116
|
+
<EmptyState
|
|
117
|
+
title="Couldn't load the manifest"
|
|
118
|
+
:hint="error ?? 'Run `nwire cache` to build .nwire/manifest.json.'"
|
|
119
|
+
/>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<div v-else class="flex-1 flex min-h-0">
|
|
123
|
+
<!-- Kind switcher -->
|
|
124
|
+
<nav
|
|
125
|
+
class="w-44 shrink-0 border-r border-zinc-800 overflow-y-auto py-2"
|
|
126
|
+
data-testid="kind-rail"
|
|
127
|
+
>
|
|
128
|
+
<button
|
|
129
|
+
v-for="k in kinds"
|
|
130
|
+
:key="k.kind"
|
|
131
|
+
class="w-full flex items-center gap-2 px-3 py-1.5 text-sm transition-colors"
|
|
132
|
+
:class="
|
|
133
|
+
k.kind === activeKind
|
|
134
|
+
? 'bg-zinc-900 text-zinc-100'
|
|
135
|
+
: 'text-zinc-400 hover:bg-zinc-900/50 hover:text-zinc-200'
|
|
136
|
+
"
|
|
137
|
+
:data-testid="`kind-${k.kind}`"
|
|
138
|
+
@click="pickKind(k.kind)"
|
|
139
|
+
>
|
|
140
|
+
<span class="w-2 h-2 rounded-full shrink-0" :style="{ background: kindColor(k.kind) }" />
|
|
141
|
+
<span class="truncate">{{ k.label }}</span>
|
|
142
|
+
<span class="ml-auto text-[10px] text-zinc-600 tabular-nums">{{ k.count }}</span>
|
|
143
|
+
</button>
|
|
144
|
+
<div v-if="!kinds.length && !isLoading" class="px-3 py-4 text-xs text-zinc-600">
|
|
145
|
+
Nothing in the manifest yet.
|
|
146
|
+
</div>
|
|
147
|
+
</nav>
|
|
148
|
+
|
|
149
|
+
<!-- Node list -->
|
|
150
|
+
<div class="w-72 shrink-0 border-r border-zinc-800 flex flex-col min-h-0">
|
|
151
|
+
<div class="p-2 border-b border-zinc-800">
|
|
152
|
+
<FilterInput
|
|
153
|
+
v-model="filter"
|
|
154
|
+
:placeholder="`filter ${kindLabel(activeKind).toLowerCase()}…`"
|
|
155
|
+
/>
|
|
156
|
+
</div>
|
|
157
|
+
<div class="flex-1 overflow-y-auto">
|
|
158
|
+
<button
|
|
159
|
+
v-for="n in filtered"
|
|
160
|
+
:key="n.id"
|
|
161
|
+
class="w-full text-left px-3 py-2 border-b border-zinc-900 transition-colors"
|
|
162
|
+
:class="n.id === selectedId ? 'bg-zinc-900/80' : 'hover:bg-zinc-900/40'"
|
|
163
|
+
data-testid="inspect-row"
|
|
164
|
+
@click="selectedId = n.id"
|
|
165
|
+
>
|
|
166
|
+
<div class="font-mono text-[13px] text-zinc-200 truncate">{{ n.name }}</div>
|
|
167
|
+
<div v-if="n.intent?.description" class="text-[11px] text-zinc-500 truncate">
|
|
168
|
+
{{ n.intent.description }}
|
|
169
|
+
</div>
|
|
170
|
+
</button>
|
|
171
|
+
<div v-if="!filtered.length" class="px-3 py-4 text-xs text-zinc-600">
|
|
172
|
+
{{ nodes.length ? "No matches." : "None of this kind." }}
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
<!-- Detail + relationships -->
|
|
178
|
+
<div class="flex-1 min-w-0 flex flex-col min-h-0" data-testid="inspect-detail">
|
|
179
|
+
<div v-if="detail" class="flex-1 flex flex-col min-h-0">
|
|
180
|
+
<div
|
|
181
|
+
v-if="isDispatchable(detail.kind)"
|
|
182
|
+
class="px-4 py-2 border-b border-zinc-800 flex items-center gap-2"
|
|
183
|
+
>
|
|
184
|
+
<button
|
|
185
|
+
class="inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded bg-zinc-900 border border-zinc-800 text-zinc-300 hover:border-zinc-700"
|
|
186
|
+
data-testid="dispatch-link"
|
|
187
|
+
@click="dispatchSelected"
|
|
188
|
+
>
|
|
189
|
+
<Send class="w-3 h-3" /> Dispatch
|
|
190
|
+
</button>
|
|
191
|
+
</div>
|
|
192
|
+
<div class="flex-1 min-h-0 overflow-hidden flex flex-col">
|
|
193
|
+
<div class="flex-1 min-h-0">
|
|
194
|
+
<NodeCard
|
|
195
|
+
:detail="detail"
|
|
196
|
+
@open-source="sourcePreview = $event"
|
|
197
|
+
@select-event="goRelated(`event:${$event}`)"
|
|
198
|
+
/>
|
|
199
|
+
</div>
|
|
200
|
+
<!-- Relationships -->
|
|
201
|
+
<div
|
|
202
|
+
v-if="relations.length"
|
|
203
|
+
class="border-t border-zinc-800 p-4 space-y-3 max-h-64 overflow-y-auto"
|
|
204
|
+
data-testid="relationships"
|
|
205
|
+
>
|
|
206
|
+
<h3 class="text-xs uppercase tracking-wide text-zinc-500">Relationships</h3>
|
|
207
|
+
<div v-for="g in relations" :key="g.label" class="space-y-1">
|
|
208
|
+
<div class="text-[10px] uppercase tracking-wide text-zinc-600">{{ g.label }}</div>
|
|
209
|
+
<div class="flex flex-wrap gap-1.5">
|
|
210
|
+
<button
|
|
211
|
+
v-for="item in g.items"
|
|
212
|
+
:key="item.id"
|
|
213
|
+
class="inline-flex items-center gap-1 font-mono text-[11px] px-2 py-0.5 rounded border border-zinc-800 bg-zinc-900 text-zinc-300 hover:border-zinc-700"
|
|
214
|
+
:data-testid="`relation-${item.id}`"
|
|
215
|
+
@click="goRelated(item.id)"
|
|
216
|
+
>
|
|
217
|
+
<span
|
|
218
|
+
class="w-1.5 h-1.5 rounded-full"
|
|
219
|
+
:style="{ background: kindColor(item.kind) }"
|
|
220
|
+
/>
|
|
221
|
+
{{ item.name }}
|
|
222
|
+
<ArrowUpRight class="w-3 h-3 text-zinc-600" />
|
|
223
|
+
</button>
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
<EmptyState
|
|
230
|
+
v-else
|
|
231
|
+
:icon="Search"
|
|
232
|
+
title="Select something to inspect"
|
|
233
|
+
hint="Pick a kind on the left, then a node — its intent, schema, source, and wiring show here."
|
|
234
|
+
/>
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
|
|
238
|
+
<SourceDrawer :source="sourcePreview" @close="sourcePreview = null" />
|
|
239
|
+
</div>
|
|
240
|
+
</template>
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Map — the hero view. Reads the deep manifest natively (`useManifest`) and
|
|
4
|
+
* renders the system as bounded-context cards connected by inter-BC flows
|
|
5
|
+
* (`GraphCanvas`), a KPI strip across the top, and a `NodeCard` detail panel
|
|
6
|
+
* that opens when you select a primitive. Structure only — behaviour lives in
|
|
7
|
+
* Trace/Streams. Resilient: loading / error / empty states, tolerant of a
|
|
8
|
+
* flat or deep manifest (see `buildBcGraph`).
|
|
9
|
+
*/
|
|
10
|
+
import { computed, ref } from "vue";
|
|
11
|
+
import { RefreshCw, Boxes } from "lucide-vue-next";
|
|
12
|
+
import { useManifest } from "@/composables/useManifest";
|
|
13
|
+
import { useProject } from "@/composables/useProject";
|
|
14
|
+
import { useTelemetry } from "@/composables/useTelemetry";
|
|
15
|
+
import { buildBcGraph, nodeDetail, type BcRow, type BcNodeDetail } from "@/lib/bc-graph";
|
|
16
|
+
import { kindColor, KIND_LEGEND } from "@/lib/kind-colors";
|
|
17
|
+
import { rollupMetrics, fmtCount } from "@/lib/node-metrics";
|
|
18
|
+
import {
|
|
19
|
+
KpiTile,
|
|
20
|
+
StatusBadge,
|
|
21
|
+
GraphCanvas,
|
|
22
|
+
NodeCard,
|
|
23
|
+
EmptyState,
|
|
24
|
+
SourceDrawer,
|
|
25
|
+
} from "@/components";
|
|
26
|
+
|
|
27
|
+
const { activeCwd } = useProject();
|
|
28
|
+
const { manifest, view, isLoading, isFetching, isError, error, refetch } = useManifest(activeCwd);
|
|
29
|
+
|
|
30
|
+
// Live behaviour feed — overlaid as per-area metrics + flow animation.
|
|
31
|
+
const { records } = useTelemetry(activeCwd);
|
|
32
|
+
const metrics = computed(() => rollupMetrics(records.value));
|
|
33
|
+
const hasRun = computed(() => metrics.value.total > 0);
|
|
34
|
+
|
|
35
|
+
const graph = computed(() => buildBcGraph(manifest.value ?? null));
|
|
36
|
+
|
|
37
|
+
/** KPI strip — totals straight off the manifest's flat arrays. */
|
|
38
|
+
function count(key: string): number {
|
|
39
|
+
const arr = (manifest.value as unknown as Record<string, unknown>)?.[key];
|
|
40
|
+
return Array.isArray(arr) ? arr.length : 0;
|
|
41
|
+
}
|
|
42
|
+
/** Live totals across every area — the system's current pulse. */
|
|
43
|
+
const liveTotals = computed(() => {
|
|
44
|
+
let dispatches = 0;
|
|
45
|
+
let errors = 0;
|
|
46
|
+
for (const m of metrics.value.byApp.values()) {
|
|
47
|
+
dispatches += m.dispatches;
|
|
48
|
+
errors += m.errors;
|
|
49
|
+
}
|
|
50
|
+
return { dispatches, errors };
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const kpis = computed(() => [
|
|
54
|
+
{ label: "Areas", value: graph.value.bcs.length, kind: "app" },
|
|
55
|
+
{ label: "Actions", value: count("actions"), kind: "action" },
|
|
56
|
+
{ label: "Events", value: count("events"), kind: "event" },
|
|
57
|
+
{ label: "Queries", value: count("queries"), kind: "query" },
|
|
58
|
+
{ label: "Projections", value: count("projections"), kind: "projection" },
|
|
59
|
+
{ label: "Flows", value: graph.value.edges.length, kind: "listener" },
|
|
60
|
+
{
|
|
61
|
+
label: "Dispatches",
|
|
62
|
+
value: fmtCount(liveTotals.value.dispatches),
|
|
63
|
+
kind: "action",
|
|
64
|
+
live: true,
|
|
65
|
+
},
|
|
66
|
+
{ label: "Errors", value: fmtCount(liveTotals.value.errors), kind: "workflow", live: true },
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
const selectedId = ref<string | null>(null);
|
|
70
|
+
const detail = computed<BcNodeDetail | null>(() =>
|
|
71
|
+
nodeDetail(manifest.value ?? null, selectedId.value),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
function onSelect(row: BcRow): void {
|
|
75
|
+
selectedId.value = selectedId.value === row.id ? null : row.id;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const sourcePreview = ref<{ file: string; line?: number; column?: number } | null>(null);
|
|
79
|
+
|
|
80
|
+
const isEmpty = computed(() => !isLoading.value && graph.value.bcs.length === 0);
|
|
81
|
+
</script>
|
|
82
|
+
|
|
83
|
+
<template>
|
|
84
|
+
<div class="h-full flex flex-col" data-testid="map-page">
|
|
85
|
+
<!-- header + KPI strip -->
|
|
86
|
+
<div class="border-b border-zinc-800 px-6 py-3">
|
|
87
|
+
<div class="flex items-center justify-between">
|
|
88
|
+
<div>
|
|
89
|
+
<h1 class="text-lg font-semibold tracking-tight">Map</h1>
|
|
90
|
+
<p class="text-xs text-zinc-500">Bounded contexts and the flows between them</p>
|
|
91
|
+
</div>
|
|
92
|
+
<div class="flex items-center gap-3">
|
|
93
|
+
<StatusBadge
|
|
94
|
+
:status="hasRun ? 'live' : 'idle'"
|
|
95
|
+
:pulse="hasRun"
|
|
96
|
+
:label="hasRun ? 'Live traffic' : 'Idle'"
|
|
97
|
+
/>
|
|
98
|
+
<button
|
|
99
|
+
class="text-zinc-400 hover:text-zinc-100 transition-colors"
|
|
100
|
+
title="Refresh manifest"
|
|
101
|
+
data-testid="map-refresh"
|
|
102
|
+
@click="refetch()"
|
|
103
|
+
>
|
|
104
|
+
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': isFetching }" />
|
|
105
|
+
</button>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
<div v-if="view?.versionMismatch" class="mt-2 text-[11px] text-amber-300">
|
|
110
|
+
Manifest version differs from this Studio build — some fields may be missing.
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
<div class="mt-3 flex flex-wrap gap-3">
|
|
114
|
+
<KpiTile
|
|
115
|
+
v-for="k in kpis"
|
|
116
|
+
:key="k.label"
|
|
117
|
+
:label="k.label"
|
|
118
|
+
:value="k.value"
|
|
119
|
+
:accent="kindColor(k.kind)"
|
|
120
|
+
/>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<!-- canvas + detail -->
|
|
125
|
+
<div class="flex-1 flex min-h-0">
|
|
126
|
+
<div class="flex-1 relative min-w-0">
|
|
127
|
+
<div
|
|
128
|
+
v-if="isError"
|
|
129
|
+
class="absolute inset-0 flex items-center justify-center p-8 text-center"
|
|
130
|
+
data-testid="map-error"
|
|
131
|
+
>
|
|
132
|
+
<div>
|
|
133
|
+
<p class="text-sm text-rose-300">{{ error }}</p>
|
|
134
|
+
<button
|
|
135
|
+
class="mt-3 text-xs text-zinc-400 hover:text-zinc-100 underline"
|
|
136
|
+
@click="refetch()"
|
|
137
|
+
>
|
|
138
|
+
Retry
|
|
139
|
+
</button>
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
<div v-else-if="isLoading" class="p-6 text-zinc-400 text-sm">Loading manifest…</div>
|
|
143
|
+
<EmptyState
|
|
144
|
+
v-else-if="isEmpty"
|
|
145
|
+
:icon="Boxes"
|
|
146
|
+
title="No bounded contexts yet"
|
|
147
|
+
hint="Once your app registers handlers, its bounded contexts and their flows appear here."
|
|
148
|
+
/>
|
|
149
|
+
<template v-else>
|
|
150
|
+
<GraphCanvas
|
|
151
|
+
:graph="graph"
|
|
152
|
+
:selected-id="selectedId"
|
|
153
|
+
:metrics="metrics.byApp"
|
|
154
|
+
@select="onSelect"
|
|
155
|
+
/>
|
|
156
|
+
<!-- legend -->
|
|
157
|
+
<div
|
|
158
|
+
class="absolute bottom-3 left-3 flex flex-wrap gap-x-3 gap-y-1 rounded-md border border-zinc-800 bg-zinc-950/90 px-3 py-2"
|
|
159
|
+
>
|
|
160
|
+
<span
|
|
161
|
+
v-for="k in KIND_LEGEND"
|
|
162
|
+
:key="k"
|
|
163
|
+
class="inline-flex items-center gap-1 text-[10px] text-zinc-400"
|
|
164
|
+
>
|
|
165
|
+
<span class="h-1.5 w-1.5 rounded-full" :style="{ background: kindColor(k) }" />
|
|
166
|
+
{{ k }}
|
|
167
|
+
</span>
|
|
168
|
+
</div>
|
|
169
|
+
</template>
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<aside
|
|
173
|
+
v-if="selectedId"
|
|
174
|
+
class="w-96 shrink-0 border-l border-zinc-800 bg-zinc-950"
|
|
175
|
+
data-testid="map-detail"
|
|
176
|
+
>
|
|
177
|
+
<NodeCard
|
|
178
|
+
:detail="detail"
|
|
179
|
+
@open-source="sourcePreview = $event"
|
|
180
|
+
@select-event="selectedId = `event:${$event}`"
|
|
181
|
+
/>
|
|
182
|
+
</aside>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
<SourceDrawer :source="sourcePreview" @close="sourcePreview = null" />
|
|
186
|
+
</div>
|
|
187
|
+
</template>
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Operate — the one place you drive the system: dispatch a handler, run a
|
|
4
|
+
* topology/script, fire a CLI command. Three modes behind `?mode=`; each is a
|
|
5
|
+
* focused panel. Replaces the former Dispatch / Run / Commands pages.
|
|
6
|
+
*/
|
|
7
|
+
import { computed } from "vue";
|
|
8
|
+
import { useRoute, useRouter } from "vue-router";
|
|
9
|
+
import { Send, Play, Terminal, Wrench } from "lucide-vue-next";
|
|
10
|
+
import DispatchPanel from "./operate/DispatchPanel.vue";
|
|
11
|
+
import RunPanel from "./operate/RunPanel.vue";
|
|
12
|
+
import CommandsPanel from "./operate/CommandsPanel.vue";
|
|
13
|
+
import EndpointPicker from "./operate/EndpointPicker.vue";
|
|
14
|
+
|
|
15
|
+
type Mode = "dispatch" | "run" | "commands";
|
|
16
|
+
|
|
17
|
+
const route = useRoute();
|
|
18
|
+
const router = useRouter();
|
|
19
|
+
|
|
20
|
+
const tabs: { mode: Mode; label: string; icon: typeof Send }[] = [
|
|
21
|
+
{ mode: "dispatch", label: "Dispatch", icon: Send },
|
|
22
|
+
{ mode: "run", label: "Run", icon: Play },
|
|
23
|
+
{ mode: "commands", label: "Commands", icon: Terminal },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const mode = computed<Mode>(() => {
|
|
27
|
+
const m = route.query.mode;
|
|
28
|
+
return m === "run" || m === "commands" ? m : "dispatch";
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function select(m: Mode): void {
|
|
32
|
+
if (m === mode.value) return;
|
|
33
|
+
// Drop mode-specific query (e.g. a command `?name=`) when switching tabs.
|
|
34
|
+
void router.replace({ query: { mode: m } });
|
|
35
|
+
}
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
<template>
|
|
39
|
+
<div class="h-full flex flex-col" data-testid="operate-page">
|
|
40
|
+
<div class="border-b border-zinc-800 px-4 py-3 flex items-center gap-3">
|
|
41
|
+
<Wrench class="w-5 h-5 text-emerald-400 shrink-0" />
|
|
42
|
+
<h1 class="font-semibold text-lg">Operate</h1>
|
|
43
|
+
<div class="ml-2 flex items-center gap-1" role="tablist">
|
|
44
|
+
<button
|
|
45
|
+
v-for="t in tabs"
|
|
46
|
+
:key="t.mode"
|
|
47
|
+
class="flex items-center gap-1.5 px-3 py-1.5 rounded text-sm transition-colors"
|
|
48
|
+
:class="
|
|
49
|
+
mode === t.mode
|
|
50
|
+
? 'bg-zinc-800 text-zinc-100'
|
|
51
|
+
: 'text-zinc-400 hover:text-zinc-100 hover:bg-zinc-900'
|
|
52
|
+
"
|
|
53
|
+
role="tab"
|
|
54
|
+
:aria-selected="mode === t.mode"
|
|
55
|
+
:data-testid="`operate-tab-${t.mode}`"
|
|
56
|
+
@click="select(t.mode)"
|
|
57
|
+
>
|
|
58
|
+
<component :is="t.icon" class="w-3.5 h-3.5" />
|
|
59
|
+
{{ t.label }}
|
|
60
|
+
</button>
|
|
61
|
+
</div>
|
|
62
|
+
<!-- Endpoint picker — appears only when the dev host exposes multiple endpoints. -->
|
|
63
|
+
<div class="ml-auto">
|
|
64
|
+
<EndpointPicker />
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<div class="flex-1 min-h-0">
|
|
69
|
+
<DispatchPanel v-if="mode === 'dispatch'" />
|
|
70
|
+
<RunPanel v-else-if="mode === 'run'" />
|
|
71
|
+
<CommandsPanel v-else />
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
</template>
|
|
@@ -3,7 +3,7 @@ import { createRouter, createMemoryHistory } from "vue-router";
|
|
|
3
3
|
import Plugins from "./Plugins.vue";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* Storybook for the Plugins page. The manifest fetch +
|
|
6
|
+
* Storybook for the Plugins page. The manifest fetch + telemetry stream fail
|
|
7
7
|
* inside Storybook, so the page degrades to its empty / no-live state —
|
|
8
8
|
* which is exactly the surface we want to document.
|
|
9
9
|
*/
|