@nwire/studio 0.10.0 → 0.11.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/README.md +27 -16
- package/package.json +2 -2
- package/src/App.vue +142 -27
- package/src/components/SourceDrawer.vue +46 -9
- package/src/components/SourcePill.vue +18 -53
- package/src/lib/__tests__/normalize-cache.test.ts +6 -5
- package/src/lib/cache.ts +60 -82
- package/src/lib/normalize-cache.ts +1 -1
- package/src/lib/project-catalog.ts +39 -1
- package/src/main.ts +52 -16
- package/src/pages/Actions.vue +5 -14
- package/src/pages/Apps.vue +177 -0
- package/src/pages/Dispatch.vue +4 -4
- package/src/pages/Events.vue +84 -40
- package/src/pages/Home.vue +133 -19
- package/src/pages/Hooks.vue +3 -8
- package/src/pages/Overview.vue +6 -4
- package/src/pages/Plugins.vue +3 -8
- package/src/pages/Projections.vue +148 -0
- package/src/pages/Projects.vue +2 -2
- package/src/pages/Queries.vue +148 -0
- package/src/pages/Run.vue +144 -5
- package/src/pages/Sinks.vue +124 -0
- package/src/pages/Topology.vue +91 -91
- package/src/pages/Trace.vue +2 -21
- package/src/pages/TraceNode.vue +2 -4
- package/src/pages/Workflows.vue +19 -26
- package/src/pages/__tests__/Projections.test.ts +90 -0
- package/src/pages/__tests__/Queries.test.ts +86 -0
- package/vite.config.ts +275 -34
- package/src/pages/Modules.vue +0 -174
package/src/pages/Topology.vue
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Topology — apps + plugins + event flow.
|
|
4
|
+
*
|
|
5
|
+
* Each app is a node. Its installed plugins are listed inside the node;
|
|
6
|
+
* its outbound sinks are listed below. Edges are event-flow edges from
|
|
7
|
+
* the `graph.events` cache: producer-app → consumer-app per event,
|
|
8
|
+
* coloured for cross-app vs in-app.
|
|
9
|
+
*/
|
|
2
10
|
import { computed, ref } from "vue";
|
|
3
11
|
import { VueFlow, type Node, type Edge, MarkerType } from "@vue-flow/core";
|
|
4
12
|
import { Background } from "@vue-flow/background";
|
|
@@ -10,100 +18,78 @@ import "@vue-flow/controls/dist/style.css";
|
|
|
10
18
|
|
|
11
19
|
const { cache } = useCache();
|
|
12
20
|
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
const COL_GAP = 60;
|
|
18
|
-
const ROW_GAP = 140;
|
|
21
|
+
const NODE_W = 260;
|
|
22
|
+
const NODE_GAP_X = 80;
|
|
23
|
+
const NODE_GAP_Y = 80;
|
|
24
|
+
const COLS = 3;
|
|
19
25
|
|
|
20
26
|
const elements = computed<{ nodes: Node[]; edges: Edge[] }>(() => {
|
|
21
27
|
if (!cache.value) return { nodes: [], edges: [] };
|
|
22
|
-
|
|
23
28
|
const nodes: Node[] = [];
|
|
24
|
-
let y = 40;
|
|
25
29
|
|
|
26
|
-
for (
|
|
27
|
-
|
|
30
|
+
for (let i = 0; i < cache.value.apps.length; i++) {
|
|
31
|
+
const app = cache.value.apps[i]!;
|
|
32
|
+
const pluginsForApp = cache.value.plugins.filter((p) => p.app === app.name);
|
|
33
|
+
const sinksForApp = cache.value.sinks?.filter((s) => s.app === app.name) ?? [];
|
|
34
|
+
const actionsCount = cache.value.actions.filter((a) => a.app === app.name).length;
|
|
35
|
+
const eventsCount = cache.value.events.filter((e) => e.app === app.name).length;
|
|
36
|
+
const projectionsCount = cache.value.projections.filter((p) => p.app === app.name).length;
|
|
37
|
+
|
|
38
|
+
const col = i % COLS;
|
|
39
|
+
const row = Math.floor(i / COLS);
|
|
40
|
+
const height = 110 + pluginsForApp.length * 18 + sinksForApp.length * 18;
|
|
41
|
+
|
|
28
42
|
nodes.push({
|
|
29
43
|
id: `app:${app.name}`,
|
|
30
|
-
position: { x:
|
|
44
|
+
position: { x: 40 + col * (NODE_W + NODE_GAP_X), y: 40 + row * (height + NODE_GAP_Y) },
|
|
31
45
|
type: "default",
|
|
32
|
-
data: {
|
|
46
|
+
data: {
|
|
47
|
+
label: app.name,
|
|
48
|
+
plugins: pluginsForApp.map((p) => p.name),
|
|
49
|
+
sinks: sinksForApp.map((s) => `${s.position} · ${s.kind ?? s.name}`),
|
|
50
|
+
actionsCount,
|
|
51
|
+
eventsCount,
|
|
52
|
+
projectionsCount,
|
|
53
|
+
},
|
|
33
54
|
style: {
|
|
34
|
-
width: `${
|
|
35
|
-
height:
|
|
36
|
-
background: "
|
|
55
|
+
width: `${NODE_W}px`,
|
|
56
|
+
height: `${height}px`,
|
|
57
|
+
background: "#0a0a0a",
|
|
37
58
|
border: "1px solid rgb(34, 197, 94)",
|
|
38
|
-
color: "
|
|
39
|
-
fontWeight: "600",
|
|
40
|
-
fontSize: "13px",
|
|
41
|
-
textAlign: "left",
|
|
42
|
-
padding: "10px 16px",
|
|
59
|
+
color: "#e4e4e7",
|
|
43
60
|
borderRadius: "8px",
|
|
61
|
+
padding: "0",
|
|
44
62
|
},
|
|
45
|
-
selectable: false,
|
|
46
|
-
draggable: false,
|
|
47
63
|
});
|
|
48
|
-
|
|
49
|
-
let col = 0;
|
|
50
|
-
let row = 0;
|
|
51
|
-
const moduleY = y + 60;
|
|
52
|
-
for (const moduleName of app.modules) {
|
|
53
|
-
const mod = cache.value.modules.find((m) => m.name === moduleName && m.app === app.name);
|
|
54
|
-
const x = 40 + col * (NODE_W + COL_GAP);
|
|
55
|
-
const ny = moduleY + row * (NODE_H + ROW_GAP * 0.5);
|
|
56
|
-
nodes.push({
|
|
57
|
-
id: `${app.name}:${moduleName}`,
|
|
58
|
-
position: { x, y: ny },
|
|
59
|
-
data: {
|
|
60
|
-
label: moduleName,
|
|
61
|
-
subtitle: mod
|
|
62
|
-
? `${mod.counts.actions}A · ${mod.counts.events}E · ${mod.counts.actors}@`
|
|
63
|
-
: "",
|
|
64
|
-
},
|
|
65
|
-
style: {
|
|
66
|
-
width: `${NODE_W}px`,
|
|
67
|
-
height: `${NODE_H}px`,
|
|
68
|
-
background: "#18181b",
|
|
69
|
-
border: "1px solid #3f3f46",
|
|
70
|
-
color: "#fafafa",
|
|
71
|
-
padding: "10px",
|
|
72
|
-
borderRadius: "6px",
|
|
73
|
-
},
|
|
74
|
-
type: "default",
|
|
75
|
-
});
|
|
76
|
-
col++;
|
|
77
|
-
if (col >= COLS_PER_ROW) {
|
|
78
|
-
col = 0;
|
|
79
|
-
row++;
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
const totalRows = Math.max(1, Math.ceil(app.modules.length / COLS_PER_ROW));
|
|
83
|
-
y = moduleY + totalRows * (NODE_H + ROW_GAP * 0.5) + 30;
|
|
84
64
|
}
|
|
85
65
|
|
|
86
|
-
// Edges
|
|
66
|
+
// Edges from the event graph: producer-app → consumer-app.
|
|
87
67
|
const edges: Edge[] = [];
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
68
|
+
const eventLog = cache.value.graph?.events ?? [];
|
|
69
|
+
for (const edge of eventLog) {
|
|
70
|
+
const producer = (edge as { producer?: { app?: string } }).producer;
|
|
71
|
+
const consumers = (edge as { consumers?: { app?: string; via?: string }[] }).consumers ?? [];
|
|
72
|
+
const sourceAppName = producer?.app;
|
|
73
|
+
if (!sourceAppName) continue;
|
|
74
|
+
const sourceId = `app:${sourceAppName}`;
|
|
75
|
+
for (const cons of consumers) {
|
|
76
|
+
if (!cons.app) continue;
|
|
77
|
+
const targetId = `app:${cons.app}`;
|
|
78
|
+
if (sourceId === targetId) continue;
|
|
79
|
+
const eventName = (edge as { event?: string }).event ?? "";
|
|
94
80
|
edges.push({
|
|
95
|
-
id: `${sourceId}->${targetId}::${
|
|
81
|
+
id: `${sourceId}->${targetId}::${eventName}::${cons.via ?? "via"}`,
|
|
96
82
|
source: sourceId,
|
|
97
83
|
target: targetId,
|
|
98
|
-
label:
|
|
84
|
+
label: eventName,
|
|
99
85
|
type: "smoothstep",
|
|
100
|
-
animated:
|
|
101
|
-
style: { stroke:
|
|
86
|
+
animated: true,
|
|
87
|
+
style: { stroke: "#a78bfa", strokeWidth: 2 },
|
|
102
88
|
labelStyle: { fontSize: "10px", fill: "#a1a1aa" },
|
|
103
89
|
labelBgStyle: { fill: "#18181b" },
|
|
104
90
|
labelBgPadding: [4, 2] as [number, number],
|
|
105
91
|
labelBgBorderRadius: 4,
|
|
106
|
-
markerEnd: { type: MarkerType.ArrowClosed, color:
|
|
92
|
+
markerEnd: { type: MarkerType.ArrowClosed, color: "#a78bfa" },
|
|
107
93
|
});
|
|
108
94
|
}
|
|
109
95
|
}
|
|
@@ -114,49 +100,63 @@ const elements = computed<{ nodes: Node[]; edges: Edge[] }>(() => {
|
|
|
114
100
|
const showCrossOnly = ref(false);
|
|
115
101
|
const filtered = computed(() => {
|
|
116
102
|
if (!showCrossOnly.value) return elements.value;
|
|
117
|
-
const edges = elements.value.edges.filter((e) =>
|
|
118
|
-
const src = (e.source as string).split(":")[0];
|
|
119
|
-
const tgt = (e.target as string).split(":")[0];
|
|
120
|
-
return src !== tgt;
|
|
121
|
-
});
|
|
103
|
+
const edges = elements.value.edges.filter((e) => e.source !== e.target);
|
|
122
104
|
return { nodes: elements.value.nodes, edges };
|
|
123
105
|
});
|
|
124
106
|
</script>
|
|
125
107
|
|
|
126
108
|
<template>
|
|
127
|
-
<div v-if="cache" class="h-full flex flex-col">
|
|
109
|
+
<div v-if="cache" class="h-full flex flex-col" data-testid="topology-page">
|
|
128
110
|
<div class="border-b border-zinc-800 px-6 py-3 flex items-center justify-between">
|
|
129
111
|
<div>
|
|
130
112
|
<h1 class="text-lg font-semibold tracking-tight">Topology</h1>
|
|
131
113
|
<p class="text-xs text-zinc-500">
|
|
132
|
-
Apps ·
|
|
114
|
+
Apps · plugins · sinks · cross-app event flows
|
|
133
115
|
<span class="ml-2">
|
|
134
116
|
<span class="inline-block w-3 h-0.5 bg-purple-400 align-middle mr-1"></span>
|
|
135
|
-
cross-
|
|
136
|
-
</span>
|
|
137
|
-
<span class="ml-3">
|
|
138
|
-
<span class="inline-block w-3 h-0.5 bg-zinc-600 align-middle mr-1"></span>
|
|
139
|
-
in-process
|
|
117
|
+
cross-app
|
|
140
118
|
</span>
|
|
141
119
|
</p>
|
|
142
120
|
</div>
|
|
143
121
|
<label class="flex items-center gap-2 text-xs text-zinc-400">
|
|
144
|
-
<input
|
|
145
|
-
Cross-
|
|
122
|
+
<input v-model="showCrossOnly" type="checkbox" class="accent-purple-400" />
|
|
123
|
+
Cross-app only
|
|
146
124
|
</label>
|
|
147
125
|
</div>
|
|
148
|
-
|
|
126
|
+
|
|
127
|
+
<div class="flex-1 relative">
|
|
149
128
|
<VueFlow
|
|
150
129
|
:nodes="filtered.nodes"
|
|
151
130
|
:edges="filtered.edges"
|
|
152
|
-
:default-viewport="{ x: 0, y: 0, zoom: 0.85 }"
|
|
153
|
-
:min-zoom="0.3"
|
|
154
|
-
:max-zoom="2"
|
|
155
131
|
:fit-view-on-init="true"
|
|
156
|
-
:
|
|
157
|
-
:nodes-connectable="false"
|
|
132
|
+
:min-zoom="0.2"
|
|
158
133
|
>
|
|
159
|
-
<
|
|
134
|
+
<template #node-default="props">
|
|
135
|
+
<div class="px-3 py-2 text-left h-full flex flex-col gap-1">
|
|
136
|
+
<div class="font-mono text-sm text-emerald-300">{{ props.data.label }}</div>
|
|
137
|
+
<div class="text-[10px] text-zinc-500">
|
|
138
|
+
{{ props.data.actionsCount }}A · {{ props.data.eventsCount }}E ·
|
|
139
|
+
{{ props.data.projectionsCount }}P
|
|
140
|
+
</div>
|
|
141
|
+
<div v-if="props.data.plugins.length > 0" class="text-[10px] text-zinc-400 mt-1">
|
|
142
|
+
<div class="uppercase tracking-wider text-zinc-600">Plugins</div>
|
|
143
|
+
<div
|
|
144
|
+
v-for="p in props.data.plugins"
|
|
145
|
+
:key="p"
|
|
146
|
+
class="font-mono truncate text-zinc-300"
|
|
147
|
+
>
|
|
148
|
+
{{ p }}
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
<div v-if="props.data.sinks.length > 0" class="text-[10px] mt-1">
|
|
152
|
+
<div class="uppercase tracking-wider text-zinc-600">Sinks</div>
|
|
153
|
+
<div v-for="s in props.data.sinks" :key="s" class="font-mono truncate text-amber-200">
|
|
154
|
+
{{ s }}
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
</template>
|
|
159
|
+
<Background pattern-color="#27272a" />
|
|
160
160
|
<Controls />
|
|
161
161
|
</VueFlow>
|
|
162
162
|
</div>
|
package/src/pages/Trace.vue
CHANGED
|
@@ -214,11 +214,11 @@ const causalForest = computed<TraceNode[]>(() => {
|
|
|
214
214
|
const eventIndex = computed(() => {
|
|
215
215
|
const m = new Map<
|
|
216
216
|
string,
|
|
217
|
-
{
|
|
217
|
+
{ app: string; source?: { file: string; line: number; column?: number } }
|
|
218
218
|
>();
|
|
219
219
|
if (cache.value) {
|
|
220
220
|
for (const e of cache.value.events) {
|
|
221
|
-
m.set(e.name, {
|
|
221
|
+
m.set(e.name, { app: e.app, source: e.source });
|
|
222
222
|
}
|
|
223
223
|
}
|
|
224
224
|
return m;
|
|
@@ -227,7 +227,6 @@ const eventIndex = computed(() => {
|
|
|
227
227
|
// ── Context explorer ─────────────────────────────────────────────────
|
|
228
228
|
interface ContextDigest {
|
|
229
229
|
apps: Set<string>;
|
|
230
|
-
modules: Set<string>;
|
|
231
230
|
uniqueEvents: Set<string>;
|
|
232
231
|
startedAt: string;
|
|
233
232
|
endedAt: string;
|
|
@@ -243,7 +242,6 @@ const context = computed<ContextDigest | undefined>(() => {
|
|
|
243
242
|
const evts = selectedTraceEvents.value;
|
|
244
243
|
if (evts.length === 0) return undefined;
|
|
245
244
|
const apps = new Set<string>();
|
|
246
|
-
const modules = new Set<string>();
|
|
247
245
|
const unique = new Set<string>();
|
|
248
246
|
const tenants = new Set<string>();
|
|
249
247
|
const users = new Set<string>();
|
|
@@ -254,8 +252,6 @@ const context = computed<ContextDigest | undefined>(() => {
|
|
|
254
252
|
apps.add(e.appName);
|
|
255
253
|
if (e.eventName) {
|
|
256
254
|
unique.add(e.eventName);
|
|
257
|
-
const meta = eventIndex.value.get(e.eventName);
|
|
258
|
-
if (meta) modules.add(`${meta.app}/${meta.module}`);
|
|
259
255
|
}
|
|
260
256
|
if (e.envelope.tenant) tenants.add(e.envelope.tenant);
|
|
261
257
|
if (e.envelope.userId) users.add(e.envelope.userId);
|
|
@@ -269,7 +265,6 @@ const context = computed<ContextDigest | undefined>(() => {
|
|
|
269
265
|
|
|
270
266
|
return {
|
|
271
267
|
apps,
|
|
272
|
-
modules,
|
|
273
268
|
uniqueEvents: unique,
|
|
274
269
|
startedAt,
|
|
275
270
|
endedAt,
|
|
@@ -444,20 +439,6 @@ watch(
|
|
|
444
439
|
</div>
|
|
445
440
|
</section>
|
|
446
441
|
|
|
447
|
-
<section v-if="context.modules.size" class="px-4 py-3">
|
|
448
|
-
<h3 class="text-[10px] uppercase tracking-wide text-zinc-500 mb-2">Modules touched</h3>
|
|
449
|
-
<ul class="space-y-1">
|
|
450
|
-
<li
|
|
451
|
-
v-for="m in [...context.modules]"
|
|
452
|
-
:key="m"
|
|
453
|
-
class="flex items-center gap-2 text-xs font-mono"
|
|
454
|
-
>
|
|
455
|
-
<Boxes class="w-3 h-3 text-violet-400" />
|
|
456
|
-
{{ m }}
|
|
457
|
-
</li>
|
|
458
|
-
</ul>
|
|
459
|
-
</section>
|
|
460
|
-
|
|
461
442
|
<section v-if="context.uniqueEvents.size" class="px-4 py-3">
|
|
462
443
|
<h3 class="text-[10px] uppercase tracking-wide text-zinc-500 mb-2">Distinct events</h3>
|
|
463
444
|
<ul class="space-y-1">
|
package/src/pages/TraceNode.vue
CHANGED
|
@@ -45,7 +45,7 @@ interface SourceLoc {
|
|
|
45
45
|
const props = defineProps<{
|
|
46
46
|
node: TraceNode;
|
|
47
47
|
depth: number;
|
|
48
|
-
eventIndex: Map<string, {
|
|
48
|
+
eventIndex: Map<string, { app: string; source?: SourceLoc }>;
|
|
49
49
|
isExpanded: (id: string) => boolean;
|
|
50
50
|
toggle: (id: string) => void;
|
|
51
51
|
formatTime: (iso: string) => string;
|
|
@@ -115,9 +115,7 @@ function open(s: SourceLoc | undefined) {
|
|
|
115
115
|
<span class="text-[10px] text-zinc-500 tabular-nums">
|
|
116
116
|
{{ formatTime(node.evt.capturedAt) }}
|
|
117
117
|
</span>
|
|
118
|
-
<span v-if="meta" class="text-[10px] text-zinc-500 font-mono">
|
|
119
|
-
· {{ meta.app }}/{{ meta.module }}
|
|
120
|
-
</span>
|
|
118
|
+
<span v-if="meta" class="text-[10px] text-zinc-500 font-mono"> · {{ meta.app }} </span>
|
|
121
119
|
<button v-if="source" type="button" class="ml-auto" @click="open(source)">
|
|
122
120
|
<SourcePill :source="source" compact />
|
|
123
121
|
</button>
|
package/src/pages/Workflows.vue
CHANGED
|
@@ -29,9 +29,8 @@ const sourcePreview = ref<{ file: string; line: number; column?: number } | null
|
|
|
29
29
|
function applyQueryPreselect(): void {
|
|
30
30
|
const name = route.query.name;
|
|
31
31
|
if (typeof name !== "string" || name.length === 0) return;
|
|
32
|
-
// Match by workflow name across (app, module). First match wins.
|
|
33
32
|
const found = cache.value?.workflows.find((w) => w.name === name);
|
|
34
|
-
if (found) selected.value = `${found.app}::${found.
|
|
33
|
+
if (found) selected.value = `${found.app}::${found.name}`;
|
|
35
34
|
}
|
|
36
35
|
|
|
37
36
|
onMounted(applyQueryPreselect);
|
|
@@ -45,16 +44,14 @@ const filtered = computed(() => {
|
|
|
45
44
|
(w) =>
|
|
46
45
|
!q ||
|
|
47
46
|
w.name.toLowerCase().includes(q) ||
|
|
48
|
-
w.module.toLowerCase().includes(q) ||
|
|
49
47
|
w.app.toLowerCase().includes(q) ||
|
|
50
|
-
w.subscribesTo.some((e) => e.toLowerCase().includes(q)) ||
|
|
51
|
-
w.dispatches.some((a) => a.toLowerCase().includes(q)) ||
|
|
48
|
+
(w.subscribesTo ?? []).some((e) => e.toLowerCase().includes(q)) ||
|
|
49
|
+
(w.dispatches ?? []).some((a) => a.toLowerCase().includes(q)) ||
|
|
52
50
|
(w.description ?? "").toLowerCase().includes(q),
|
|
53
51
|
);
|
|
54
52
|
});
|
|
55
53
|
|
|
56
|
-
const key = (w: { app: string;
|
|
57
|
-
`${w.app}::${w.module}::${w.name}`;
|
|
54
|
+
const key = (w: { app: string; name: string }) => `${w.app}::${w.name}`;
|
|
58
55
|
const detail = computed(() => filtered.value.find((w) => key(w) === selected.value) ?? null);
|
|
59
56
|
</script>
|
|
60
57
|
|
|
@@ -80,7 +77,7 @@ const detail = computed(() => filtered.value.find((w) => key(w) === selected.val
|
|
|
80
77
|
|
|
81
78
|
<MasterDetail v-else class="flex-1">
|
|
82
79
|
<template #listHeader>
|
|
83
|
-
<FilterInput v-model="filter" placeholder="filter by name,
|
|
80
|
+
<FilterInput v-model="filter" placeholder="filter by name, app, event, action…" />
|
|
84
81
|
</template>
|
|
85
82
|
|
|
86
83
|
<template #list>
|
|
@@ -99,16 +96,18 @@ const detail = computed(() => filtered.value.find((w) => key(w) === selected.val
|
|
|
99
96
|
:is="w.public ? Globe : Lock"
|
|
100
97
|
class="w-3 h-3"
|
|
101
98
|
:class="w.public ? 'text-emerald-400' : 'text-zinc-500'"
|
|
102
|
-
:title="w.public ? 'public — exposed across
|
|
99
|
+
:title="w.public ? 'public — exposed across apps' : 'private — app-internal'"
|
|
103
100
|
/>
|
|
104
|
-
<span class="text-[10px] text-zinc-500">{{ w.app }}
|
|
101
|
+
<span class="text-[10px] text-zinc-500">{{ w.app }}</span>
|
|
105
102
|
</template>
|
|
106
|
-
<template v-if="w.description || w.subscribesTo.length > 0" #description>
|
|
103
|
+
<template v-if="w.description || (w.subscribesTo ?? []).length > 0" #description>
|
|
107
104
|
<div v-if="w.description">{{ w.description }}</div>
|
|
108
|
-
<div v-if="w.subscribesTo.length > 0" class="text-zinc-600 mt-0.5">
|
|
105
|
+
<div v-if="(w.subscribesTo ?? []).length > 0" class="text-zinc-600 mt-0.5">
|
|
109
106
|
<span class="text-zinc-500">on</span>
|
|
110
|
-
{{ w.subscribesTo.slice(0, 2).join(", ") }}
|
|
111
|
-
<span v-if="w.subscribesTo.length > 2"
|
|
107
|
+
{{ (w.subscribesTo ?? []).slice(0, 2).join(", ") }}
|
|
108
|
+
<span v-if="(w.subscribesTo ?? []).length > 2">
|
|
109
|
+
+{{ (w.subscribesTo ?? []).length - 2 }}
|
|
110
|
+
</span>
|
|
112
111
|
</div>
|
|
113
112
|
</template>
|
|
114
113
|
</ListRow>
|
|
@@ -120,7 +119,7 @@ const detail = computed(() => filtered.value.find((w) => key(w) === selected.val
|
|
|
120
119
|
<div class="p-6 space-y-5" data-testid="workflow-detail">
|
|
121
120
|
<div>
|
|
122
121
|
<div class="text-[10px] uppercase tracking-wide text-zinc-500">
|
|
123
|
-
{{ detail.app }}
|
|
122
|
+
{{ detail.app }}
|
|
124
123
|
</div>
|
|
125
124
|
<h2 class="font-mono text-xl mt-1">{{ detail.name }}</h2>
|
|
126
125
|
<p v-if="detail.description" class="text-sm text-zinc-400 mt-2 max-w-2xl">
|
|
@@ -136,12 +135,12 @@ const detail = computed(() => filtered.value.find((w) => key(w) === selected.val
|
|
|
136
135
|
|
|
137
136
|
<div class="space-y-3">
|
|
138
137
|
<h3 class="text-xs uppercase tracking-wide text-zinc-500">Listens to</h3>
|
|
139
|
-
<div v-if="detail.subscribesTo.length === 0" class="text-xs text-zinc-600">
|
|
138
|
+
<div v-if="(detail.subscribesTo ?? []).length === 0" class="text-xs text-zinc-600">
|
|
140
139
|
No event subscriptions declared.
|
|
141
140
|
</div>
|
|
142
141
|
<div v-else class="space-y-1">
|
|
143
142
|
<button
|
|
144
|
-
v-for="ev in detail.subscribesTo"
|
|
143
|
+
v-for="ev in detail.subscribesTo ?? []"
|
|
145
144
|
:key="ev"
|
|
146
145
|
type="button"
|
|
147
146
|
class="flex items-center gap-2 font-mono text-sm text-left hover:text-cyan-300"
|
|
@@ -156,12 +155,12 @@ const detail = computed(() => filtered.value.find((w) => key(w) === selected.val
|
|
|
156
155
|
|
|
157
156
|
<div class="space-y-3">
|
|
158
157
|
<h3 class="text-xs uppercase tracking-wide text-zinc-500">Dispatches</h3>
|
|
159
|
-
<div v-if="detail.dispatches.length === 0" class="text-xs text-zinc-600">
|
|
158
|
+
<div v-if="(detail.dispatches ?? []).length === 0" class="text-xs text-zinc-600">
|
|
160
159
|
Pure observer — no action dispatches.
|
|
161
160
|
</div>
|
|
162
161
|
<div v-else class="space-y-1">
|
|
163
162
|
<button
|
|
164
|
-
v-for="action in detail.dispatches"
|
|
163
|
+
v-for="action in detail.dispatches ?? []"
|
|
165
164
|
:key="action"
|
|
166
165
|
type="button"
|
|
167
166
|
class="flex items-center gap-2 font-mono text-sm text-left hover:text-amber-300"
|
|
@@ -175,13 +174,7 @@ const detail = computed(() => filtered.value.find((w) => key(w) === selected.val
|
|
|
175
174
|
</div>
|
|
176
175
|
|
|
177
176
|
<div v-if="detail.source" class="pt-2">
|
|
178
|
-
<
|
|
179
|
-
type="button"
|
|
180
|
-
class="inline-flex items-center"
|
|
181
|
-
@click="sourcePreview = detail.source!"
|
|
182
|
-
>
|
|
183
|
-
<SourcePill :source="detail.source" />
|
|
184
|
-
</button>
|
|
177
|
+
<SourcePill :source="detail.source" @click="sourcePreview = detail.source!" />
|
|
185
178
|
</div>
|
|
186
179
|
</div>
|
|
187
180
|
</template>
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Projections page — renders the list from cache and cross-links to
|
|
3
|
+
* each query that reads the projection.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
6
|
+
import { mount, flushPromises } from "@vue/test-utils";
|
|
7
|
+
import { createRouter, createMemoryHistory } from "vue-router";
|
|
8
|
+
import Projections from "../Projections.vue";
|
|
9
|
+
|
|
10
|
+
const counterProjection = {
|
|
11
|
+
name: "counter-total",
|
|
12
|
+
app: "shop",
|
|
13
|
+
};
|
|
14
|
+
const counterQuery = {
|
|
15
|
+
name: "counter.get-count",
|
|
16
|
+
app: "shop",
|
|
17
|
+
public: false,
|
|
18
|
+
projection: "counter-total",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
globalThis.fetch = vi.fn((url: string | URL) => {
|
|
23
|
+
const u = String(url);
|
|
24
|
+
if (u.includes("/__nwire/manifest.json")) {
|
|
25
|
+
return Promise.resolve(
|
|
26
|
+
new Response(
|
|
27
|
+
JSON.stringify({
|
|
28
|
+
generatedAt: new Date().toISOString(),
|
|
29
|
+
apps: [],
|
|
30
|
+
modules: [],
|
|
31
|
+
actions: [],
|
|
32
|
+
events: [],
|
|
33
|
+
actors: [],
|
|
34
|
+
projections: [counterProjection],
|
|
35
|
+
queries: [counterQuery],
|
|
36
|
+
resolvers: [],
|
|
37
|
+
routes: [],
|
|
38
|
+
workflows: [],
|
|
39
|
+
externalCalls: [],
|
|
40
|
+
inboundWebhooks: [],
|
|
41
|
+
outboxes: [],
|
|
42
|
+
inboxes: [],
|
|
43
|
+
crons: [],
|
|
44
|
+
hooks: [],
|
|
45
|
+
plugins: [],
|
|
46
|
+
bindings: [],
|
|
47
|
+
graph: { events: [] },
|
|
48
|
+
}),
|
|
49
|
+
{ status: 200 },
|
|
50
|
+
),
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
return Promise.resolve(new Response("", { status: 404 }));
|
|
54
|
+
}) as typeof fetch;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
function makeRouter() {
|
|
58
|
+
return createRouter({
|
|
59
|
+
history: createMemoryHistory(),
|
|
60
|
+
routes: [
|
|
61
|
+
{ path: "/projections", name: "projections", component: Projections },
|
|
62
|
+
{ path: "/queries", name: "queries", component: { template: "<div/>" } },
|
|
63
|
+
],
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe("Projections", () => {
|
|
68
|
+
it("renders projections from the cache", async () => {
|
|
69
|
+
const router = makeRouter();
|
|
70
|
+
await router.push("/projections");
|
|
71
|
+
const wrapper = mount(Projections, { global: { plugins: [router] } });
|
|
72
|
+
await flushPromises();
|
|
73
|
+
await flushPromises();
|
|
74
|
+
|
|
75
|
+
expect(wrapper.text()).toContain("counter-total");
|
|
76
|
+
expect(wrapper.find("[data-testid=projections-page]").exists()).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("deep-links via ?name=… and shows linked queries", async () => {
|
|
80
|
+
const router = makeRouter();
|
|
81
|
+
await router.push("/projections?name=counter-total");
|
|
82
|
+
const wrapper = mount(Projections, { global: { plugins: [router] } });
|
|
83
|
+
await flushPromises();
|
|
84
|
+
await flushPromises();
|
|
85
|
+
|
|
86
|
+
const detail = wrapper.find("[data-testid=projection-detail]");
|
|
87
|
+
expect(detail.exists()).toBe(true);
|
|
88
|
+
expect(detail.text()).toContain("counter.get-count");
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Queries page — renders the list from cache and cross-links to the
|
|
3
|
+
* backing projection when the query is projection-form.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
6
|
+
import { mount, flushPromises } from "@vue/test-utils";
|
|
7
|
+
import { createRouter, createMemoryHistory } from "vue-router";
|
|
8
|
+
import Queries from "../Queries.vue";
|
|
9
|
+
|
|
10
|
+
const counterQuery = {
|
|
11
|
+
name: "counter.get-count",
|
|
12
|
+
app: "shop",
|
|
13
|
+
public: true,
|
|
14
|
+
projection: "counter-total",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
globalThis.fetch = vi.fn((url: string | URL) => {
|
|
19
|
+
const u = String(url);
|
|
20
|
+
if (u.includes("/__nwire/manifest.json")) {
|
|
21
|
+
return Promise.resolve(
|
|
22
|
+
new Response(
|
|
23
|
+
JSON.stringify({
|
|
24
|
+
generatedAt: new Date().toISOString(),
|
|
25
|
+
apps: [],
|
|
26
|
+
modules: [],
|
|
27
|
+
actions: [],
|
|
28
|
+
events: [],
|
|
29
|
+
actors: [],
|
|
30
|
+
projections: [],
|
|
31
|
+
queries: [counterQuery],
|
|
32
|
+
resolvers: [],
|
|
33
|
+
routes: [],
|
|
34
|
+
workflows: [],
|
|
35
|
+
externalCalls: [],
|
|
36
|
+
inboundWebhooks: [],
|
|
37
|
+
outboxes: [],
|
|
38
|
+
inboxes: [],
|
|
39
|
+
crons: [],
|
|
40
|
+
hooks: [],
|
|
41
|
+
plugins: [],
|
|
42
|
+
bindings: [],
|
|
43
|
+
graph: { events: [] },
|
|
44
|
+
}),
|
|
45
|
+
{ status: 200 },
|
|
46
|
+
),
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
return Promise.resolve(new Response("", { status: 404 }));
|
|
50
|
+
}) as typeof fetch;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
function makeRouter() {
|
|
54
|
+
return createRouter({
|
|
55
|
+
history: createMemoryHistory(),
|
|
56
|
+
routes: [
|
|
57
|
+
{ path: "/queries", name: "queries", component: Queries },
|
|
58
|
+
{ path: "/projections", name: "projections", component: { template: "<div/>" } },
|
|
59
|
+
],
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe("Queries", () => {
|
|
64
|
+
it("renders queries from the cache", async () => {
|
|
65
|
+
const router = makeRouter();
|
|
66
|
+
await router.push("/queries");
|
|
67
|
+
const wrapper = mount(Queries, { global: { plugins: [router] } });
|
|
68
|
+
await flushPromises();
|
|
69
|
+
await flushPromises();
|
|
70
|
+
|
|
71
|
+
expect(wrapper.text()).toContain("counter.get-count");
|
|
72
|
+
expect(wrapper.find("[data-testid=queries-page]").exists()).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("deep-links via ?name=… and shows the backing projection", async () => {
|
|
76
|
+
const router = makeRouter();
|
|
77
|
+
await router.push("/queries?name=counter.get-count");
|
|
78
|
+
const wrapper = mount(Queries, { global: { plugins: [router] } });
|
|
79
|
+
await flushPromises();
|
|
80
|
+
await flushPromises();
|
|
81
|
+
|
|
82
|
+
const detail = wrapper.find("[data-testid=query-detail]");
|
|
83
|
+
expect(detail.exists()).toBe(true);
|
|
84
|
+
expect(detail.text()).toContain("counter-total");
|
|
85
|
+
});
|
|
86
|
+
});
|