@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.
- package/LICENSE +21 -0
- package/README.md +72 -0
- package/components.json +19 -0
- package/index.html +12 -0
- package/package.json +66 -0
- package/src/App.vue +305 -0
- package/src/components/EmptyState.stories.ts +53 -0
- package/src/components/EmptyState.vue +28 -0
- package/src/components/ErrorBoundary.vue +60 -0
- package/src/components/FilterInput.stories.ts +32 -0
- package/src/components/FilterInput.vue +33 -0
- package/src/components/JsonView.stories.ts +38 -0
- package/src/components/JsonView.vue +34 -0
- package/src/components/KindBadge.stories.ts +72 -0
- package/src/components/KindBadge.vue +59 -0
- package/src/components/ListRow.stories.ts +56 -0
- package/src/components/ListRow.vue +48 -0
- package/src/components/MasterDetail.stories.ts +74 -0
- package/src/components/MasterDetail.vue +35 -0
- package/src/components/MonacoViewer.vue +143 -0
- package/src/components/PageHeader.stories.ts +45 -0
- package/src/components/PageHeader.vue +46 -0
- package/src/components/SchemaNode.vue +208 -0
- package/src/components/SchemaTree.vue +65 -0
- package/src/components/SourceDrawer.vue +136 -0
- package/src/components/SourcePill.vue +103 -0
- package/src/components/__tests__/EmptyState.test.ts +28 -0
- package/src/components/__tests__/ErrorBoundary.test.ts +52 -0
- package/src/components/__tests__/FilterInput.test.ts +38 -0
- package/src/components/__tests__/JsonView.test.ts +33 -0
- package/src/components/__tests__/KindBadge.test.ts +39 -0
- package/src/components/__tests__/ListRow.test.ts +39 -0
- package/src/components/__tests__/MasterDetail.test.ts +40 -0
- package/src/components/__tests__/PageHeader.test.ts +42 -0
- package/src/components/index.ts +17 -0
- package/src/components/ui/badge/Badge.vue +17 -0
- package/src/components/ui/badge/index.ts +25 -0
- package/src/components/ui/button/Button.vue +28 -0
- package/src/components/ui/button/index.ts +34 -0
- package/src/components/ui/card/Card.vue +14 -0
- package/src/components/ui/card/CardContent.vue +14 -0
- package/src/components/ui/card/CardDescription.vue +14 -0
- package/src/components/ui/card/CardFooter.vue +14 -0
- package/src/components/ui/card/CardHeader.vue +14 -0
- package/src/components/ui/card/CardTitle.vue +14 -0
- package/src/components/ui/card/index.ts +6 -0
- package/src/components/ui/dialog/Dialog.vue +15 -0
- package/src/components/ui/dialog/DialogClose.vue +12 -0
- package/src/components/ui/dialog/DialogContent.vue +47 -0
- package/src/components/ui/dialog/DialogDescription.vue +22 -0
- package/src/components/ui/dialog/DialogFooter.vue +12 -0
- package/src/components/ui/dialog/DialogHeader.vue +14 -0
- package/src/components/ui/dialog/DialogScrollContent.vue +60 -0
- package/src/components/ui/dialog/DialogTitle.vue +22 -0
- package/src/components/ui/dialog/DialogTrigger.vue +12 -0
- package/src/components/ui/dialog/index.ts +9 -0
- package/src/components/ui/input/Input.vue +32 -0
- package/src/components/ui/input/index.ts +1 -0
- package/src/components/ui/scroll-area/ScrollArea.vue +22 -0
- package/src/components/ui/scroll-area/ScrollBar.vue +32 -0
- package/src/components/ui/scroll-area/index.ts +2 -0
- package/src/components/ui/separator/Separator.vue +27 -0
- package/src/components/ui/separator/index.ts +1 -0
- package/src/components/ui/skeleton/Skeleton.vue +14 -0
- package/src/components/ui/skeleton/index.ts +1 -0
- package/src/components/ui/tabs/Tabs.vue +15 -0
- package/src/components/ui/tabs/TabsContent.vue +25 -0
- package/src/components/ui/tabs/TabsList.vue +25 -0
- package/src/components/ui/tabs/TabsTrigger.vue +29 -0
- package/src/components/ui/tabs/index.ts +4 -0
- package/src/components/ui/tooltip/Tooltip.vue +15 -0
- package/src/components/ui/tooltip/TooltipContent.vue +40 -0
- package/src/components/ui/tooltip/TooltipProvider.vue +12 -0
- package/src/components/ui/tooltip/TooltipTrigger.vue +12 -0
- package/src/components/ui/tooltip/index.ts +4 -0
- package/src/composables/useCopy.ts +31 -0
- package/src/lib/__tests__/normalize-cache.test.ts +104 -0
- package/src/lib/cache.ts +334 -0
- package/src/lib/normalize-cache.ts +92 -0
- package/src/lib/project-catalog.ts +125 -0
- package/src/lib/utils.ts +6 -0
- package/src/main.ts +112 -0
- package/src/pages/Actions.vue +180 -0
- package/src/pages/Commands.vue +262 -0
- package/src/pages/Dispatch.vue +431 -0
- package/src/pages/Events.vue +166 -0
- package/src/pages/Home.stories.ts +47 -0
- package/src/pages/Home.vue +485 -0
- package/src/pages/Hooks.vue +297 -0
- package/src/pages/Live.vue +249 -0
- package/src/pages/Modules.vue +174 -0
- package/src/pages/Overview.vue +159 -0
- package/src/pages/Plugins.stories.ts +44 -0
- package/src/pages/Plugins.vue +403 -0
- package/src/pages/Projects.vue +272 -0
- package/src/pages/Run.vue +479 -0
- package/src/pages/Topology.vue +164 -0
- package/src/pages/Trace.vue +511 -0
- package/src/pages/TraceNode.vue +166 -0
- package/src/pages/Workflows.vue +191 -0
- package/src/pages/__tests__/Actions.test.ts +98 -0
- package/src/pages/__tests__/Home.test.ts +98 -0
- package/src/pages/__tests__/Hooks.test.ts +119 -0
- package/src/pages/__tests__/Plugins.test.ts +80 -0
- package/src/style.css +40 -0
- package/tsconfig.json +20 -0
- 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 = {};
|