@nwire/studio 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +72 -0
  3. package/components.json +19 -0
  4. package/index.html +12 -0
  5. package/package.json +66 -0
  6. package/src/App.vue +305 -0
  7. package/src/components/EmptyState.stories.ts +53 -0
  8. package/src/components/EmptyState.vue +28 -0
  9. package/src/components/ErrorBoundary.vue +60 -0
  10. package/src/components/FilterInput.stories.ts +32 -0
  11. package/src/components/FilterInput.vue +33 -0
  12. package/src/components/JsonView.stories.ts +38 -0
  13. package/src/components/JsonView.vue +34 -0
  14. package/src/components/KindBadge.stories.ts +72 -0
  15. package/src/components/KindBadge.vue +59 -0
  16. package/src/components/ListRow.stories.ts +56 -0
  17. package/src/components/ListRow.vue +48 -0
  18. package/src/components/MasterDetail.stories.ts +74 -0
  19. package/src/components/MasterDetail.vue +35 -0
  20. package/src/components/MonacoViewer.vue +143 -0
  21. package/src/components/PageHeader.stories.ts +45 -0
  22. package/src/components/PageHeader.vue +46 -0
  23. package/src/components/SchemaNode.vue +208 -0
  24. package/src/components/SchemaTree.vue +65 -0
  25. package/src/components/SourceDrawer.vue +136 -0
  26. package/src/components/SourcePill.vue +103 -0
  27. package/src/components/__tests__/EmptyState.test.ts +28 -0
  28. package/src/components/__tests__/ErrorBoundary.test.ts +52 -0
  29. package/src/components/__tests__/FilterInput.test.ts +38 -0
  30. package/src/components/__tests__/JsonView.test.ts +33 -0
  31. package/src/components/__tests__/KindBadge.test.ts +39 -0
  32. package/src/components/__tests__/ListRow.test.ts +39 -0
  33. package/src/components/__tests__/MasterDetail.test.ts +40 -0
  34. package/src/components/__tests__/PageHeader.test.ts +42 -0
  35. package/src/components/index.ts +17 -0
  36. package/src/components/ui/badge/Badge.vue +17 -0
  37. package/src/components/ui/badge/index.ts +25 -0
  38. package/src/components/ui/button/Button.vue +28 -0
  39. package/src/components/ui/button/index.ts +34 -0
  40. package/src/components/ui/card/Card.vue +14 -0
  41. package/src/components/ui/card/CardContent.vue +14 -0
  42. package/src/components/ui/card/CardDescription.vue +14 -0
  43. package/src/components/ui/card/CardFooter.vue +14 -0
  44. package/src/components/ui/card/CardHeader.vue +14 -0
  45. package/src/components/ui/card/CardTitle.vue +14 -0
  46. package/src/components/ui/card/index.ts +6 -0
  47. package/src/components/ui/dialog/Dialog.vue +15 -0
  48. package/src/components/ui/dialog/DialogClose.vue +12 -0
  49. package/src/components/ui/dialog/DialogContent.vue +47 -0
  50. package/src/components/ui/dialog/DialogDescription.vue +22 -0
  51. package/src/components/ui/dialog/DialogFooter.vue +12 -0
  52. package/src/components/ui/dialog/DialogHeader.vue +14 -0
  53. package/src/components/ui/dialog/DialogScrollContent.vue +60 -0
  54. package/src/components/ui/dialog/DialogTitle.vue +22 -0
  55. package/src/components/ui/dialog/DialogTrigger.vue +12 -0
  56. package/src/components/ui/dialog/index.ts +9 -0
  57. package/src/components/ui/input/Input.vue +32 -0
  58. package/src/components/ui/input/index.ts +1 -0
  59. package/src/components/ui/scroll-area/ScrollArea.vue +22 -0
  60. package/src/components/ui/scroll-area/ScrollBar.vue +32 -0
  61. package/src/components/ui/scroll-area/index.ts +2 -0
  62. package/src/components/ui/separator/Separator.vue +27 -0
  63. package/src/components/ui/separator/index.ts +1 -0
  64. package/src/components/ui/skeleton/Skeleton.vue +14 -0
  65. package/src/components/ui/skeleton/index.ts +1 -0
  66. package/src/components/ui/tabs/Tabs.vue +15 -0
  67. package/src/components/ui/tabs/TabsContent.vue +25 -0
  68. package/src/components/ui/tabs/TabsList.vue +25 -0
  69. package/src/components/ui/tabs/TabsTrigger.vue +29 -0
  70. package/src/components/ui/tabs/index.ts +4 -0
  71. package/src/components/ui/tooltip/Tooltip.vue +15 -0
  72. package/src/components/ui/tooltip/TooltipContent.vue +40 -0
  73. package/src/components/ui/tooltip/TooltipProvider.vue +12 -0
  74. package/src/components/ui/tooltip/TooltipTrigger.vue +12 -0
  75. package/src/components/ui/tooltip/index.ts +4 -0
  76. package/src/composables/useCopy.ts +31 -0
  77. package/src/lib/__tests__/normalize-cache.test.ts +104 -0
  78. package/src/lib/cache.ts +334 -0
  79. package/src/lib/normalize-cache.ts +92 -0
  80. package/src/lib/project-catalog.ts +125 -0
  81. package/src/lib/utils.ts +6 -0
  82. package/src/main.ts +112 -0
  83. package/src/pages/Actions.vue +180 -0
  84. package/src/pages/Commands.vue +262 -0
  85. package/src/pages/Dispatch.vue +431 -0
  86. package/src/pages/Events.vue +166 -0
  87. package/src/pages/Home.stories.ts +47 -0
  88. package/src/pages/Home.vue +485 -0
  89. package/src/pages/Hooks.vue +297 -0
  90. package/src/pages/Live.vue +249 -0
  91. package/src/pages/Modules.vue +174 -0
  92. package/src/pages/Overview.vue +159 -0
  93. package/src/pages/Plugins.stories.ts +44 -0
  94. package/src/pages/Plugins.vue +403 -0
  95. package/src/pages/Projects.vue +272 -0
  96. package/src/pages/Run.vue +479 -0
  97. package/src/pages/Topology.vue +164 -0
  98. package/src/pages/Trace.vue +511 -0
  99. package/src/pages/TraceNode.vue +166 -0
  100. package/src/pages/Workflows.vue +191 -0
  101. package/src/pages/__tests__/Actions.test.ts +98 -0
  102. package/src/pages/__tests__/Home.test.ts +98 -0
  103. package/src/pages/__tests__/Hooks.test.ts +119 -0
  104. package/src/pages/__tests__/Plugins.test.ts +80 -0
  105. package/src/style.css +40 -0
  106. package/tsconfig.json +20 -0
  107. package/vite.config.ts +892 -0
