@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.
Files changed (130) hide show
  1. package/package.json +5 -3
  2. package/src/App.vue +62 -56
  3. package/src/components/BcCard.stories.ts +47 -0
  4. package/src/components/BcCard.vue +152 -0
  5. package/src/components/DurationBar.stories.ts +55 -0
  6. package/src/components/DurationBar.vue +72 -0
  7. package/src/components/ErrorCard.stories.ts +133 -0
  8. package/src/components/ErrorCard.vue +153 -0
  9. package/src/components/GraphCanvas.stories.ts +48 -0
  10. package/src/components/GraphCanvas.vue +88 -0
  11. package/src/components/KpiTile.stories.ts +32 -0
  12. package/src/components/KpiTile.vue +39 -0
  13. package/src/components/LiveTable.stories.ts +78 -0
  14. package/src/components/LiveTable.vue +186 -0
  15. package/src/components/MetadataInspector.stories.ts +53 -0
  16. package/src/components/MetadataInspector.vue +105 -0
  17. package/src/components/NodeCard.stories.ts +44 -0
  18. package/src/components/NodeCard.vue +150 -0
  19. package/src/components/RcaPanel.stories.ts +95 -0
  20. package/src/components/RcaPanel.vue +223 -0
  21. package/src/components/ServiceNode.vue +134 -0
  22. package/src/components/SourceDrawer.vue +6 -4
  23. package/src/components/SourcePill.vue +10 -3
  24. package/src/components/StatusBadge.stories.ts +33 -0
  25. package/src/components/StatusBadge.vue +54 -0
  26. package/src/components/Waterfall.stories.ts +85 -0
  27. package/src/components/Waterfall.vue +53 -0
  28. package/src/components/WaterfallRow.vue +74 -0
  29. package/src/components/__tests__/BcCard.test.ts +53 -0
  30. package/src/components/__tests__/DurationBar.test.ts +31 -0
  31. package/src/components/__tests__/ErrorCard.test.ts +71 -0
  32. package/src/components/__tests__/KpiTile.test.ts +23 -0
  33. package/src/components/__tests__/LiveTable.test.ts +100 -0
  34. package/src/components/__tests__/MetadataInspector.test.ts +38 -0
  35. package/src/components/__tests__/NodeCard.test.ts +116 -0
  36. package/src/components/__tests__/RcaPanel.test.ts +81 -0
  37. package/src/components/__tests__/StatusBadge.test.ts +23 -0
  38. package/src/components/__tests__/Waterfall.test.ts +54 -0
  39. package/src/components/index.ts +13 -0
  40. package/src/composables/__tests__/composables-context.test.ts +107 -0
  41. package/src/composables/__tests__/useTelemetry.test.ts +104 -0
  42. package/src/composables/__tests__/useTelemetryRuns.test.ts +282 -0
  43. package/src/composables/useDiscovery.ts +73 -0
  44. package/src/composables/useEndpoints.ts +94 -0
  45. package/src/composables/useLogTail.ts +51 -0
  46. package/src/composables/useManifest.ts +43 -0
  47. package/src/composables/useProcesses.ts +114 -0
  48. package/src/composables/useProject.ts +34 -0
  49. package/src/composables/useTelemetry.ts +270 -0
  50. package/src/lib/__tests__/bc-graph.test.ts +218 -0
  51. package/src/lib/__tests__/dispatch-form.test.ts +113 -0
  52. package/src/lib/__tests__/error-friendly.test.ts +198 -0
  53. package/src/lib/__tests__/home.test.ts +231 -0
  54. package/src/lib/__tests__/inspect.test.ts +160 -0
  55. package/src/lib/__tests__/kind-colors.test.ts +59 -0
  56. package/src/lib/__tests__/live-table.test.ts +194 -0
  57. package/src/lib/__tests__/manifest-health.test.ts +120 -0
  58. package/src/lib/__tests__/manifest.test.ts +87 -0
  59. package/src/lib/__tests__/metadata.test.ts +47 -0
  60. package/src/lib/__tests__/node-metrics.test.ts +144 -0
  61. package/src/lib/__tests__/operate.test.ts +97 -0
  62. package/src/lib/__tests__/pipeline-flow.test.ts +79 -0
  63. package/src/lib/__tests__/rca.test.ts +124 -0
  64. package/src/lib/__tests__/telemetry.test.ts +91 -0
  65. package/src/lib/__tests__/topology-graph.test.ts +331 -0
  66. package/src/lib/__tests__/topology-view.test.ts +154 -0
  67. package/src/lib/__tests__/waterfall.test.ts +165 -0
  68. package/src/lib/bc-graph.ts +298 -0
  69. package/src/lib/dispatch-form.ts +160 -0
  70. package/src/lib/error-friendly.ts +288 -0
  71. package/src/lib/home.ts +191 -0
  72. package/src/lib/inspect.ts +226 -0
  73. package/src/lib/kind-colors.ts +132 -0
  74. package/src/lib/live-table.ts +204 -0
  75. package/src/lib/manifest-health.ts +71 -0
  76. package/src/lib/manifest.ts +139 -0
  77. package/src/lib/metadata.ts +52 -0
  78. package/src/lib/node-metrics.ts +242 -0
  79. package/src/lib/operate.ts +114 -0
  80. package/src/lib/pipeline-flow.ts +120 -0
  81. package/src/lib/rca.ts +193 -0
  82. package/src/lib/telemetry.ts +155 -0
  83. package/src/lib/topology-graph.ts +551 -0
  84. package/src/lib/topology-view.ts +185 -0
  85. package/src/lib/waterfall.ts +148 -0
  86. package/src/main.ts +63 -29
  87. package/src/pages/Errors.vue +272 -0
  88. package/src/pages/Home.stories.ts +7 -8
  89. package/src/pages/Home.vue +255 -540
  90. package/src/pages/Hooks.stories.ts +44 -0
  91. package/src/pages/Hooks.vue +165 -164
  92. package/src/pages/Inspect.vue +240 -0
  93. package/src/pages/Map.vue +187 -0
  94. package/src/pages/Operate.vue +74 -0
  95. package/src/pages/Plugins.stories.ts +1 -1
  96. package/src/pages/Plugins.vue +174 -238
  97. package/src/pages/Projects.vue +62 -60
  98. package/src/pages/Streams.vue +344 -0
  99. package/src/pages/Topology.vue +318 -136
  100. package/src/pages/Trace.vue +174 -412
  101. package/src/pages/__tests__/Home.test.ts +109 -54
  102. package/src/pages/__tests__/Hooks.test.ts +5 -5
  103. package/src/pages/__tests__/Inspect.test.ts +111 -0
  104. package/src/pages/__tests__/Plugins.test.ts +85 -35
  105. package/src/pages/__tests__/Trace.test.ts +117 -0
  106. package/src/pages/operate/CommandsPanel.vue +186 -0
  107. package/src/pages/{Dispatch.vue → operate/DispatchPanel.vue} +100 -203
  108. package/src/pages/operate/EndpointPicker.vue +56 -0
  109. package/src/pages/operate/RunPanel.vue +316 -0
  110. package/src/server/__tests__/nwire-read.test.ts +80 -0
  111. package/src/server/nwire-read.ts +63 -0
  112. package/vite.config.ts +220 -2
  113. package/src/lib/__tests__/normalize-cache.test.ts +0 -105
  114. package/src/lib/cache.ts +0 -312
  115. package/src/lib/normalize-cache.ts +0 -92
  116. package/src/pages/Actions.vue +0 -171
  117. package/src/pages/Apps.vue +0 -177
  118. package/src/pages/Commands.vue +0 -262
  119. package/src/pages/Events.vue +0 -210
  120. package/src/pages/Live.vue +0 -249
  121. package/src/pages/Overview.vue +0 -161
  122. package/src/pages/Projections.vue +0 -148
  123. package/src/pages/Queries.vue +0 -148
  124. package/src/pages/Run.vue +0 -618
  125. package/src/pages/Sinks.vue +0 -124
  126. package/src/pages/TraceNode.vue +0 -164
  127. package/src/pages/Workflows.vue +0 -184
  128. package/src/pages/__tests__/Actions.test.ts +0 -98
  129. package/src/pages/__tests__/Projections.test.ts +0 -90
  130. package/src/pages/__tests__/Queries.test.ts +0 -86
