@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.
@@ -35,7 +35,7 @@ const filtered = computed(() => {
35
35
  (a) =>
36
36
  !q ||
37
37
  a.name.toLowerCase().includes(q) ||
38
- a.module.toLowerCase().includes(q) ||
38
+ a.app.toLowerCase().includes(q) ||
39
39
  (a.description ?? "").toLowerCase().includes(q),
40
40
  );
41
41
  });
@@ -57,7 +57,7 @@ interface SchemaField {
57
57
  const schemaFields = computed<SchemaField[]>(() => {
58
58
  if (!selected.value) return [];
59
59
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
60
- const schema = selected.value.schema as any;
60
+ const schema = selected.value.inputSchema as any;
61
61
  if (!schema?.properties) return [];
62
62
  const required = new Set<string>(Array.isArray(schema.required) ? schema.required : []);
63
63
  const out: SchemaField[] = [];
@@ -83,7 +83,7 @@ watch(selectedName, () => {
83
83
  result.value = null;
84
84
  if (!selected.value) return;
85
85
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
86
- const schema = selected.value.schema as any;
86
+ const schema = selected.value.inputSchema as any;
87
87
  const scaffold: Record<string, unknown> = {};
88
88
  if (schema?.properties) {
89
89
  for (const [key, prop] of Object.entries(schema.properties)) {
@@ -223,7 +223,7 @@ async function dispatch() {
223
223
  <div v-else class="p-6 space-y-5">
224
224
  <div>
225
225
  <div class="text-[10px] uppercase tracking-wide text-zinc-500">
226
- {{ selected.app }} · {{ selected.module }}
226
+ {{ selected.app }}
227
227
  </div>
228
228
  <h2 class="font-mono text-xl mt-1">{{ selected.name }}</h2>
229
229
  <p v-if="selected.description" class="text-sm text-zinc-400 mt-2 max-w-2xl">
@@ -22,10 +22,6 @@ function applyQueryPreselect(): void {
22
22
  onMounted(applyQueryPreselect);
23
23
  watch(() => route.query.name, applyQueryPreselect);
24
24
 
25
- function openModule(name: string): void {
26
- void router.push({ path: "/modules", query: { name } });
27
- }
28
-
29
25
  const filtered = computed(() => {
30
26
  if (!cache.value) return [];
31
27
  const q = filter.value.toLowerCase();
@@ -34,15 +30,51 @@ const filtered = computed(() => {
34
30
  !q ||
35
31
  e.name.toLowerCase().includes(q) ||
36
32
  (e.description ?? "").toLowerCase().includes(q) ||
37
- e.module.toLowerCase().includes(q),
33
+ e.app.toLowerCase().includes(q),
38
34
  );
39
35
  });
40
36
 
41
37
  const detail = computed(() => filtered.value.find((e) => e.name === selected.value) ?? null);
42
38
 
43
- const detailEdge = computed(() => {
44
- if (!cache.value || !detail.value) return null;
45
- return cache.value.graph.events.find((e) => e.event === detail.value!.name);
39
+ /** Producers graph edges where `to` is this event and via === "emits". */
40
+ const producers = computed(() => {
41
+ if (!cache.value || !detail.value) return [];
42
+ return cache.value.graph.events
43
+ .filter((edge) => edge.to === detail.value!.name && edge.via === "emits")
44
+ .map((edge) => edge.from);
45
+ });
46
+
47
+ /** Consumers — graph edges where `from` is this event. */
48
+ const consumers = computed(() => {
49
+ if (!cache.value || !detail.value) return [];
50
+ return cache.value.graph.events
51
+ .filter((edge) => edge.from === detail.value!.name)
52
+ .map((edge) => ({ name: edge.to, via: edge.via }));
53
+ });
54
+
55
+ function openProducer(name: string): void {
56
+ void router.push({ path: "/actions", query: { name } });
57
+ }
58
+
59
+ function openConsumer(c: { name: string; via: string }): void {
60
+ if (c.via === "folds") {
61
+ void router.push({ path: "/projections", query: { name: c.name } });
62
+ } else if (c.via === "subscribes") {
63
+ void router.push({ path: "/workflows", query: { name: c.name } });
64
+ } else if (c.via === "dispatches") {
65
+ void router.push({ path: "/actions", query: { name: c.name } });
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Payload schema — the scanner emits it on actions (`inputSchema`) but
71
+ * not yet on events. Render whatever the manifest happens to carry
72
+ * under `.schema` if anything; otherwise the SchemaTree slot just
73
+ * doesn't render.
74
+ */
75
+ const detailSchema = computed<unknown>(() => {
76
+ const d = detail.value as { schema?: unknown } | null;
77
+ return d?.schema;
46
78
  });
47
79
  </script>
48
80
 
@@ -77,11 +109,10 @@ const detailEdge = computed(() => {
77
109
  <span class="font-mono text-sm truncate">{{ e.name }}</span>
78
110
  </div>
79
111
  <component
80
- :is="(e.public ?? e.visibility === 'public') ? Globe : Lock"
112
+ :is="e.public ? Globe : Lock"
81
113
  class="w-3 h-3 shrink-0"
82
- :class="
83
- (e.public ?? e.visibility === 'public') ? 'text-emerald-400' : 'text-zinc-500'
84
- "
114
+ :class="e.public ? 'text-emerald-400' : 'text-zinc-500'"
115
+ :title="e.public ? 'public reaches outbound sinks' : 'private — stays in-process'"
85
116
  />
86
117
  </div>
87
118
  <div v-if="e.description" class="text-xs text-zinc-500 mt-1 line-clamp-2 ml-5">
@@ -96,17 +127,18 @@ const detailEdge = computed(() => {
96
127
  <div v-else class="p-6 space-y-5">
97
128
  <div>
98
129
  <div class="text-[10px] uppercase tracking-wide text-zinc-500 flex items-center gap-2">
99
- <span>{{ detail.app }} · {{ detail.module }}</span>
130
+ <span>{{ detail.app }}</span>
100
131
  <span
101
132
  class="px-1.5 py-0.5 rounded text-[10px] uppercase"
102
133
  :class="
103
- (detail.public ?? detail.visibility === 'public')
134
+ detail.public
104
135
  ? 'bg-emerald-950/50 border border-emerald-900 text-emerald-300'
105
136
  : 'bg-zinc-950/50 border border-zinc-800 text-zinc-400'
106
137
  "
107
138
  >
108
- {{ (detail.public ?? detail.visibility === "public") ? "public" : "private" }}
139
+ {{ detail.public ? "public" : "private" }}
109
140
  </span>
141
+ <span v-if="detail.version" class="text-zinc-600">v{{ detail.version }}</span>
110
142
  </div>
111
143
  <h2 class="font-mono text-xl mt-1">{{ detail.name }}</h2>
112
144
  <p v-if="detail.description" class="text-sm text-zinc-400 mt-2 max-w-2xl">
@@ -114,51 +146,63 @@ const detailEdge = computed(() => {
114
146
  </p>
115
147
  </div>
116
148
 
117
- <div v-if="detailEdge">
149
+ <div v-if="producers.length > 0 || consumers.length > 0">
118
150
  <h3 class="text-xs uppercase tracking-wide text-zinc-500 mb-2">Flow</h3>
119
- <div class="text-sm space-y-1">
120
- <div class="flex items-center gap-2">
121
- <span class="text-zinc-500">Produced by:</span>
122
- <button
123
- type="button"
124
- class="font-mono underline-offset-2 hover:underline text-zinc-200"
125
- :data-testid="`module-link-${detailEdge.producer.module}`"
126
- @click="openModule(detailEdge.producer.module)"
127
- >
128
- {{ detailEdge.producer.app }}/{{ detailEdge.producer.module }}
129
- </button>
151
+ <div class="text-sm space-y-2">
152
+ <div v-if="producers.length > 0" class="flex items-start gap-2">
153
+ <span class="text-zinc-500 mt-0.5">Produced by:</span>
154
+ <div class="flex flex-wrap gap-1.5">
155
+ <button
156
+ v-for="p in producers"
157
+ :key="p"
158
+ type="button"
159
+ class="flex items-center gap-1 text-xs font-mono bg-zinc-900 border border-zinc-800 rounded px-1.5 py-0.5 hover:bg-zinc-800"
160
+ @click="openProducer(p)"
161
+ >
162
+ <ArrowRight class="w-2.5 h-2.5 text-amber-400" />
163
+ {{ p }}
164
+ </button>
165
+ </div>
130
166
  </div>
131
- <div class="flex items-start gap-2">
167
+ <div v-if="consumers.length > 0" class="flex items-start gap-2">
132
168
  <span class="text-zinc-500 mt-0.5">Consumed by:</span>
133
169
  <div class="flex flex-wrap gap-1.5">
134
170
  <button
135
- v-for="(c, i) in detailEdge.consumers"
171
+ v-for="(c, i) in consumers"
136
172
  :key="i"
137
173
  type="button"
138
174
  class="flex items-center gap-1 text-xs font-mono bg-zinc-900 border border-zinc-800 rounded px-1.5 py-0.5 hover:bg-zinc-800"
139
- :data-testid="`module-link-${c.module}`"
140
- @click="openModule(c.module)"
175
+ @click="openConsumer(c)"
141
176
  >
142
177
  <ArrowRight class="w-2.5 h-2.5 text-zinc-500" />
143
- {{ c.app }}/{{ c.module }}
178
+ {{ c.name }}
144
179
  <span class="text-[9px] uppercase text-zinc-500">{{ c.via }}</span>
145
180
  </button>
146
181
  </div>
147
182
  </div>
183
+ <div
184
+ v-if="detail.audience && detail.audience.length > 0"
185
+ class="flex items-start gap-2"
186
+ >
187
+ <span class="text-zinc-500 mt-0.5">Audience:</span>
188
+ <div class="flex flex-wrap gap-1.5">
189
+ <span
190
+ v-for="a in detail.audience"
191
+ :key="a"
192
+ class="text-xs font-mono bg-zinc-900 border border-zinc-800 rounded px-1.5 py-0.5"
193
+ >
194
+ {{ a }}
195
+ </span>
196
+ </div>
197
+ </div>
148
198
  </div>
149
199
  </div>
150
200
 
151
201
  <div v-if="detail.source" class="flex items-center gap-2">
152
- <button
153
- type="button"
154
- class="inline-flex items-center"
155
- @click="sourcePreview = detail.source!"
156
- >
157
- <SourcePill :source="detail.source" />
158
- </button>
202
+ <SourcePill :source="detail.source" @click="sourcePreview = detail.source!" />
159
203
  </div>
160
204
 
161
- <SchemaTree :schema="detail.schema" label="Payload schema" />
205
+ <SchemaTree v-if="detailSchema" :schema="detailSchema" label="Payload schema" />
162
206
  </div>
163
207
  </div>
164
208
  <SourceDrawer :source="sourcePreview" @close="sourcePreview = null" />
@@ -24,6 +24,7 @@ import {
24
24
  Flame,
25
25
  CheckCircle2,
26
26
  WifiOff,
27
+ Cigarette,
27
28
  } from "lucide-vue-next";
28
29
  import { useCache } from "@/lib/cache";
29
30
  import { PageHeader, EmptyState } from "@/components";
@@ -234,24 +235,22 @@ const bootSummary = computed(() => {
234
235
  const recs = pluginsBootedRecords.value;
235
236
  if (recs.length === 0) return null;
236
237
  let plugins = 0;
237
- let modules = 0;
238
238
  let totalMs = 0;
239
239
  for (const r of recs) {
240
240
  totalMs += r.payload.durationMs ?? 0;
241
- if (r.payload.kind === "module") modules++;
242
- else plugins++;
241
+ plugins++;
243
242
  }
244
- return { total: recs.length, plugins, modules, totalMs };
243
+ return { total: recs.length, plugins, totalMs };
245
244
  });
246
245
 
247
246
  const compositionStats = computed(() => {
248
247
  if (!cache.value) return null;
249
248
  return {
250
249
  apps: cache.value.apps.length,
251
- modules: cache.value.modules.length,
252
250
  plugins: cache.value.plugins.length,
253
251
  actions: cache.value.actions.length,
254
252
  events: cache.value.events.length,
253
+ sinks: cache.value.sinks?.length ?? 0,
255
254
  };
256
255
  });
257
256
 
@@ -262,20 +261,135 @@ function openPluginHook(name: string): void {
262
261
  function shortTime(ts: string): string {
263
262
  return new Date(ts).toLocaleTimeString(undefined, { hour12: false });
264
263
  }
264
+
265
+ // ─── Smoke ────────────────────────────────────────────────────────────
266
+ //
267
+ // Generate sample dispatches against the live wire so the Stream, Trace,
268
+ // Home metrics, and Run logs panels actually have something to render
269
+ // the first time a user opens Studio. We walk the action manifest, build
270
+ // a best-effort payload from each action's inputSchema (filling required
271
+ // fields with type-appropriate defaults), and POST `/_nwire/dispatch` for
272
+ // each one a few times.
273
+ //
274
+ // All work is fire-and-forget — failures count as "smoke" too (they show
275
+ // up on the failures panel). The button only renders when the cache has
276
+ // actions and the live wire seems reachable.
277
+
278
+ const smokeBusy = ref(false);
279
+ const smokeError = ref<string | null>(null);
280
+ const smokeResult = ref<{ fired: number; failed: number } | null>(null);
281
+
282
+ function sampleFromSchema(schema: unknown): unknown {
283
+ if (!schema || typeof schema !== "object") return {};
284
+ const s = schema as {
285
+ type?: string;
286
+ properties?: Record<string, unknown>;
287
+ required?: string[];
288
+ enum?: unknown[];
289
+ };
290
+ if (s.enum && Array.isArray(s.enum) && s.enum.length > 0) return s.enum[0];
291
+ switch (s.type) {
292
+ case "string":
293
+ return "sample";
294
+ case "number":
295
+ return 1;
296
+ case "boolean":
297
+ return true;
298
+ case "array":
299
+ return [];
300
+ case "object": {
301
+ const out: Record<string, unknown> = {};
302
+ const props = s.properties ?? {};
303
+ const required = new Set(s.required ?? []);
304
+ for (const [key, raw] of Object.entries(props)) {
305
+ // Always include required; otherwise skip — best-effort.
306
+ if (required.has(key)) out[key] = sampleFromSchema(raw);
307
+ }
308
+ return out;
309
+ }
310
+ default:
311
+ return null;
312
+ }
313
+ }
314
+
315
+ async function runSmoke(): Promise<void> {
316
+ if (!cache.value) return;
317
+ smokeBusy.value = true;
318
+ smokeError.value = null;
319
+ smokeResult.value = null;
320
+ let fired = 0;
321
+ let failed = 0;
322
+ try {
323
+ const actions = cache.value.actions;
324
+ if (actions.length === 0) {
325
+ smokeError.value = "No actions in the manifest — nothing to dispatch.";
326
+ return;
327
+ }
328
+ // Fire each action once with sample input; for the first one fire
329
+ // twice so there's at least one repeat in the stream.
330
+ for (let i = 0; i < actions.length; i++) {
331
+ const a = actions[i]!;
332
+ const input = sampleFromSchema(a.inputSchema);
333
+ const repeats = i === 0 ? 2 : 1;
334
+ for (let r = 0; r < repeats; r++) {
335
+ try {
336
+ const res = await fetch("/_nwire/dispatch", {
337
+ method: "POST",
338
+ headers: { "content-type": "application/json" },
339
+ body: JSON.stringify({ handler: a.name, input }),
340
+ });
341
+ if (res.ok) fired++;
342
+ else failed++;
343
+ } catch {
344
+ failed++;
345
+ }
346
+ }
347
+ }
348
+ smokeResult.value = { fired, failed };
349
+ } catch (err) {
350
+ smokeError.value = (err as Error).message;
351
+ } finally {
352
+ smokeBusy.value = false;
353
+ }
354
+ }
265
355
  </script>
266
356
 
267
357
  <template>
268
358
  <div class="p-6 space-y-6" data-testid="home-page">
269
- <PageHeader
270
- title="Home"
271
- :icon="HomeIcon"
272
- icon-color="text-emerald-400"
273
- :subtitle="
274
- hasLiveData
275
- ? 'Live system snapshot — failures, throughput, and what got booted'
276
- : 'Wire not reportingshowing static composition only'
277
- "
278
- />
359
+ <div class="flex items-start justify-between gap-6">
360
+ <PageHeader
361
+ title="Home"
362
+ :icon="HomeIcon"
363
+ icon-color="text-emerald-400"
364
+ :subtitle="
365
+ hasLiveData
366
+ ? 'Live system snapshotfailures, throughput, and what got booted'
367
+ : 'Wire not reporting — showing static composition only'
368
+ "
369
+ />
370
+ <div class="flex flex-col items-end gap-1.5 pt-1" data-testid="home-smoke">
371
+ <button
372
+ type="button"
373
+ class="inline-flex items-center gap-2 rounded-md border border-zinc-700 bg-zinc-900 hover:bg-zinc-800 px-3 py-1.5 text-xs font-medium text-zinc-200 transition-colors disabled:opacity-50"
374
+ :disabled="smokeBusy || !cache || cache.actions.length === 0"
375
+ :title="
376
+ !cache || cache.actions.length === 0
377
+ ? 'No actions in the manifest yet.'
378
+ : 'Fire a sample dispatch against every action so the live panels populate.'
379
+ "
380
+ @click="runSmoke"
381
+ >
382
+ <Cigarette class="w-3.5 h-3.5 text-amber-400" />
383
+ {{ smokeBusy ? "Smoking…" : "Smoke" }}
384
+ </button>
385
+ <div v-if="smokeResult" class="text-[10px] text-zinc-500 tabular-nums">
386
+ fired {{ smokeResult.fired }} · failed {{ smokeResult.failed }}
387
+ </div>
388
+ <div v-if="smokeError" class="text-[10px] text-rose-300 max-w-[200px] text-right">
389
+ {{ smokeError }}
390
+ </div>
391
+ </div>
392
+ </div>
279
393
 
280
394
  <!-- Section 1: Recent failures ─────────────────────────────────── -->
281
395
  <section data-testid="home-failures">
@@ -432,9 +546,9 @@ function shortTime(ts: string): string {
432
546
  >
433
547
  Booted <span class="font-semibold">{{ bootSummary.total }}</span> plugins (<span
434
548
  class="tabular-nums"
435
- >{{ bootSummary.modules }}</span
549
+ >{{ bootSummary.plugins }}</span
436
550
  >
437
- modules + <span class="tabular-nums">{{ bootSummary.plugins }}</span> plugins) in
551
+ plugins recorded) in
438
552
  <span class="tabular-nums">{{ bootSummary.totalMs.toFixed(0) }}</span> ms
439
553
  </div>
440
554
  <div
@@ -443,10 +557,10 @@ function shortTime(ts: string): string {
443
557
  data-testid="home-boot-static"
444
558
  >
445
559
  <span class="tabular-nums">{{ compositionStats.apps }}</span> app(s),
446
- <span class="tabular-nums">{{ compositionStats.modules }}</span> module(s),
447
560
  <span class="tabular-nums">{{ compositionStats.plugins }}</span> plugin(s) ·
448
561
  <span class="tabular-nums">{{ compositionStats.actions }}</span> actions ·
449
- <span class="tabular-nums">{{ compositionStats.events }}</span> events
562
+ <span class="tabular-nums">{{ compositionStats.events }}</span> events ·
563
+ <span class="tabular-nums">{{ compositionStats.sinks }}</span> sinks
450
564
  <div v-if="!hasLiveData" class="text-[11px] text-zinc-500 mt-1">
451
565
  Boot timings appear once the wire is running.
452
566
  </div>
@@ -202,14 +202,9 @@ watch(detail, () => {
202
202
  <div class="flex items-center gap-3 flex-wrap">
203
203
  <h2 class="font-mono text-xl">{{ detail.name }}</h2>
204
204
  <span class="text-[10px] text-zinc-500 font-mono">{{ detail.id }}</span>
205
- <button
206
- v-if="detail.source"
207
- type="button"
208
- class="ml-auto inline-flex items-center"
209
- @click="sourcePreview = detail.source!"
210
- >
211
- <SourcePill :source="detail.source" />
212
- </button>
205
+ <span v-if="detail.source" class="ml-auto inline-flex">
206
+ <SourcePill :source="detail.source" @click="sourcePreview = detail.source!" />
207
+ </span>
213
208
  </div>
214
209
  <div class="text-[11px] text-zinc-500 mt-2 flex gap-4 tabular-nums">
215
210
  <span>{{ detail.chain }} chain step{{ detail.chain === 1 ? "" : "s" }}</span>
@@ -58,7 +58,7 @@ const stats = computed(() => {
58
58
  if (!cache.value) return [];
59
59
  return [
60
60
  { label: "Apps", value: cache.value.apps.length, icon: Network, color: "text-blue-400" },
61
- { label: "Modules", value: cache.value.modules.length, icon: Boxes, color: "text-emerald-400" },
61
+ { label: "Plugins", value: cache.value.plugins.length, icon: Boxes, color: "text-emerald-400" },
62
62
  { label: "Actions", value: cache.value.actions.length, icon: Zap, color: "text-amber-400" },
63
63
  { label: "Events", value: cache.value.events.length, icon: Radio, color: "text-purple-400" },
64
64
  { label: "Actors", value: cache.value.actors.length, icon: Layers, color: "text-pink-400" },
@@ -142,14 +142,16 @@ const stats = computed(() => {
142
142
  >
143
143
  <div class="flex items-center justify-between">
144
144
  <span class="font-medium">{{ app.name }}</span>
145
- <span class="text-xs text-zinc-500 tabular-nums"> {{ app.modules.length }} BCs </span>
145
+ <span class="text-xs text-zinc-500 tabular-nums">
146
+ {{ app.plugins.length }} plugin(s)
147
+ </span>
146
148
  </div>
147
149
  <div v-if="app.description" class="text-sm text-zinc-400 mt-1">
148
150
  {{ app.description }}
149
151
  </div>
150
152
  <div class="mt-2 flex flex-wrap gap-1">
151
- <KindBadge v-for="m in app.modules" :key="m" variant="neutral">
152
- {{ m }}
153
+ <KindBadge v-for="p in app.plugins" :key="p" variant="neutral">
154
+ {{ p }}
153
155
  </KindBadge>
154
156
  </div>
155
157
  </div>
@@ -309,14 +309,9 @@ function openHook(hookName: string): void {
309
309
  {{ detail.kind }}
310
310
  </KindBadge>
311
311
  <span class="text-[10px] text-zinc-500 font-mono">{{ detail.app }}</span>
312
- <button
313
- v-if="detail.source"
314
- type="button"
315
- class="ml-auto inline-flex items-center"
316
- @click="sourcePreview = detail.source!"
317
- >
318
- <SourcePill :source="detail.source" />
319
- </button>
312
+ <span v-if="detail.source" class="ml-auto inline-flex">
313
+ <SourcePill :source="detail.source" @click="sourcePreview = detail.source!" />
314
+ </span>
320
315
  </div>
321
316
  </div>
322
317
 
@@ -0,0 +1,148 @@
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>
@@ -199,8 +199,8 @@ onMounted(() => {
199
199
  <div>{{ p.composition.apps }}</div>
200
200
  </div>
201
201
  <div>
202
- <div class="text-zinc-600 text-[10px] uppercase tracking-wide">modules</div>
203
- <div>{{ p.composition.modules }}</div>
202
+ <div class="text-zinc-600 text-[10px] uppercase tracking-wide">plugins</div>
203
+ <div>{{ p.composition.plugins }}</div>
204
204
  </div>
205
205
  <div>
206
206
  <div class="text-zinc-600 text-[10px] uppercase tracking-wide">actions</div>