@@ -0,0 +1,431 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from "vue";
3
+ import { useCache } from "@/lib/cache";
4
+ import { Zap, Search, Send, CheckCircle2, XCircle } from "lucide-vue-next";
5
+
6
+ const { cache } = useCache();
7
+ const filter = ref("");
8
+ const selectedName = ref<string | null>(null);
9
+ const input = ref<string>("{}");
10
+ const tenant = ref("");
11
+ const userId = ref("");
12
+ /**
13
+ * Two ways to author the payload:
14
+ *
15
+ * - "form" — inline inputs derived from the OpenAPI schema (strings,
16
+ * numbers, booleans, enums). Best for happy-path dispatches.
17
+ * - "json" — raw textarea. Fallback for nested objects/arrays or when
18
+ * the operator wants full control.
19
+ *
20
+ * The two views read+write the same `input` JSON string so toggling
21
+ * keeps the value in sync.
22
+ */
23
+ const inputMode = ref<"form" | "json">("form");
24
+ const result = ref<
25
+ | { ok: true; envelope: { messageId: string; correlationId: string }; result: unknown }
26
+ | { ok: false; error: string }
27
+ | null
28
+ >(null);
29
+ const busy = ref(false);
30
+
31
+ const filtered = computed(() => {
32
+ if (!cache.value) return [];
33
+ const q = filter.value.toLowerCase();
34
+ return cache.value.actions.filter(
35
+ (a) =>
36
+ !q ||
37
+ a.name.toLowerCase().includes(q) ||
38
+ a.module.toLowerCase().includes(q) ||
39
+ (a.description ?? "").toLowerCase().includes(q),
40
+ );
41
+ });
42
+
43
+ const selected = computed(() => filtered.value.find((a) => a.name === selectedName.value) ?? null);
44
+
45
+ // Schema field list for the visible "Expected input" panel. Reads
46
+ // JSON-Schema-shape properties out of the cached action and produces a
47
+ // flat row per top-level field: name, type, required flag, default,
48
+ // enum options. Nested objects show their type as "object" — the user
49
+ // edits them in the JSON textarea.
50
+ interface SchemaField {
51
+ name: string;
52
+ type: string;
53
+ required: boolean;
54
+ default?: unknown;
55
+ enum?: string[];
56
+ }
57
+ const schemaFields = computed<SchemaField[]>(() => {
58
+ if (!selected.value) return [];
59
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
60
+ const schema = selected.value.schema as any;
61
+ if (!schema?.properties) return [];
62
+ const required = new Set<string>(Array.isArray(schema.required) ? schema.required : []);
63
+ const out: SchemaField[] = [];
64
+ for (const [name, raw] of Object.entries(schema.properties)) {
65
+ const p = raw as {
66
+ type?: string | string[];
67
+ default?: unknown;
68
+ enum?: unknown[];
69
+ format?: string;
70
+ };
71
+ const t = Array.isArray(p.type) ? p.type.join(" | ") : (p.type ?? "any");
72
+ const field: SchemaField = { name, type: t, required: required.has(name) };
73
+ if (p.default !== undefined) field.default = p.default;
74
+ if (Array.isArray(p.enum)) field.enum = p.enum.map(String);
75
+ out.push(field);
76
+ }
77
+ return out;
78
+ });
79
+
80
+ // When a different action is picked, seed a sensible input scaffold from
81
+ // the schema's required fields.
82
+ watch(selectedName, () => {
83
+ result.value = null;
84
+ if (!selected.value) return;
85
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
86
+ const schema = selected.value.schema as any;
87
+ const scaffold: Record<string, unknown> = {};
88
+ if (schema?.properties) {
89
+ for (const [key, prop] of Object.entries(schema.properties)) {
90
+ const p = prop as { type?: string | string[]; default?: unknown };
91
+ if (p.default !== undefined) {
92
+ scaffold[key] = p.default;
93
+ continue;
94
+ }
95
+ const t = Array.isArray(p.type) ? p.type[0] : p.type;
96
+ switch (t) {
97
+ case "string":
98
+ scaffold[key] = "";
99
+ break;
100
+ case "number":
101
+ scaffold[key] = 0;
102
+ break;
103
+ case "boolean":
104
+ scaffold[key] = false;
105
+ break;
106
+ case "array":
107
+ scaffold[key] = [];
108
+ break;
109
+ case "object":
110
+ scaffold[key] = {};
111
+ break;
112
+ default:
113
+ scaffold[key] = null;
114
+ }
115
+ }
116
+ }
117
+ input.value = JSON.stringify(scaffold, null, 2);
118
+ });
119
+
120
+ /**
121
+ * Inline form mode reads + writes individual fields of the parsed input
122
+ * JSON. We work off a single source of truth (`input` JSON string) and
123
+ * project per-field getters/setters so the form view stays consistent
124
+ * with the textarea when the operator toggles between them.
125
+ */
126
+ function parsedInput(): Record<string, unknown> {
127
+ try {
128
+ const parsed = JSON.parse(input.value);
129
+ return typeof parsed === "object" && parsed !== null ? (parsed as Record<string, unknown>) : {};
130
+ } catch {
131
+ return {};
132
+ }
133
+ }
134
+
135
+ function setField(name: string, value: unknown): void {
136
+ const obj = parsedInput();
137
+ obj[name] = value;
138
+ input.value = JSON.stringify(obj, null, 2);
139
+ }
140
+
141
+ function getField(name: string): unknown {
142
+ return parsedInput()[name];
143
+ }
144
+
145
+ /**
146
+ * Fields the inline form can render. Anything else (nested objects,
147
+ * arrays of objects, unions) falls back to the JSON textarea.
148
+ */
149
+ function isInlineRenderable(field: SchemaField): boolean {
150
+ const t = field.type.split(" | ")[0];
151
+ return t === "string" || t === "number" || t === "integer" || t === "boolean";
152
+ }
153
+
154
+ const allFieldsInlineRenderable = computed(
155
+ () => schemaFields.value.length > 0 && schemaFields.value.every(isInlineRenderable),
156
+ );
157
+
158
+ async function dispatch() {
159
+ if (!selected.value) return;
160
+ busy.value = true;
161
+ result.value = null;
162
+ try {
163
+ const parsed = JSON.parse(input.value);
164
+ const res = await fetch("/_nwire/dispatch", {
165
+ method: "POST",
166
+ headers: { "Content-Type": "application/json" },
167
+ body: JSON.stringify({
168
+ action: selected.value.name,
169
+ input: parsed,
170
+ tenant: tenant.value || undefined,
171
+ userId: userId.value || undefined,
172
+ }),
173
+ });
174
+ result.value = (await res.json()) as typeof result.value;
175
+ } catch (err) {
176
+ result.value = { ok: false, error: (err as Error).message };
177
+ } finally {
178
+ busy.value = false;
179
+ }
180
+ }
181
+ </script>
182
+
183
+ <template>
184
+ <div v-if="cache" class="h-full flex">
185
+ <!-- Action picker -->
186
+ <div class="w-2/5 border-r border-zinc-800 flex flex-col">
187
+ <div class="border-b border-zinc-800 px-4 py-3">
188
+ <h1 class="text-lg font-semibold tracking-tight">Dispatch</h1>
189
+ <div class="relative mt-2">
190
+ <Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-zinc-500" />
191
+ <input
192
+ v-model="filter"
193
+ placeholder="pick an action…"
194
+ 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"
195
+ />
196
+ </div>
197
+ </div>
198
+ <div class="flex-1 overflow-auto">
199
+ <button
200
+ v-for="a in filtered"
201
+ :key="`${a.app}::${a.name}`"
202
+ class="w-full text-left px-4 py-2.5 border-b border-zinc-900 hover:bg-zinc-900/50 transition-colors"
203
+ :class="{ 'bg-zinc-900': selectedName === a.name }"
204
+ @click="selectedName = a.name"
205
+ >
206
+ <div class="flex items-center justify-between">
207
+ <div class="flex items-center gap-2 min-w-0">
208
+ <Zap class="w-3 h-3 text-amber-400 shrink-0" />
209
+ <span class="font-mono text-sm truncate">{{ a.name }}</span>
210
+ </div>
211
+ <span class="text-[10px] text-zinc-500">{{ a.app }}</span>
212
+ </div>
213
+ <div v-if="a.description" class="text-[10px] text-zinc-500 ml-5 mt-0.5 line-clamp-1">
214
+ {{ a.description }}
215
+ </div>
216
+ </button>
217
+ </div>
218
+ </div>
219
+
220
+ <!-- Form + result -->
221
+ <div class="flex-1 overflow-auto">
222
+ <div v-if="!selected" class="p-6 text-zinc-500 text-sm">Pick an action to dispatch.</div>
223
+ <div v-else class="p-6 space-y-5">
224
+ <div>
225
+ <div class="text-[10px] uppercase tracking-wide text-zinc-500">
226
+ {{ selected.app }} · {{ selected.module }}
227
+ </div>
228
+ <h2 class="font-mono text-xl mt-1">{{ selected.name }}</h2>
229
+ <p v-if="selected.description" class="text-sm text-zinc-400 mt-2 max-w-2xl">
230
+ {{ selected.description }}
231
+ </p>
232
+ </div>
233
+
234
+ <div class="grid grid-cols-2 gap-3">
235
+ <div>
236
+ <label class="text-[10px] uppercase tracking-wide text-zinc-500"
237
+ >Tenant (optional)</label
238
+ >
239
+ <input
240
+ v-model="tenant"
241
+ placeholder="school-tlv"
242
+ class="w-full mt-1 bg-zinc-900 border border-zinc-800 rounded px-2 py-1.5 text-sm focus:outline-none focus:border-zinc-600"
243
+ />
244
+ </div>
245
+ <div>
246
+ <label class="text-[10px] uppercase tracking-wide text-zinc-500"
247
+ >User id (optional)</label
248
+ >
249
+ <input
250
+ v-model="userId"
251
+ placeholder="student-avi-9"
252
+ class="w-full mt-1 bg-zinc-900 border border-zinc-800 rounded px-2 py-1.5 text-sm focus:outline-none focus:border-zinc-600"
253
+ />
254
+ </div>
255
+ </div>
256
+
257
+ <div v-if="schemaFields.length > 0">
258
+ <label class="text-[10px] uppercase tracking-wide text-zinc-500 mb-1 block">
259
+ Expected input schema
260
+ </label>
261
+ <div
262
+ class="rounded border border-zinc-800 bg-zinc-950/50 divide-y divide-zinc-800"
263
+ data-testid="schema-panel"
264
+ >
265
+ <div
266
+ v-for="f in schemaFields"
267
+ :key="f.name"
268
+ class="px-3 py-2 flex items-center justify-between gap-3 text-xs"
269
+ >
270
+ <div class="flex items-center gap-2 min-w-0">
271
+ <span class="font-mono text-zinc-200">{{ f.name }}</span>
272
+ <span
273
+ v-if="f.required"
274
+ class="text-[9px] uppercase tracking-wider text-amber-400"
275
+ title="required"
276
+ >req</span
277
+ >
278
+ <span v-else class="text-[9px] uppercase tracking-wider text-zinc-600">opt</span>
279
+ </div>
280
+ <div class="flex items-center gap-2 text-zinc-500 shrink-0">
281
+ <code class="text-[10px] bg-zinc-900 px-1.5 py-0.5 rounded">{{ f.type }}</code>
282
+ <span v-if="f.default !== undefined" class="text-[10px]">
283
+ = <code class="text-zinc-400">{{ JSON.stringify(f.default) }}</code>
284
+ </span>
285
+ <span v-if="f.enum" class="text-[10px]">
286
+ : <code class="text-zinc-400">{{ f.enum.join(" | ") }}</code>
287
+ </span>
288
+ </div>
289
+ </div>
290
+ </div>
291
+ </div>
292
+
293
+ <div>
294
+ <label
295
+ class="text-[10px] uppercase tracking-wide text-zinc-500 flex items-center justify-between mb-1"
296
+ >
297
+ <span>Input</span>
298
+ <span class="flex items-center gap-2">
299
+ <button
300
+ v-if="allFieldsInlineRenderable"
301
+ class="text-[10px] px-2 py-0.5 rounded border border-zinc-800 normal-case tracking-normal"
302
+ :class="{
303
+ 'bg-zinc-800 text-zinc-100': inputMode === 'form',
304
+ 'text-zinc-500': inputMode !== 'form',
305
+ }"
306
+ @click="inputMode = 'form'"
307
+ data-testid="dispatch-mode-form"
308
+ >
309
+ Form
310
+ </button>
311
+ <button
312
+ class="text-[10px] px-2 py-0.5 rounded border border-zinc-800 normal-case tracking-normal"
313
+ :class="{
314
+ 'bg-zinc-800 text-zinc-100': inputMode === 'json',
315
+ 'text-zinc-500': inputMode !== 'json',
316
+ }"
317
+ @click="inputMode = 'json'"
318
+ data-testid="dispatch-mode-json"
319
+ >
320
+ JSON
321
+ </button>
322
+ </span>
323
+ </label>
324
+ <!-- Form view — one input per schema field, primitive types only. -->
325
+ <div
326
+ v-if="inputMode === 'form' && allFieldsInlineRenderable"
327
+ class="space-y-2 rounded border border-zinc-800 bg-zinc-950/50 p-3"
328
+ data-testid="dispatch-form"
329
+ >
330
+ <div v-for="f in schemaFields" :key="f.name" class="flex flex-col gap-1">
331
+ <label
332
+ class="text-[10px] uppercase tracking-wide text-zinc-500 flex items-center gap-2"
333
+ >
334
+ <span class="font-mono text-zinc-300 normal-case tracking-normal">{{
335
+ f.name
336
+ }}</span>
337
+ <span v-if="f.required" class="text-[9px] uppercase tracking-wider text-amber-400"
338
+ >req</span
339
+ >
340
+ </label>
341
+ <!-- enum → select -->
342
+ <select
343
+ v-if="f.enum"
344
+ class="bg-zinc-900 border border-zinc-800 rounded px-2 py-1.5 text-sm focus:outline-none focus:border-zinc-600"
345
+ :value="getField(f.name)"
346
+ @input="setField(f.name, ($event.target as HTMLSelectElement).value)"
347
+ >
348
+ <option v-for="e in f.enum" :key="e" :value="e">{{ e }}</option>
349
+ </select>
350
+ <!-- boolean → checkbox -->
351
+ <label
352
+ v-else-if="f.type.startsWith('boolean')"
353
+ class="flex items-center gap-2 text-sm"
354
+ >
355
+ <input
356
+ type="checkbox"
357
+ :checked="Boolean(getField(f.name))"
358
+ @change="setField(f.name, ($event.target as HTMLInputElement).checked)"
359
+ />
360
+ <span class="text-zinc-400">{{ getField(f.name) ? "true" : "false" }}</span>
361
+ </label>
362
+ <!-- number/integer → number input -->
363
+ <input
364
+ v-else-if="f.type.startsWith('number') || f.type.startsWith('integer')"
365
+ type="number"
366
+ class="bg-zinc-900 border border-zinc-800 rounded px-2 py-1.5 text-sm focus:outline-none focus:border-zinc-600"
367
+ :value="getField(f.name) as number"
368
+ @input="setField(f.name, Number(($event.target as HTMLInputElement).value))"
369
+ />
370
+ <!-- string → text input -->
371
+ <input
372
+ v-else
373
+ type="text"
374
+ class="bg-zinc-900 border border-zinc-800 rounded px-2 py-1.5 text-sm focus:outline-none focus:border-zinc-600"
375
+ :value="getField(f.name) as string"
376
+ @input="setField(f.name, ($event.target as HTMLInputElement).value)"
377
+ />
378
+ </div>
379
+ </div>
380
+ <!-- JSON view — raw textarea fallback for nested/complex shapes. -->
381
+ <textarea
382
+ v-else
383
+ v-model="input"
384
+ rows="10"
385
+ spellcheck="false"
386
+ class="w-full bg-zinc-950 border border-zinc-800 rounded p-3 font-mono text-xs focus:outline-none focus:border-zinc-600"
387
+ ></textarea>
388
+ </div>
389
+
390
+ <button
391
+ class="px-4 py-2 rounded bg-emerald-700 hover:bg-emerald-600 disabled:opacity-50 text-sm font-medium flex items-center gap-2"
392
+ :disabled="busy"
393
+ @click="dispatch"
394
+ >
395
+ <Send class="w-4 h-4" />
396
+ {{ busy ? "Dispatching…" : "Dispatch" }}
397
+ </button>
398
+
399
+ <div v-if="result">
400
+ <div
401
+ v-if="result.ok"
402
+ class="rounded border border-emerald-900 bg-emerald-950/30 p-3 space-y-2"
403
+ >
404
+ <div class="flex items-center gap-2 text-emerald-300">
405
+ <CheckCircle2 class="w-4 h-4" />
406
+ <span class="font-medium">Accepted</span>
407
+ </div>
408
+ <div class="text-[10px] font-mono text-zinc-400">
409
+ msg {{ result.envelope.messageId }}
410
+ <br />
411
+ corr {{ result.envelope.correlationId }}
412
+ </div>
413
+ <pre class="text-[11px] bg-zinc-950 border border-zinc-800 rounded p-2 overflow-auto">{{
414
+ JSON.stringify(result.result, null, 2)
415
+ }}</pre>
416
+ </div>
417
+ <div
418
+ v-else
419
+ class="rounded border border-rose-900 bg-rose-950/30 p-3 flex items-start gap-2"
420
+ >
421
+ <XCircle class="w-4 h-4 text-rose-400 mt-0.5" />
422
+ <div>
423
+ <div class="font-medium text-rose-300">Rejected</div>
424
+ <div class="text-sm text-rose-200 mt-1 font-mono">{{ result.error }}</div>
425
+ </div>
426
+ </div>
427
+ </div>
428
+ </div>
429
+ </div>
430
+ </div>
431
+ </template>
@@ -0,0 +1,166 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, ref, watch } from "vue";
3
+ import { useRoute, useRouter } from "vue-router";
4
+ import { useCache } from "@/lib/cache";
5
+ import { Radio, Search, Lock, Globe, ArrowRight } from "lucide-vue-next";
6
+ import { SchemaTree, SourcePill, SourceDrawer } from "@/components";
7
+
8
+ const route = useRoute();
9
+ const router = useRouter();
10
+ const { cache } = useCache();
11
+ const filter = ref("");
12
+ const selected = ref<string | null>(null);
13
+ const sourcePreview = ref<{ file: string; line: number; column?: number } | null>(null);
14
+
15
+ function applyQueryPreselect(): void {
16
+ const name = route.query.name;
17
+ if (typeof name === "string" && name.length > 0) {
18
+ selected.value = name;
19
+ }
20
+ }
21
+
22
+ onMounted(applyQueryPreselect);
23
+ watch(() => route.query.name, applyQueryPreselect);
24
+
25
+ function openModule(name: string): void {
26
+ void router.push({ path: "/modules", query: { name } });
27
+ }
28
+
29
+ const filtered = computed(() => {
30
+ if (!cache.value) return [];
31
+ const q = filter.value.toLowerCase();
32
+ return cache.value.events.filter(
33
+ (e) =>
34
+ !q ||
35
+ e.name.toLowerCase().includes(q) ||
36
+ (e.description ?? "").toLowerCase().includes(q) ||
37
+ e.module.toLowerCase().includes(q),
38
+ );
39
+ });
40
+
41
+ const detail = computed(() => filtered.value.find((e) => e.name === selected.value) ?? null);
42
+
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);
46
+ });
47
+ </script>
48
+
49
+ <template>
50
+ <div v-if="cache" class="h-full flex">
51
+ <div class="w-2/5 border-r border-zinc-800 flex flex-col">
52
+ <div class="border-b border-zinc-800 px-4 py-3">
53
+ <h1 class="text-lg font-semibold tracking-tight">Events</h1>
54
+ <div class="relative mt-2">
55
+ <Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-zinc-500" />
56
+ <input
57
+ v-model="filter"
58
+ placeholder="filter…"
59
+ 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"
60
+ />
61
+ </div>
62
+ <div class="text-[10px] text-zinc-500 mt-1">
63
+ {{ filtered.length }} / {{ cache.events.length }}
64
+ </div>
65
+ </div>
66
+ <div class="flex-1 overflow-auto">
67
+ <button
68
+ v-for="e in filtered"
69
+ :key="`${e.app}::${e.name}`"
70
+ class="w-full text-left px-4 py-2.5 border-b border-zinc-900 hover:bg-zinc-900/50 transition-colors"
71
+ :class="{ 'bg-zinc-900': selected === e.name }"
72
+ @click="selected = e.name"
73
+ >
74
+ <div class="flex items-center justify-between">
75
+ <div class="flex items-center gap-2 min-w-0">
76
+ <Radio class="w-3 h-3 text-purple-400 shrink-0" />
77
+ <span class="font-mono text-sm truncate">{{ e.name }}</span>
78
+ </div>
79
+ <component
80
+ :is="(e.public ?? e.visibility === 'public') ? Globe : Lock"
81
+ class="w-3 h-3 shrink-0"
82
+ :class="
83
+ (e.public ?? e.visibility === 'public') ? 'text-emerald-400' : 'text-zinc-500'
84
+ "
85
+ />
86
+ </div>
87
+ <div v-if="e.description" class="text-xs text-zinc-500 mt-1 line-clamp-2 ml-5">
88
+ {{ e.description }}
89
+ </div>
90
+ </button>
91
+ </div>
92
+ </div>
93
+
94
+ <div class="flex-1 overflow-auto">
95
+ <div v-if="!detail" class="p-6 text-zinc-500 text-sm">Select an event to view its flow.</div>
96
+ <div v-else class="p-6 space-y-5">
97
+ <div>
98
+ <div class="text-[10px] uppercase tracking-wide text-zinc-500 flex items-center gap-2">
99
+ <span>{{ detail.app }} · {{ detail.module }}</span>
100
+ <span
101
+ class="px-1.5 py-0.5 rounded text-[10px] uppercase"
102
+ :class="
103
+ (detail.public ?? detail.visibility === 'public')
104
+ ? 'bg-emerald-950/50 border border-emerald-900 text-emerald-300'
105
+ : 'bg-zinc-950/50 border border-zinc-800 text-zinc-400'
106
+ "
107
+ >
108
+ {{ (detail.public ?? detail.visibility === "public") ? "public" : "private" }}
109
+ </span>
110
+ </div>
111
+ <h2 class="font-mono text-xl mt-1">{{ detail.name }}</h2>
112
+ <p v-if="detail.description" class="text-sm text-zinc-400 mt-2 max-w-2xl">
113
+ {{ detail.description }}
114
+ </p>
115
+ </div>
116
+
117
+ <div v-if="detailEdge">
118
+ <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>
130
+ </div>
131
+ <div class="flex items-start gap-2">
132
+ <span class="text-zinc-500 mt-0.5">Consumed by:</span>
133
+ <div class="flex flex-wrap gap-1.5">
134
+ <button
135
+ v-for="(c, i) in detailEdge.consumers"
136
+ :key="i"
137
+ type="button"
138
+ 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)"
141
+ >
142
+ <ArrowRight class="w-2.5 h-2.5 text-zinc-500" />
143
+ {{ c.app }}/{{ c.module }}
144
+ <span class="text-[9px] uppercase text-zinc-500">{{ c.via }}</span>
145
+ </button>
146
+ </div>
147
+ </div>
148
+ </div>
149
+ </div>
150
+
151
+ <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>
159
+ </div>
160
+
161
+ <SchemaTree :schema="detail.schema" label="Payload schema" />
162
+ </div>
163
+ </div>
164
+ <SourceDrawer :source="sourcePreview" @close="sourcePreview = null" />
165
+ </div>
166
+ </template>
@@ -0,0 +1,47 @@
1
+ import type { Meta, StoryObj } from "@storybook/vue3-vite";
2
+ import { createRouter, createMemoryHistory } from "vue-router";
3
+ import Home from "./Home.vue";
4
+
5
+ /**
6
+ * Storybook for the Home dashboard. We give it an in-memory router so
7
+ * the "→ trace" / plugin-row links render without exploding, and let the
8
+ * page try to fetch /__nwire/manifest.json + /_nwire/telemetry/* — in
9
+ * Storybook those fail and the component degrades to its "no live data"
10
+ * states, which is exactly what we want to document.
11
+ */
12
+ const router = createRouter({
13
+ history: createMemoryHistory(),
14
+ routes: [
15
+ { path: "/", name: "home", component: Home },
16
+ { path: "/trace", name: "trace", component: { template: "<div/>" } },
17
+ { path: "/hooks", name: "hooks", component: { template: "<div/>" } },
18
+ ],
19
+ });
20
+
21
+ const meta: Meta<typeof Home> = {
22
+ title: "Pages/Home",
23
+ component: Home,
24
+ decorators: [
25
+ (story) => ({
26
+ components: { story },
27
+ template: '<div class="h-screen bg-zinc-950 text-zinc-100"><story /></div>',
28
+ }),
29
+ ],
30
+ parameters: {
31
+ layout: "fullscreen",
32
+ vueRouter: { router },
33
+ },
34
+ render: () => ({
35
+ components: { Home },
36
+ setup() {
37
+ return {};
38
+ },
39
+ template: "<Home />",
40
+ }),
41
+ };
42
+ export default meta;
43
+
44
+ type Story = StoryObj<typeof Home>;
45
+
46
+ /** Default: the wire isn't running — all three sections degrade gracefully. */
47
+ export const NoLiveData: Story = {};