@@ -1,249 +0,0 @@
1
- <script setup lang="ts">
2
- import { ref, computed, onMounted, onUnmounted } from "vue";
3
- import {
4
- Radio,
5
- PlayCircle,
6
- PauseCircle,
7
- Trash2,
8
- Filter,
9
- Network,
10
- Globe,
11
- Search,
12
- } from "lucide-vue-next";
13
-
14
- interface BufferedEvent {
15
- seq: number;
16
- eventName: string;
17
- payload: unknown;
18
- envelope: {
19
- messageId: string;
20
- correlationId: string;
21
- causationId: string;
22
- tenant?: string;
23
- userId?: string;
24
- timestamp: string;
25
- version: number;
26
- };
27
- source: "in-process" | "external";
28
- appName: string;
29
- capturedAt: string;
30
- }
31
-
32
- const events = ref<BufferedEvent[]>([]);
33
- const paused = ref(false);
34
- const filter = ref("");
35
- const selectedCorrelation = ref<string | null>(null);
36
- const status = ref<"connecting" | "open" | "closed" | "error">("connecting");
37
- let es: EventSource | null = null;
38
-
39
- onMounted(() => {
40
- connect();
41
- });
42
-
43
- onUnmounted(() => {
44
- es?.close();
45
- });
46
-
47
- function connect() {
48
- status.value = "connecting";
49
- es?.close();
50
- es = new EventSource("/_nwire/events/stream");
51
- es.onopen = () => {
52
- status.value = "open";
53
- };
54
- es.onerror = () => {
55
- status.value = "error";
56
- };
57
- es.onmessage = (msg) => {
58
- if (paused.value) return;
59
- try {
60
- const evt = JSON.parse(msg.data) as BufferedEvent;
61
- events.value.unshift(evt);
62
- if (events.value.length > 1000) events.value.pop();
63
- } catch (err) {
64
- console.error("bad event", err);
65
- }
66
- };
67
- }
68
-
69
- function reconnect() {
70
- connect();
71
- }
72
-
73
- function clear() {
74
- events.value = [];
75
- }
76
-
77
- const filtered = computed(() => {
78
- const q = filter.value.toLowerCase();
79
- return events.value.filter((e) => {
80
- if (selectedCorrelation.value && e.envelope.correlationId !== selectedCorrelation.value) {
81
- return false;
82
- }
83
- if (!q) return true;
84
- return (
85
- e.eventName.toLowerCase().includes(q) ||
86
- e.envelope.messageId.toLowerCase().includes(q) ||
87
- e.envelope.correlationId.toLowerCase().includes(q) ||
88
- (e.envelope.tenant?.toLowerCase().includes(q) ?? false) ||
89
- (e.envelope.userId?.toLowerCase().includes(q) ?? false)
90
- );
91
- });
92
- });
93
-
94
- const selectedTrace = computed(() => {
95
- if (!selectedCorrelation.value) return [];
96
- return events.value
97
- .filter((e) => e.envelope.correlationId === selectedCorrelation.value)
98
- .sort((a, b) => a.seq - b.seq);
99
- });
100
-
101
- function shortId(id: string): string {
102
- return id.split("-")[0] ?? id.slice(0, 8);
103
- }
104
- </script>
105
-
106
- <template>
107
- <div class="h-full flex">
108
- <!-- Stream column -->
109
- <div class="flex-1 flex flex-col border-r border-zinc-800 min-w-0">
110
- <div class="border-b border-zinc-800 px-4 py-3 flex items-center justify-between gap-3">
111
- <div class="flex items-center gap-3 min-w-0">
112
- <Radio
113
- class="w-5 h-5 shrink-0"
114
- :class="{
115
- 'text-emerald-400 animate-pulse': status === 'open' && !paused,
116
- 'text-zinc-400': paused,
117
- 'text-amber-400': status === 'connecting',
118
- 'text-rose-400': status === 'error' || status === 'closed',
119
- }"
120
- />
121
- <h1 class="font-semibold text-lg truncate">Live</h1>
122
- <span
123
- class="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-zinc-900 text-zinc-400"
124
- >
125
- {{ status }}
126
- </span>
127
- <span class="text-xs text-zinc-500 tabular-nums"
128
- >{{ filtered.length }} / {{ events.length }}</span
129
- >
130
- </div>
131
- <div class="flex items-center gap-1 shrink-0">
132
- <button
133
- class="p-1.5 rounded hover:bg-zinc-800 text-zinc-400 hover:text-zinc-100"
134
- :title="paused ? 'Resume' : 'Pause'"
135
- @click="paused = !paused"
136
- >
137
- <PlayCircle v-if="paused" class="w-4 h-4 text-emerald-400" />
138
- <PauseCircle v-else class="w-4 h-4" />
139
- </button>
140
- <button
141
- class="p-1.5 rounded hover:bg-zinc-800 text-zinc-400 hover:text-zinc-100"
142
- title="Clear"
143
- @click="clear"
144
- >
145
- <Trash2 class="w-4 h-4" />
146
- </button>
147
- <button
148
- v-if="status === 'error' || status === 'closed'"
149
- class="px-2 py-1 rounded text-xs bg-zinc-800 hover:bg-zinc-700"
150
- @click="reconnect"
151
- >
152
- Reconnect
153
- </button>
154
- </div>
155
- </div>
156
-
157
- <div class="px-4 py-2 border-b border-zinc-800 flex items-center gap-2">
158
- <div class="relative flex-1">
159
- <Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-zinc-500" />
160
- <input
161
- v-model="filter"
162
- placeholder="filter event name / id / tenant / user…"
163
- class="w-full bg-zinc-900 border border-zinc-800 rounded pl-7 pr-2 py-1.5 text-sm placeholder:text-zinc-600 focus:outline-none focus:border-zinc-600"
164
- />
165
- </div>
166
- <button
167
- v-if="selectedCorrelation"
168
- class="text-xs px-2 py-1 rounded bg-purple-950/50 border border-purple-900 text-purple-300 hover:bg-purple-900/50"
169
- @click="selectedCorrelation = null"
170
- >
171
- <Filter class="w-3 h-3 inline mr-1" />
172
- clear trace filter
173
- </button>
174
- </div>
175
-
176
- <div class="flex-1 overflow-auto">
177
- <div v-if="filtered.length === 0" class="p-6 text-sm text-zinc-500">
178
- {{ events.length === 0 ? "Waiting for events…" : "No events match the filter." }}
179
- </div>
180
- <button
181
- v-for="evt in filtered"
182
- :key="evt.seq"
183
- class="w-full text-left px-4 py-2 border-b border-zinc-900 hover:bg-zinc-900/50 transition-colors"
184
- :class="{
185
- 'bg-zinc-900/70':
186
- selectedCorrelation && evt.envelope.correlationId === selectedCorrelation,
187
- }"
188
- @click="selectedCorrelation = evt.envelope.correlationId"
189
- >
190
- <div class="flex items-center justify-between gap-3">
191
- <div class="flex items-center gap-2 min-w-0">
192
- <component
193
- :is="evt.source === 'external' ? Network : Globe"
194
- class="w-3 h-3 shrink-0"
195
- :class="evt.source === 'external' ? 'text-purple-400' : 'text-emerald-400'"
196
- />
197
- <span class="font-mono text-sm truncate">{{ evt.eventName }}</span>
198
- <span class="text-[10px] text-zinc-500 shrink-0">{{ evt.appName }}</span>
199
- </div>
200
- <span class="text-[10px] text-zinc-500 tabular-nums shrink-0">
201
- {{ new Date(evt.capturedAt).toLocaleTimeString() }}
202
- </span>
203
- </div>
204
- <div class="text-[10px] text-zinc-500 font-mono mt-0.5 truncate">
205
- msg {{ shortId(evt.envelope.messageId) }} · corr
206
- {{ shortId(evt.envelope.correlationId) }}
207
- <span v-if="evt.envelope.tenant">· tenant {{ evt.envelope.tenant }}</span>
208
- <span v-if="evt.envelope.userId">· user {{ evt.envelope.userId }}</span>
209
- </div>
210
- </button>
211
- </div>
212
- </div>
213
-
214
- <!-- Trace column -->
215
- <div class="w-1/2 flex flex-col">
216
- <div class="border-b border-zinc-800 px-4 py-3">
217
- <h2 class="text-sm font-medium tracking-tight">
218
- {{ selectedCorrelation ? "Trace" : "Details" }}
219
- </h2>
220
- <p v-if="selectedCorrelation" class="text-[10px] text-zinc-500 font-mono mt-0.5">
221
- corr {{ selectedCorrelation }}
222
- </p>
223
- <p v-else class="text-xs text-zinc-500 mt-0.5">Click an event to see its causation tree.</p>
224
- </div>
225
- <div class="flex-1 overflow-auto p-4 space-y-2">
226
- <div v-for="(evt, i) in selectedTrace" :key="evt.seq" class="relative pl-4">
227
- <div
228
- class="absolute left-1 top-0 bottom-0 w-px bg-zinc-800"
229
- v-if="i < selectedTrace.length - 1"
230
- ></div>
231
- <div class="absolute left-0 top-2.5 w-2 h-2 rounded-full bg-emerald-400"></div>
232
- <div class="rounded border border-zinc-800 bg-zinc-900/40 p-3">
233
- <div class="flex items-center justify-between mb-1">
234
- <span class="font-mono text-sm">{{ evt.eventName }}</span>
235
- <span class="text-[10px] text-zinc-500">{{ evt.appName }}</span>
236
- </div>
237
- <div class="text-[10px] text-zinc-500 font-mono mb-2">
238
- msg {{ shortId(evt.envelope.messageId) }} ← caused by
239
- {{ shortId(evt.envelope.causationId) }}
240
- </div>
241
- <pre class="text-[11px] bg-zinc-950 border border-zinc-800 rounded p-2 overflow-auto">{{
242
- JSON.stringify(evt.payload, null, 2)
243
- }}</pre>
244
- </div>
245
- </div>
246
- </div>
247
- </div>
248
- </div>
249
- </template>
@@ -1,161 +0,0 @@
1
- <script setup lang="ts">
2
- import { computed } from "vue";
3
- import { RouterLink } from "vue-router";
4
- import { useCache } from "@/lib/cache";
5
- import {
6
- Boxes,
7
- Zap,
8
- Radio,
9
- Layers,
10
- Database,
11
- GitBranch,
12
- Network,
13
- LayoutDashboard,
14
- Waves,
15
- Send,
16
- Play,
17
- Workflow,
18
- } from "lucide-vue-next";
19
- import { PageHeader, KindBadge } from "@/components";
20
-
21
- const { cache } = useCache();
22
-
23
- // "Daily" shortcuts — the high-frequency entry points.
24
- const quickActions = [
25
- {
26
- to: "/live",
27
- label: "Trace",
28
- desc: "Watch actions / events / workflows fire in real time",
29
- icon: Waves,
30
- color: "text-cyan-400",
31
- },
32
- {
33
- to: "/dispatch",
34
- label: "Try",
35
- desc: "Dispatch any action with a form generated from its schema",
36
- icon: Send,
37
- color: "text-amber-400",
38
- },
39
- {
40
- to: "/run",
41
- label: "Processes",
42
- desc: "Start, stop, and watch logs for your wires",
43
- icon: Play,
44
- color: "text-emerald-400",
45
- },
46
- {
47
- to: "/eventstorm",
48
- label: "Flow",
49
- desc: "See the causal graph: action → event → workflow → action",
50
- icon: Workflow,
51
- color: "text-violet-400",
52
- },
53
- ];
54
-
55
- const hasData = computed(() => !!cache.value && cache.value.apps.length > 0);
56
-
57
- const stats = computed(() => {
58
- if (!cache.value) return [];
59
- return [
60
- { label: "Apps", value: cache.value.apps.length, icon: Network, color: "text-blue-400" },
61
- { label: "Plugins", value: cache.value.plugins.length, icon: Boxes, color: "text-emerald-400" },
62
- { label: "Actions", value: cache.value.actions.length, icon: Zap, color: "text-amber-400" },
63
- { label: "Events", value: cache.value.events.length, icon: Radio, color: "text-purple-400" },
64
- { label: "Actors", value: cache.value.actors.length, icon: Layers, color: "text-pink-400" },
65
- {
66
- label: "Projections",
67
- value: cache.value.projections.length,
68
- icon: Database,
69
- color: "text-cyan-400",
70
- },
71
- {
72
- label: "Workflows",
73
- value: cache.value.workflows.length,
74
- icon: GitBranch,
75
- color: "text-violet-400",
76
- },
77
- {
78
- label: "Resolvers",
79
- value: cache.value.resolvers.length,
80
- icon: Network,
81
- color: "text-rose-400",
82
- },
83
- ];
84
- });
85
- </script>
86
-
87
- <template>
88
- <div v-if="cache" class="p-6 space-y-6" data-testid="overview-page">
89
- <PageHeader
90
- title="Overview"
91
- :icon="LayoutDashboard"
92
- icon-color="text-emerald-400"
93
- :subtitle="
94
- hasData
95
- ? 'Snapshot from .nwire/manifest.json'
96
- : 'No apps discovered yet — try the shortcuts below'
97
- "
98
- />
99
-
100
- <!-- Quick actions — what you actually do here daily. -->
101
- <div>
102
- <h2 class="text-sm font-medium text-zinc-300 uppercase tracking-wide mb-3">Start here</h2>
103
- <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
104
- <RouterLink
105
- v-for="a in quickActions"
106
- :key="a.to"
107
- :to="a.to"
108
- class="rounded-lg border border-zinc-800 bg-zinc-900/50 px-4 py-3 hover:bg-zinc-900 hover:border-zinc-700 transition-colors block"
109
- >
110
- <div class="flex items-center gap-2 mb-1">
111
- <component :is="a.icon" class="w-4 h-4" :class="a.color" />
112
- <span class="font-medium text-zinc-100">{{ a.label }}</span>
113
- </div>
114
- <div class="text-xs text-zinc-400 leading-relaxed">{{ a.desc }}</div>
115
- </RouterLink>
116
- </div>
117
- </div>
118
-
119
- <div v-if="hasData" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-8 gap-3">
120
- <div
121
- v-for="s in stats"
122
- :key="s.label"
123
- class="rounded-lg border border-zinc-800 bg-zinc-900/50 px-4 py-3"
124
- data-testid="overview-stat"
125
- >
126
- <div class="flex items-center justify-between mb-1">
127
- <span class="text-xs text-zinc-500 uppercase tracking-wide">{{ s.label }}</span>
128
- <component :is="s.icon" class="w-3.5 h-3.5" :class="s.color" />
129
- </div>
130
- <div class="text-2xl font-semibold tabular-nums">{{ s.value }}</div>
131
- </div>
132
- </div>
133
-
134
- <div v-if="hasData">
135
- <h2 class="text-sm font-medium text-zinc-300 uppercase tracking-wide mb-3">Apps</h2>
136
- <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
137
- <div
138
- v-for="app in cache.apps"
139
- :key="app.name"
140
- class="rounded-lg border border-zinc-800 bg-zinc-900/50 px-4 py-3"
141
- data-testid="overview-app-card"
142
- >
143
- <div class="flex items-center justify-between">
144
- <span class="font-medium">{{ app.name }}</span>
145
- <span class="text-xs text-zinc-500 tabular-nums">
146
- {{ app.plugins.length }} plugin(s)
147
- </span>
148
- </div>
149
- <div v-if="app.description" class="text-sm text-zinc-400 mt-1">
150
- {{ app.description }}
151
- </div>
152
- <div class="mt-2 flex flex-wrap gap-1">
153
- <KindBadge v-for="p in app.plugins" :key="p" variant="neutral">
154
- {{ p }}
155
- </KindBadge>
156
- </div>
157
- </div>
158
- </div>
159
- </div>
160
- </div>
161
- </template>
@@ -1,148 +0,0 @@
1
- <script setup lang="ts">
2
- /**
3
- * Projections — CQRS read models. Each fold takes (state, event) → state;
4
- * the projection store is keyed by projection name + tenant. Studio shows
5
- * the registered projections and which events they listen to.
6
- */
7
- import { computed, onMounted, ref, watch } from "vue";
8
- import { useRoute, useRouter } from "vue-router";
9
- import { useCache } from "@/lib/cache";
10
- import { Database, ArrowRight } from "lucide-vue-next";
11
- import {
12
- PageHeader,
13
- FilterInput,
14
- EmptyState,
15
- MasterDetail,
16
- SourcePill,
17
- SourceDrawer,
18
- ListRow,
19
- } from "@/components";
20
-
21
- const route = useRoute();
22
- const router = useRouter();
23
- const { cache } = useCache();
24
- const filter = ref("");
25
- const selected = ref<string | null>(null);
26
- const sourcePreview = ref<{ file: string; line: number; column?: number } | null>(null);
27
-
28
- function applyQueryPreselect(): void {
29
- const name = route.query.name;
30
- if (typeof name !== "string" || name.length === 0) return;
31
- const found = cache.value?.projections.find((p) => p.name === name);
32
- if (found) selected.value = `${found.app}::${found.name}`;
33
- }
34
-
35
- onMounted(applyQueryPreselect);
36
- watch(() => route.query.name, applyQueryPreselect);
37
- watch(() => cache.value, applyQueryPreselect);
38
-
39
- const filtered = computed(() => {
40
- if (!cache.value) return [];
41
- const q = filter.value.toLowerCase();
42
- return cache.value.projections.filter(
43
- (p) => !q || p.name.toLowerCase().includes(q) || p.app.toLowerCase().includes(q),
44
- );
45
- });
46
-
47
- const key = (p: { app: string; name: string }) => `${p.app}::${p.name}`;
48
- const detail = computed(() => filtered.value.find((p) => key(p) === selected.value) ?? null);
49
-
50
- /**
51
- * Cross-reference: which queries read from this projection? Studio
52
- * doesn't yet know the projection→query edge directly (the scanner
53
- * emits queries with their projection name when set), so we filter by
54
- * name match.
55
- */
56
- const queriesForDetail = computed(() => {
57
- if (!detail.value || !cache.value) return [];
58
- return cache.value.queries.filter(
59
- (q) => (q as { projection?: string }).projection === detail.value!.name,
60
- );
61
- });
62
- </script>
63
-
64
- <template>
65
- <div v-if="cache" class="h-full flex flex-col" data-testid="projections-page">
66
- <div class="p-6 pb-3 border-b border-zinc-800">
67
- <PageHeader
68
- title="Projections"
69
- subtitle="CQRS read models — every fold from event stream to materialised state."
70
- :icon="Database"
71
- icon-color="text-cyan-400"
72
- :count="filtered.length"
73
- :total="cache.projections.length"
74
- />
75
- </div>
76
-
77
- <EmptyState
78
- v-if="cache.projections.length === 0"
79
- title="No projections in cache"
80
- hint="Projections are declared via defineProjection(name, { listens, initial, on }). Run `nwire cache` after adding one."
81
- :icon="Database"
82
- />
83
-
84
- <MasterDetail v-else class="flex-1">
85
- <template #listHeader>
86
- <FilterInput v-model="filter" placeholder="filter by name or app…" />
87
- </template>
88
-
89
- <template #list>
90
- <ListRow
91
- v-for="p in filtered"
92
- :key="key(p)"
93
- :selected="selected === key(p)"
94
- @click="selected = key(p)"
95
- >
96
- <template #title>
97
- <Database class="w-3 h-3 text-cyan-400 shrink-0" />
98
- <span class="font-mono text-sm truncate">{{ p.name }}</span>
99
- </template>
100
- <template #meta>
101
- <span class="text-[10px] text-zinc-500">{{ p.app }}</span>
102
- </template>
103
- </ListRow>
104
- </template>
105
-
106
- <template #empty
107
- >Select a projection to view its event subscriptions and reading queries.</template
108
- >
109
-
110
- <template v-if="detail" #detail>
111
- <div class="p-6 space-y-5" data-testid="projection-detail">
112
- <div>
113
- <div class="text-[10px] uppercase tracking-wide text-zinc-500">
114
- {{ detail.app }}
115
- </div>
116
- <h2 class="font-mono text-xl mt-1">{{ detail.name }}</h2>
117
- </div>
118
-
119
- <div class="space-y-3">
120
- <h3 class="text-xs uppercase tracking-wide text-zinc-500">
121
- Queries on this projection
122
- </h3>
123
- <div v-if="queriesForDetail.length === 0" class="text-xs text-zinc-600">
124
- No queries registered against this projection yet.
125
- </div>
126
- <div v-else class="space-y-1">
127
- <button
128
- v-for="q in queriesForDetail"
129
- :key="q.name"
130
- type="button"
131
- class="flex items-center gap-2 font-mono text-sm text-left hover:text-emerald-300"
132
- @click="router.push({ path: '/queries', query: { name: q.name } })"
133
- >
134
- <ArrowRight class="w-3.5 h-3.5 text-emerald-400" />
135
- <span class="underline-offset-2 hover:underline">{{ q.name }}</span>
136
- </button>
137
- </div>
138
- </div>
139
-
140
- <div v-if="detail.source" class="pt-2">
141
- <SourcePill :source="detail.source" @click="sourcePreview = detail.source!" />
142
- </div>
143
- </div>
144
- </template>
145
- </MasterDetail>
146
- <SourceDrawer :source="sourcePreview" @close="sourcePreview = null" />
147
- </div>
148
- </template>