@nwire/studio 0.12.0 → 0.13.0
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,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inspect — pure selectors that turn the deep manifest into the unified
|
|
3
|
+
* per-kind browser: the kind switcher (what's present + counts), a node's full
|
|
4
|
+
* detail (reusing `BcNodeDetail` so `NodeCard` renders it), and a node's
|
|
5
|
+
* relationships (its graph edges, grouped + humanized). Native: reads
|
|
6
|
+
* `ManifestView` / `GraphNode` / `GraphEdge` from `@nwire/scan` — no adapter.
|
|
7
|
+
*/
|
|
8
|
+
import type { Manifest, ManifestView, GraphNode, GraphEdge, EdgeType } from "./manifest";
|
|
9
|
+
import {
|
|
10
|
+
nodeDetail,
|
|
11
|
+
toInvariants,
|
|
12
|
+
toConfigModules,
|
|
13
|
+
toEnvVars,
|
|
14
|
+
type BcNodeDetail,
|
|
15
|
+
} from "./bc-graph";
|
|
16
|
+
|
|
17
|
+
/** Friendly labels per graph node kind. */
|
|
18
|
+
const KIND_LABELS: Record<string, string> = {
|
|
19
|
+
action: "Actions",
|
|
20
|
+
query: "Queries",
|
|
21
|
+
event: "Events",
|
|
22
|
+
actor: "Actors",
|
|
23
|
+
workflow: "Workflows",
|
|
24
|
+
projection: "Projections",
|
|
25
|
+
command: "Commands",
|
|
26
|
+
route: "Routes",
|
|
27
|
+
cron: "Crons",
|
|
28
|
+
externalCall: "External calls",
|
|
29
|
+
inboundWebhook: "Webhooks",
|
|
30
|
+
outbox: "Outboxes",
|
|
31
|
+
inbox: "Inboxes",
|
|
32
|
+
sinkStage: "Sinks",
|
|
33
|
+
sourceStage: "Sources",
|
|
34
|
+
plugin: "Plugins",
|
|
35
|
+
capability: "Capabilities",
|
|
36
|
+
kind: "Ctx kinds",
|
|
37
|
+
hook: "Hooks",
|
|
38
|
+
app: "Apps",
|
|
39
|
+
schema: "Schemas",
|
|
40
|
+
resource: "Resources",
|
|
41
|
+
error: "Errors",
|
|
42
|
+
binding: "Bindings",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** Curated display order; kinds not listed fall to the end, alphabetical. */
|
|
46
|
+
const KIND_ORDER: readonly string[] = [
|
|
47
|
+
"action",
|
|
48
|
+
"query",
|
|
49
|
+
"event",
|
|
50
|
+
"actor",
|
|
51
|
+
"workflow",
|
|
52
|
+
"projection",
|
|
53
|
+
"command",
|
|
54
|
+
"route",
|
|
55
|
+
"cron",
|
|
56
|
+
"externalCall",
|
|
57
|
+
"inboundWebhook",
|
|
58
|
+
"sinkStage",
|
|
59
|
+
"sourceStage",
|
|
60
|
+
"app",
|
|
61
|
+
"plugin",
|
|
62
|
+
"capability",
|
|
63
|
+
"hook",
|
|
64
|
+
"schema",
|
|
65
|
+
"resource",
|
|
66
|
+
"error",
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
/** Human label for a kind (falls back to the raw kind). */
|
|
70
|
+
export function kindLabel(kind: string): string {
|
|
71
|
+
return KIND_LABELS[kind] ?? kind;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface InspectKindEntry {
|
|
75
|
+
readonly kind: string;
|
|
76
|
+
readonly label: string;
|
|
77
|
+
readonly count: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Which kinds are present in the manifest + their counts, in curated order
|
|
82
|
+
* (then alphabetical for anything uncurated). Only kinds with ≥1 node appear.
|
|
83
|
+
*/
|
|
84
|
+
export function inspectKinds(view: ManifestView | null): InspectKindEntry[] {
|
|
85
|
+
if (!view) return [];
|
|
86
|
+
const counts = new Map<string, number>();
|
|
87
|
+
for (const n of view.model.nodes) counts.set(n.kind, (counts.get(n.kind) ?? 0) + 1);
|
|
88
|
+
|
|
89
|
+
const rank = (k: string): number => {
|
|
90
|
+
const i = KIND_ORDER.indexOf(k);
|
|
91
|
+
return i === -1 ? KIND_ORDER.length : i;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
return [...counts.entries()]
|
|
95
|
+
.map(([kind, count]) => ({ kind, label: kindLabel(kind), count }))
|
|
96
|
+
.sort((a, b) => rank(a.kind) - rank(b.kind) || a.label.localeCompare(b.label));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Nodes of one kind, name-sorted — the master list for the active tab. */
|
|
100
|
+
export function nodesOfKind(view: ManifestView | null, kind: string): GraphNode[] {
|
|
101
|
+
if (!view) return [];
|
|
102
|
+
return view
|
|
103
|
+
.byKind(kind)
|
|
104
|
+
.slice()
|
|
105
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Free-text match over a node's name + intent description. */
|
|
109
|
+
export function matchesNode(node: GraphNode, query: string): boolean {
|
|
110
|
+
const q = query.trim().toLowerCase();
|
|
111
|
+
if (!q) return true;
|
|
112
|
+
return (
|
|
113
|
+
node.name.toLowerCase().includes(q) ||
|
|
114
|
+
(node.intent?.description?.toLowerCase().includes(q) ?? false)
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Resolve a graph node to a full `BcNodeDetail` (so `NodeCard` renders it).
|
|
120
|
+
* Prefers the flat-array entry (carries the input schema) and falls back to the
|
|
121
|
+
* graph node's own intent + `data` for kinds without a flat array.
|
|
122
|
+
*/
|
|
123
|
+
export function nodeToDetail(
|
|
124
|
+
view: ManifestView | null,
|
|
125
|
+
manifest: Manifest | null | undefined,
|
|
126
|
+
node: GraphNode | null | undefined,
|
|
127
|
+
): BcNodeDetail | null {
|
|
128
|
+
if (!node) return null;
|
|
129
|
+
const flat = nodeDetail(manifest, node.id);
|
|
130
|
+
if (flat) return flat;
|
|
131
|
+
|
|
132
|
+
// Structural kinds (plugin, hook, route, capability, …) have no flat entry —
|
|
133
|
+
// build the detail from the graph node itself.
|
|
134
|
+
const data = node.data ?? {};
|
|
135
|
+
// env/config are surfaced as their own sections, never in the generic Details.
|
|
136
|
+
const env = toEnvVars(data.env);
|
|
137
|
+
const config = toConfigModules(data.config);
|
|
138
|
+
const extra: Record<string, unknown> = {};
|
|
139
|
+
for (const [k, v] of Object.entries(data)) {
|
|
140
|
+
if (k !== "emits" && k !== "env" && k !== "config" && v !== undefined) extra[k] = v;
|
|
141
|
+
}
|
|
142
|
+
const intent = node.intent ?? {};
|
|
143
|
+
if (intent.persona) extra.persona = intent.persona;
|
|
144
|
+
if (intent.journeyStep) extra.journeyStep = intent.journeyStep;
|
|
145
|
+
if (intent.capability) extra.capability = intent.capability;
|
|
146
|
+
if (intent.tags?.length) extra.tags = intent.tags;
|
|
147
|
+
// Invariants are surfaced as their own section, never in the generic Details.
|
|
148
|
+
delete extra.invariants;
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
id: node.id,
|
|
152
|
+
kind: node.kind,
|
|
153
|
+
name: node.name,
|
|
154
|
+
description: intent.description,
|
|
155
|
+
public: intent.public === true,
|
|
156
|
+
source: node.source as BcNodeDetail["source"],
|
|
157
|
+
emits: Array.isArray(data.emits) ? (data.emits as string[]) : undefined,
|
|
158
|
+
invariants: toInvariants(intent.invariants),
|
|
159
|
+
env,
|
|
160
|
+
config,
|
|
161
|
+
extra,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export interface RelatedNode {
|
|
166
|
+
readonly id: string;
|
|
167
|
+
readonly kind: string;
|
|
168
|
+
readonly name: string;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface RelationGroup {
|
|
172
|
+
readonly label: string;
|
|
173
|
+
readonly items: readonly RelatedNode[];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Direction-aware human labels per edge type. */
|
|
177
|
+
const RELATION_LABELS: Record<EdgeType, { out: string; in: string }> = {
|
|
178
|
+
triggers: { out: "Triggers", in: "Triggered by" },
|
|
179
|
+
emits: { out: "Emits", in: "Emitted by" },
|
|
180
|
+
delivers: { out: "Delivers to", in: "Receives from" },
|
|
181
|
+
transitions: { out: "Transitions to", in: "Transitioned from" },
|
|
182
|
+
dispatches: { out: "Dispatches", in: "Dispatched by" },
|
|
183
|
+
reads: { out: "Reads", in: "Read by" },
|
|
184
|
+
calls: { out: "Calls", in: "Called by" },
|
|
185
|
+
provides: { out: "Provides", in: "Provided by" },
|
|
186
|
+
contributes: { out: "Contributes", in: "Contributed by" },
|
|
187
|
+
mounts: { out: "Mounts", in: "Mounted by" },
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
function nameOfId(id: string): { kind: string; name: string } {
|
|
191
|
+
const i = id.indexOf(":");
|
|
192
|
+
return i === -1 ? { kind: "", name: id } : { kind: id.slice(0, i), name: id.slice(i + 1) };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* A node's edges, grouped by humanized relation (direction-aware) — the
|
|
197
|
+
* "Relationships" panel. Empty when the node has no edges.
|
|
198
|
+
*/
|
|
199
|
+
export function relationships(view: ManifestView | null, id: string | null): RelationGroup[] {
|
|
200
|
+
if (!view || !id) return [];
|
|
201
|
+
const groups = new Map<string, RelatedNode[]>();
|
|
202
|
+
const push = (label: string, otherId: string): void => {
|
|
203
|
+
const { kind, name } = nameOfId(otherId);
|
|
204
|
+
const item = { id: otherId, kind, name };
|
|
205
|
+
const list = groups.get(label);
|
|
206
|
+
if (list) {
|
|
207
|
+
if (!list.some((x) => x.id === otherId)) list.push(item);
|
|
208
|
+
} else {
|
|
209
|
+
groups.set(label, [item]);
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
for (const e of view.edgesOf(id, "both") as GraphEdge[]) {
|
|
214
|
+
const labels = RELATION_LABELS[e.type];
|
|
215
|
+
if (!labels) continue;
|
|
216
|
+
if (e.from === id) push(labels.out, e.to);
|
|
217
|
+
if (e.to === id) push(labels.in, e.from);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return [...groups.entries()].map(([label, items]) => ({ label, items }));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Kinds that can be invoked from the Operate/Dispatch surface. */
|
|
224
|
+
export function isDispatchable(kind: string): boolean {
|
|
225
|
+
return kind === "action" || kind === "command" || kind === "query";
|
|
226
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one kind→colour map — the Studio visual language for handler kinds and
|
|
3
|
+
* graph node kinds. Pure data + helpers so components and the graph canvas
|
|
4
|
+
* agree on colour and read the same in Storybook and tests.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Canonical hex per kind (matches `studio-redesign.html`). */
|
|
8
|
+
export const KIND_COLOR: Record<string, string> = {
|
|
9
|
+
action: "#7c9cff",
|
|
10
|
+
query: "#36c6a8",
|
|
11
|
+
event: "#c08bff",
|
|
12
|
+
listener: "#9aa7ff",
|
|
13
|
+
actor: "#ffb454",
|
|
14
|
+
workflow: "#ff7eb6",
|
|
15
|
+
projection: "#5ad1ff",
|
|
16
|
+
command: "#7c9cff",
|
|
17
|
+
// structural / topology kinds
|
|
18
|
+
app: "#39d98a",
|
|
19
|
+
plugin: "#8a97ad",
|
|
20
|
+
capability: "#8a97ad",
|
|
21
|
+
route: "#5ad1ff",
|
|
22
|
+
sinkStage: "#5c6a82",
|
|
23
|
+
sourceStage: "#5c6a82",
|
|
24
|
+
hook: "#8a97ad",
|
|
25
|
+
externalCall: "#ffca45",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const FALLBACK = "#8a97ad";
|
|
29
|
+
|
|
30
|
+
/** Hex colour for a kind (falls back to a neutral dim). */
|
|
31
|
+
export function kindColor(kind: string | undefined): string {
|
|
32
|
+
return (kind && KIND_COLOR[kind]) || FALLBACK;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Map a graph node kind to a KindBadge variant (semantic palette). */
|
|
36
|
+
export function kindVariant(
|
|
37
|
+
kind: string | undefined,
|
|
38
|
+
): "info" | "success" | "warning" | "danger" | "neutral" {
|
|
39
|
+
switch (kind) {
|
|
40
|
+
case "action":
|
|
41
|
+
case "command":
|
|
42
|
+
case "query":
|
|
43
|
+
case "route":
|
|
44
|
+
return "info";
|
|
45
|
+
case "app":
|
|
46
|
+
return "success";
|
|
47
|
+
case "actor":
|
|
48
|
+
case "externalCall":
|
|
49
|
+
return "warning";
|
|
50
|
+
default:
|
|
51
|
+
return "neutral";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Map a dotted telemetry record kind (e.g. `action.completed`, `event.published`,
|
|
57
|
+
* `external.call.failed`) to a colour key in `KIND_COLOR`, so a live record reads
|
|
58
|
+
* with the same colour as its handler kind on the Map.
|
|
59
|
+
*/
|
|
60
|
+
export function recordColorKey(recordKind: string): string {
|
|
61
|
+
const base = recordKind.split(".")[0] ?? recordKind;
|
|
62
|
+
switch (base) {
|
|
63
|
+
case "action":
|
|
64
|
+
case "event":
|
|
65
|
+
case "query":
|
|
66
|
+
case "actor":
|
|
67
|
+
case "projection":
|
|
68
|
+
case "listener":
|
|
69
|
+
case "workflow":
|
|
70
|
+
return base;
|
|
71
|
+
case "reaction":
|
|
72
|
+
return "workflow"; // reactions are workflow-family sagas
|
|
73
|
+
case "external":
|
|
74
|
+
return "externalCall";
|
|
75
|
+
case "source":
|
|
76
|
+
return "sourceStage";
|
|
77
|
+
case "sink":
|
|
78
|
+
return "sinkStage";
|
|
79
|
+
case "hook":
|
|
80
|
+
return "hook";
|
|
81
|
+
default:
|
|
82
|
+
return base;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The legend order shown under the canvas. */
|
|
87
|
+
export const KIND_LEGEND: readonly string[] = [
|
|
88
|
+
"action",
|
|
89
|
+
"query",
|
|
90
|
+
"event",
|
|
91
|
+
"listener",
|
|
92
|
+
"actor",
|
|
93
|
+
"workflow",
|
|
94
|
+
"projection",
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Lucide icon name per kind — the one glyph that reads a node's role at a
|
|
99
|
+
* glance on the service map. Names match `lucide-vue-next` exports; the canvas
|
|
100
|
+
* resolves them dynamically so components and the legend agree.
|
|
101
|
+
*/
|
|
102
|
+
export const KIND_ICON: Record<string, string> = {
|
|
103
|
+
// sources — what enters the app
|
|
104
|
+
route: "Globe",
|
|
105
|
+
inboundWebhook: "Webhook",
|
|
106
|
+
cron: "Clock",
|
|
107
|
+
command: "Terminal",
|
|
108
|
+
// handlers — what runs
|
|
109
|
+
action: "Zap",
|
|
110
|
+
query: "Search",
|
|
111
|
+
// effects — what running produces
|
|
112
|
+
event: "Radio",
|
|
113
|
+
externalCall: "ExternalLink",
|
|
114
|
+
workflow: "GitBranch",
|
|
115
|
+
actor: "Box",
|
|
116
|
+
projection: "LayoutGrid",
|
|
117
|
+
listener: "Ear",
|
|
118
|
+
// structural
|
|
119
|
+
app: "Boxes",
|
|
120
|
+
plugin: "Plug",
|
|
121
|
+
capability: "Plug",
|
|
122
|
+
sinkStage: "ArrowDownToLine",
|
|
123
|
+
sourceStage: "ArrowUpFromLine",
|
|
124
|
+
hook: "Anchor",
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const ICON_FALLBACK = "Circle";
|
|
128
|
+
|
|
129
|
+
/** Lucide icon name for a kind (falls back to a neutral circle). */
|
|
130
|
+
export function kindIcon(kind: string | undefined): string {
|
|
131
|
+
return (kind && KIND_ICON[kind]) || ICON_FALLBACK;
|
|
132
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure logic behind the live telemetry table — subject extraction, time
|
|
3
|
+
* parsing, text filtering, sorting, and KPI roll-ups. Kept DOM-free so the
|
|
4
|
+
* firehose's behavior is unit-tested without mounting a component; `LiveTable`
|
|
5
|
+
* and the Streams page render what these return.
|
|
6
|
+
*/
|
|
7
|
+
import type { TelemetryRecord } from "./telemetry";
|
|
8
|
+
import { isFailure } from "./telemetry";
|
|
9
|
+
|
|
10
|
+
/** Sortable columns. `time` is the default (newest first). */
|
|
11
|
+
export type SortKey = "time" | "kind" | "subject" | "duration";
|
|
12
|
+
export type SortDir = "asc" | "desc";
|
|
13
|
+
|
|
14
|
+
/** Epoch millis for a record's `ts` (0 when absent/unparseable — sorts last desc). */
|
|
15
|
+
export function recordTime(r: TelemetryRecord): number {
|
|
16
|
+
if (!r.ts) return 0;
|
|
17
|
+
const t = Date.parse(r.ts);
|
|
18
|
+
return Number.isNaN(t) ? 0 : t;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The primary noun a record is about — the handler/event/actor it concerns.
|
|
23
|
+
* Reads the per-kind field the runtime + forge stamp (`action`, `event`,
|
|
24
|
+
* `actor`, …); `event` may be a string or an `EventMessage`-shaped object.
|
|
25
|
+
*/
|
|
26
|
+
export function recordSubject(r: TelemetryRecord): string {
|
|
27
|
+
const ev = r.event;
|
|
28
|
+
const eventName =
|
|
29
|
+
typeof ev === "string"
|
|
30
|
+
? ev
|
|
31
|
+
: ev && typeof ev === "object"
|
|
32
|
+
? ((ev as { name?: string; type?: string }).name ??
|
|
33
|
+
(ev as { name?: string; type?: string }).type)
|
|
34
|
+
: undefined;
|
|
35
|
+
const pick =
|
|
36
|
+
(r.action as string | undefined) ??
|
|
37
|
+
(r.query as string | undefined) ??
|
|
38
|
+
(r.projection as string | undefined) ??
|
|
39
|
+
(r.actor as string | undefined) ??
|
|
40
|
+
(r.workflow as string | undefined) ??
|
|
41
|
+
(r.call as string | undefined) ??
|
|
42
|
+
(r.timer as string | undefined) ??
|
|
43
|
+
(r.sourceEvent as string | undefined) ??
|
|
44
|
+
eventName ??
|
|
45
|
+
(r.listener as string | undefined);
|
|
46
|
+
return pick ?? "—";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Duration in ms when the record carries one, else `undefined`. */
|
|
50
|
+
export function recordDuration(r: TelemetryRecord): number | undefined {
|
|
51
|
+
return typeof r.durationMs === "number" ? r.durationMs : undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Lower-cased haystack of everything a text filter should match against. */
|
|
55
|
+
function haystack(r: TelemetryRecord): string {
|
|
56
|
+
const env = r.envelope;
|
|
57
|
+
return [
|
|
58
|
+
r.kind,
|
|
59
|
+
recordSubject(r),
|
|
60
|
+
r.appName,
|
|
61
|
+
env?.correlationId,
|
|
62
|
+
env?.causationId,
|
|
63
|
+
env?.messageId,
|
|
64
|
+
env?.tenant,
|
|
65
|
+
env?.userId,
|
|
66
|
+
r.error?.message,
|
|
67
|
+
]
|
|
68
|
+
.filter(Boolean)
|
|
69
|
+
.join(" ")
|
|
70
|
+
.toLowerCase();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Free-text match across kind, subject, ids, tenant, user, app, and error. */
|
|
74
|
+
export function matchesFilter(r: TelemetryRecord, query: string): boolean {
|
|
75
|
+
const q = query.trim().toLowerCase();
|
|
76
|
+
if (!q) return true;
|
|
77
|
+
return haystack(r).includes(q);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface FilterSpec {
|
|
81
|
+
/** Free text. */
|
|
82
|
+
readonly text?: string;
|
|
83
|
+
/** Restrict to a single record kind (exact). */
|
|
84
|
+
readonly kind?: string | null;
|
|
85
|
+
/** Restrict to one correlation chain. */
|
|
86
|
+
readonly correlationId?: string | null;
|
|
87
|
+
/** Only failures (the Errors-family kinds, or a stamped `error`). */
|
|
88
|
+
readonly failuresOnly?: boolean;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Apply every active filter clause. Order-independent; all must pass. */
|
|
92
|
+
export function filterRecords(
|
|
93
|
+
records: readonly TelemetryRecord[],
|
|
94
|
+
spec: FilterSpec,
|
|
95
|
+
): TelemetryRecord[] {
|
|
96
|
+
return records.filter((r) => {
|
|
97
|
+
if (spec.kind && r.kind !== spec.kind) return false;
|
|
98
|
+
if (spec.correlationId && r.envelope?.correlationId !== spec.correlationId) return false;
|
|
99
|
+
if (spec.failuresOnly && !isFailure(r)) return false;
|
|
100
|
+
if (spec.text && !matchesFilter(r, spec.text)) return false;
|
|
101
|
+
return true;
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Stable sort by the chosen column + direction (input array is not mutated). */
|
|
106
|
+
export function sortRecords(
|
|
107
|
+
records: readonly TelemetryRecord[],
|
|
108
|
+
key: SortKey,
|
|
109
|
+
dir: SortDir,
|
|
110
|
+
): TelemetryRecord[] {
|
|
111
|
+
const sign = dir === "asc" ? 1 : -1;
|
|
112
|
+
return records
|
|
113
|
+
.map((r, i) => [r, i] as const)
|
|
114
|
+
.sort(([a, ai], [b, bi]) => {
|
|
115
|
+
const d = compare(a, b, key);
|
|
116
|
+
return d !== 0 ? d * sign : ai - bi; // stable on ties
|
|
117
|
+
})
|
|
118
|
+
.map(([r]) => r);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function compare(a: TelemetryRecord, b: TelemetryRecord, key: SortKey): number {
|
|
122
|
+
switch (key) {
|
|
123
|
+
case "time":
|
|
124
|
+
return recordTime(a) - recordTime(b);
|
|
125
|
+
case "duration":
|
|
126
|
+
return (recordDuration(a) ?? -1) - (recordDuration(b) ?? -1);
|
|
127
|
+
case "kind":
|
|
128
|
+
return a.kind.localeCompare(b.kind);
|
|
129
|
+
case "subject":
|
|
130
|
+
return recordSubject(a).localeCompare(recordSubject(b));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Clamp-and-wrap the selection index when the user presses ↑/↓. */
|
|
135
|
+
export function nextIndex(current: number, length: number, delta: number): number {
|
|
136
|
+
if (length === 0) return -1;
|
|
137
|
+
if (current < 0) return delta > 0 ? 0 : length - 1;
|
|
138
|
+
const next = current + delta;
|
|
139
|
+
if (next < 0) return 0;
|
|
140
|
+
if (next >= length) return length - 1;
|
|
141
|
+
return next;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface StreamKpis {
|
|
145
|
+
/** Total records in the buffer. */
|
|
146
|
+
readonly total: number;
|
|
147
|
+
/** How many are failures. */
|
|
148
|
+
readonly failures: number;
|
|
149
|
+
/** Distinct record kinds seen. */
|
|
150
|
+
readonly kinds: number;
|
|
151
|
+
/** Distinct correlation chains seen. */
|
|
152
|
+
readonly chains: number;
|
|
153
|
+
/** Records per second over the buffer's time span (0 when < 2 records). */
|
|
154
|
+
readonly perSecond: number;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Roll the buffer up into the header strip's numbers. */
|
|
158
|
+
export function computeKpis(records: readonly TelemetryRecord[]): StreamKpis {
|
|
159
|
+
const kinds = new Set<string>();
|
|
160
|
+
const chains = new Set<string>();
|
|
161
|
+
let failures = 0;
|
|
162
|
+
let min = Infinity;
|
|
163
|
+
let max = -Infinity;
|
|
164
|
+
for (const r of records) {
|
|
165
|
+
kinds.add(r.kind);
|
|
166
|
+
const cid = r.envelope?.correlationId;
|
|
167
|
+
if (cid) chains.add(cid);
|
|
168
|
+
if (isFailure(r)) failures++;
|
|
169
|
+
const t = recordTime(r);
|
|
170
|
+
if (t > 0) {
|
|
171
|
+
if (t < min) min = t;
|
|
172
|
+
if (t > max) max = t;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const spanSec = max > min ? (max - min) / 1000 : 0;
|
|
176
|
+
const perSecond = spanSec > 0 ? Math.round((records.length / spanSec) * 10) / 10 : 0;
|
|
177
|
+
return {
|
|
178
|
+
total: records.length,
|
|
179
|
+
failures,
|
|
180
|
+
kinds: kinds.size,
|
|
181
|
+
chains: chains.size,
|
|
182
|
+
perSecond,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The distinct kinds present, in first-seen order — drives the kind filter. */
|
|
187
|
+
export function distinctKinds(records: readonly TelemetryRecord[]): string[] {
|
|
188
|
+
const seen = new Set<string>();
|
|
189
|
+
const out: string[] = [];
|
|
190
|
+
for (const r of records) {
|
|
191
|
+
if (!seen.has(r.kind)) {
|
|
192
|
+
seen.add(r.kind);
|
|
193
|
+
out.push(r.kind);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Short id for display (first dash-segment, else first 8 chars). */
|
|
200
|
+
export function shortId(id: string | undefined): string {
|
|
201
|
+
if (!id) return "—";
|
|
202
|
+
if (id.includes("-")) return id.split("-")[0] || id.slice(0, 8);
|
|
203
|
+
return id.slice(0, 8);
|
|
204
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manifest health — pure checks over a raw `.nwire/manifest.json` payload.
|
|
3
|
+
*
|
|
4
|
+
* The manifest is generated by `@nwire/scan` and its schema evolves. A Studio
|
|
5
|
+
* served against a manifest built by an older scanner must not crash; pages
|
|
6
|
+
* that depend on a not-yet-present field render an empty state, and the operator
|
|
7
|
+
* gets a "rebuild" hint naming exactly what's missing.
|
|
8
|
+
*
|
|
9
|
+
* Reports against the native manifest directly so App.vue can drive the
|
|
10
|
+
* stale-manifest banner from the deep manifest, no flat reshape in between.
|
|
11
|
+
*/
|
|
12
|
+
import type { Manifest } from "./manifest";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Top-level array fields the *current* `@nwire/scan` emits on every manifest
|
|
16
|
+
* (always present, empty when a kind is unused — verified against real
|
|
17
|
+
* `buildManifest` output). A field that's absent (or not an array) means the
|
|
18
|
+
* manifest was built by an older scanner; we name it in the rebuild hint.
|
|
19
|
+
*
|
|
20
|
+
* Deliberately NOT the old flat-cache set: the deep manifest keeps apps,
|
|
21
|
+
* resolvers, routes, hooks, plugins, sinks, bindings under `topology`/`model`,
|
|
22
|
+
* not as top-level arrays — checking for them here false-flagged every real
|
|
23
|
+
* manifest. `generatedAt` isn't emitted at top level either, so it's dropped.
|
|
24
|
+
*/
|
|
25
|
+
const ARRAY_FIELDS = [
|
|
26
|
+
"events",
|
|
27
|
+
"actions",
|
|
28
|
+
"actors",
|
|
29
|
+
"projections",
|
|
30
|
+
"queries",
|
|
31
|
+
"workflows",
|
|
32
|
+
"commands",
|
|
33
|
+
"crons",
|
|
34
|
+
"externalCalls",
|
|
35
|
+
"inboundWebhooks",
|
|
36
|
+
"outboxes",
|
|
37
|
+
"inboxes",
|
|
38
|
+
"resources",
|
|
39
|
+
"errors",
|
|
40
|
+
"schemas",
|
|
41
|
+
] as const;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The field paths a manifest is missing relative to the current schema. Empty
|
|
45
|
+
* when the manifest is current-shape. Pure — no DOM, unit-testable.
|
|
46
|
+
*
|
|
47
|
+
* Order matches the schema declaration so the banner reads predictably
|
|
48
|
+
* (`resolvers`, `workflows`, …).
|
|
49
|
+
*/
|
|
50
|
+
export function missingFields(manifest: Manifest | null | undefined): string[] {
|
|
51
|
+
if (manifest === null || typeof manifest !== "object" || Array.isArray(manifest)) {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const input = manifest as unknown as Record<string, unknown>;
|
|
56
|
+
const missing: string[] = [];
|
|
57
|
+
|
|
58
|
+
for (const field of ARRAY_FIELDS) {
|
|
59
|
+
if (!Array.isArray(input[field])) missing.push(field);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// graph.events — nested arrays.
|
|
63
|
+
const rawGraph = input.graph as { events?: unknown } | undefined;
|
|
64
|
+
if (!rawGraph || typeof rawGraph !== "object") {
|
|
65
|
+
missing.push("graph");
|
|
66
|
+
} else if (!Array.isArray(rawGraph.events)) {
|
|
67
|
+
missing.push("graph.events");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return missing;
|
|
71
|
+
}
|