@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
@@ -0,0 +1,186 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Operate › Commands — fire `nwire` CLI commands via the supervisor and tail
4
+ * their output. Same `/__nwire/run/*` surface as Run; the list comes from
5
+ * `/__nwire/run/commands` and processes are narrowed to the ones this panel
6
+ * launched (`nwire …`). Lifecycle + logs ride the shared composables.
7
+ */
8
+ import { computed, onMounted, ref, watch } from "vue";
9
+ import { useRoute } from "vue-router";
10
+ import { Play, Square, RefreshCw, Trash2, Terminal } from "lucide-vue-next";
11
+ import { StatusBadge, EmptyState } from "@/components";
12
+ import { useProcesses } from "@/composables/useProcesses";
13
+ import { useLogTail } from "@/composables/useLogTail";
14
+ import { statusTone, isActive } from "@/lib/operate";
15
+
16
+ interface CommandEntry {
17
+ name: string;
18
+ description: string;
19
+ }
20
+
21
+ const route = useRoute();
22
+ const {
23
+ processes,
24
+ error: procError,
25
+ load,
26
+ exec,
27
+ stop,
28
+ forget,
29
+ } = useProcesses({
30
+ filter: (p) => p.topology.startsWith("nwire "),
31
+ intervalMs: 1500,
32
+ });
33
+
34
+ const commands = ref<CommandEntry[]>([]);
35
+ const argsInput = ref("");
36
+ const startBusy = ref(false);
37
+ const selectedCommand = ref<string | null>(null);
38
+ const selectedId = ref<string | null>(null);
39
+ const { logs } = useLogTail(selectedId);
40
+
41
+ const selectedProc = computed(() => processes.value.find((p) => p.id === selectedId.value) ?? null);
42
+
43
+ function applyPreselect(): void {
44
+ const name = route.query.name;
45
+ selectedCommand.value = typeof name === "string" && name.length > 0 ? name : null;
46
+ }
47
+ watch(() => route.query.name, applyPreselect);
48
+
49
+ async function loadCommands(): Promise<void> {
50
+ try {
51
+ const res = await fetch("/__nwire/run/commands");
52
+ commands.value = ((await res.json()) as { commands: CommandEntry[] }).commands ?? [];
53
+ } catch {
54
+ commands.value = [];
55
+ }
56
+ }
57
+
58
+ async function onExec(name: string): Promise<void> {
59
+ startBusy.value = true;
60
+ selectedCommand.value = name;
61
+ const argv = argsInput.value.trim().split(/\s+/).filter(Boolean);
62
+ const proc = await exec(name, argv);
63
+ if (proc) selectedId.value = proc.id;
64
+ startBusy.value = false;
65
+ }
66
+
67
+ async function onForget(id: string): Promise<void> {
68
+ if (selectedId.value === id) selectedId.value = null;
69
+ await forget(id);
70
+ }
71
+
72
+ onMounted(() => {
73
+ applyPreselect();
74
+ void loadCommands();
75
+ });
76
+ </script>
77
+
78
+ <template>
79
+ <div class="h-full flex" data-testid="commands-panel">
80
+ <!-- Command picker -->
81
+ <div class="w-1/3 border-r border-zinc-800 flex flex-col">
82
+ <div class="border-b border-zinc-800 px-4 py-3">
83
+ <input
84
+ v-model="argsInput"
85
+ placeholder="extra args (optional, e.g. `units`)"
86
+ class="w-full bg-zinc-900 border border-zinc-800 rounded px-2 py-1.5 text-xs placeholder:text-zinc-600 focus:outline-none focus:border-zinc-600"
87
+ />
88
+ <div v-if="procError" class="text-xs text-rose-400 mt-2">
89
+ {{ procError }}
90
+ </div>
91
+ </div>
92
+ <div class="flex-1 overflow-auto">
93
+ <div v-if="!commands.length" class="p-4 text-xs text-zinc-500">No commands available.</div>
94
+ <button
95
+ v-for="c in commands"
96
+ :key="c.name"
97
+ class="w-full text-left px-4 py-2.5 border-b border-zinc-900 hover:bg-zinc-900/50 transition-colors disabled:opacity-50"
98
+ :class="{ 'bg-zinc-900': selectedCommand === c.name }"
99
+ :data-testid="`command-row-${c.name}`"
100
+ :disabled="startBusy"
101
+ @click="onExec(c.name)"
102
+ >
103
+ <div class="flex items-center gap-2">
104
+ <Play class="w-3 h-3 text-emerald-400 shrink-0" />
105
+ <span class="font-mono text-sm">nwire {{ c.name }}</span>
106
+ </div>
107
+ <div class="text-xs text-zinc-500 mt-1 ml-5">{{ c.description }}</div>
108
+ </button>
109
+ </div>
110
+ </div>
111
+
112
+ <!-- Active processes -->
113
+ <div class="w-1/3 border-r border-zinc-800 flex flex-col">
114
+ <div class="border-b border-zinc-800 px-4 py-3 flex items-center justify-between">
115
+ <h2 class="text-sm font-semibold tracking-tight">Active</h2>
116
+ <button class="text-zinc-500 hover:text-zinc-300" title="Refresh" @click="load">
117
+ <RefreshCw class="w-3 h-3" />
118
+ </button>
119
+ </div>
120
+ <div class="flex-1 overflow-auto">
121
+ <EmptyState
122
+ v-if="processes.length === 0"
123
+ :icon="Terminal"
124
+ title="No runs yet"
125
+ hint="Click a command on the left to launch it."
126
+ />
127
+ <button
128
+ v-for="p in processes"
129
+ :key="p.id"
130
+ class="w-full text-left px-4 py-2 border-b border-zinc-900 hover:bg-zinc-900/50"
131
+ :class="{ 'bg-zinc-900': selectedId === p.id }"
132
+ data-testid="command-process-row"
133
+ @click="selectedId = p.id"
134
+ >
135
+ <div class="flex items-center gap-2">
136
+ <StatusBadge
137
+ :status="statusTone(p.status)"
138
+ :label="p.status"
139
+ :pulse="p.status === 'running'"
140
+ />
141
+ <span class="font-mono text-sm truncate">{{ p.topology }}</span>
142
+ </div>
143
+ <div class="text-[10px] text-zinc-500 mt-1 ml-1">
144
+ pid {{ p.pid ?? "—" }} · started {{ p.startedAt.slice(11, 19) }}
145
+ </div>
146
+ </button>
147
+ </div>
148
+ </div>
149
+
150
+ <!-- Output -->
151
+ <div class="flex-1 flex flex-col min-w-0">
152
+ <div class="border-b border-zinc-800 px-4 py-3 flex items-center justify-between">
153
+ <h2 class="text-sm font-semibold tracking-tight font-mono truncate">
154
+ {{ selectedProc ? selectedProc.topology : "Output" }}
155
+ </h2>
156
+ <div v-if="selectedProc" class="flex items-center gap-2">
157
+ <button
158
+ v-if="isActive(selectedProc.status)"
159
+ class="text-xs text-zinc-400 hover:text-rose-400 flex items-center gap-1"
160
+ @click="stop(selectedProc.id)"
161
+ >
162
+ <Square class="w-3 h-3" /> stop
163
+ </button>
164
+ <button
165
+ class="text-xs text-zinc-400 hover:text-zinc-300 flex items-center gap-1"
166
+ @click="onForget(selectedProc.id)"
167
+ >
168
+ <Trash2 class="w-3 h-3" /> forget
169
+ </button>
170
+ </div>
171
+ </div>
172
+ <div class="flex-1 overflow-auto bg-zinc-950 font-mono text-xs p-4">
173
+ <div v-if="logs.length === 0" class="text-zinc-600">
174
+ Select a process to view its stdout.
175
+ </div>
176
+ <div
177
+ v-for="l in logs"
178
+ :key="l.seq"
179
+ :class="l.stream === 'stderr' ? 'text-rose-400' : 'text-zinc-300'"
180
+ >
181
+ {{ l.line }}
182
+ </div>
183
+ </div>
184
+ </div>
185
+ </div>
186
+ </template>
@@ -1,128 +1,80 @@
1
1
  <script setup lang="ts">
2
+ /**
3
+ * Operate › Dispatch — invoke a registered handler.
4
+ *
5
+ * The handler list comes from the native manifest (`useManifest` → the flat
6
+ * `actions`/`queries` arrays, which carry the JSON input schema). Picking a
7
+ * target derives a form (or a JSON textarea for nested shapes), and Dispatch
8
+ * POSTs to `/_nwire/dispatch`.
9
+ */
2
10
  import { computed, ref, watch } from "vue";
3
- import { useCache } from "@/lib/cache";
4
11
  import { Zap, Search, Send, CheckCircle2, XCircle } from "lucide-vue-next";
12
+ import { useManifest } from "@/composables/useManifest";
13
+ import {
14
+ dispatchTargets,
15
+ filterTargets,
16
+ schemaFields,
17
+ scaffoldInput,
18
+ allInlineRenderable,
19
+ type DispatchTarget,
20
+ } from "@/lib/dispatch-form";
21
+ import { MetadataInspector, EmptyState } from "@/components";
22
+
23
+ const { manifest, isLoading, isError, error } = useManifest();
5
24
 
6
- const { cache } = useCache();
7
25
  const filter = ref("");
8
26
  const selectedName = ref<string | null>(null);
9
27
  const input = ref<string>("{}");
10
28
  const tenant = ref("");
11
29
  const userId = ref("");
30
+ const inputMode = ref<"form" | "json">("form");
31
+ const busy = ref(false);
32
+
12
33
  /**
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.
34
+ * Dispatch result, mirroring `@nwire/koa`'s inspect route: `{ result }` on
35
+ * success, `{ error: { code, summary } }` on failure. `__clientError` is set
36
+ * only when the request never reached the wire (network / parse).
22
37
  */
23
- const inputMode = ref<"form" | "json">("form");
24
38
  const result = ref<
25
- | { ok: true; envelope: { messageId: string; correlationId: string }; result: unknown }
26
- | { ok: false; error: string }
39
+ | { result: unknown }
40
+ | { error: { code: string; summary?: string } }
41
+ | { __clientError: string }
27
42
  | null
28
43
  >(null);
29
- const busy = ref(false);
30
44
 
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.app.toLowerCase().includes(q) ||
39
- (a.description ?? "").toLowerCase().includes(q),
40
- );
41
- });
45
+ const targets = computed(() => dispatchTargets(manifest.value));
46
+ const filtered = computed(() => filterTargets(targets.value, filter.value));
47
+ const selected = computed<DispatchTarget | null>(
48
+ () => filtered.value.find((t) => t.name === selectedName.value) ?? null,
49
+ );
42
50
 
43
- const selected = computed(() => filtered.value.find((a) => a.name === selectedName.value) ?? null);
51
+ const fields = computed(() => schemaFields(selected.value?.inputSchema));
52
+ const allInline = computed(() => allInlineRenderable(fields.value));
44
53
 
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.inputSchema 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);
54
+ const resultOk = computed(() => !!result.value && "result" in result.value);
55
+ const resultError = computed<string | null>(() => {
56
+ const r = result.value;
57
+ if (!r) return null;
58
+ if ("__clientError" in r) return r.__clientError;
59
+ if ("error" in r) return r.error.summary ? `${r.error.code}: ${r.error.summary}` : r.error.code;
60
+ return null;
61
+ });
62
+ const resultBody = computed<Record<string, unknown>>(() => {
63
+ const r = result.value;
64
+ if (r && "result" in r) {
65
+ const v = r.result;
66
+ return v && typeof v === "object" ? (v as Record<string, unknown>) : { value: v };
76
67
  }
77
- return out;
68
+ return {};
78
69
  });
79
70
 
80
- // When a different action is picked, seed a sensible input scaffold from
81
- // the schema's required fields.
82
71
  watch(selectedName, () => {
83
72
  result.value = null;
84
73
  if (!selected.value) return;
85
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
86
- const schema = selected.value.inputSchema 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);
74
+ input.value = JSON.stringify(scaffoldInput(selected.value.inputSchema), null, 2);
75
+ inputMode.value = allInline.value ? "form" : "json";
118
76
  });
119
77
 
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
78
  function parsedInput(): Record<string, unknown> {
127
79
  try {
128
80
  const parsed = JSON.parse(input.value);
@@ -131,31 +83,16 @@ function parsedInput(): Record<string, unknown> {
131
83
  return {};
132
84
  }
133
85
  }
134
-
135
86
  function setField(name: string, value: unknown): void {
136
87
  const obj = parsedInput();
137
88
  obj[name] = value;
138
89
  input.value = JSON.stringify(obj, null, 2);
139
90
  }
140
-
141
91
  function getField(name: string): unknown {
142
92
  return parsedInput()[name];
143
93
  }
144
94
 
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() {
95
+ async function dispatch(): Promise<void> {
159
96
  if (!selected.value) return;
160
97
  busy.value = true;
161
98
  result.value = null;
@@ -165,7 +102,7 @@ async function dispatch() {
165
102
  method: "POST",
166
103
  headers: { "Content-Type": "application/json" },
167
104
  body: JSON.stringify({
168
- action: selected.value.name,
105
+ handler: selected.value.name,
169
106
  input: parsed,
170
107
  tenant: tenant.value || undefined,
171
108
  userId: userId.value || undefined,
@@ -173,7 +110,7 @@ async function dispatch() {
173
110
  });
174
111
  result.value = (await res.json()) as typeof result.value;
175
112
  } catch (err) {
176
- result.value = { ok: false, error: (err as Error).message };
113
+ result.value = { __clientError: (err as Error).message };
177
114
  } finally {
178
115
  busy.value = false;
179
116
  }
@@ -181,49 +118,60 @@ async function dispatch() {
181
118
  </script>
182
119
 
183
120
  <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">
121
+ <div class="h-full flex" data-testid="dispatch-panel">
122
+ <!-- Handler picker -->
123
+ <div class="w-2/5 border-r border-zinc-800 flex flex-col min-w-0">
187
124
  <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">
125
+ <div class="relative">
190
126
  <Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-zinc-500" />
191
127
  <input
192
128
  v-model="filter"
193
- placeholder="pick an action…"
129
+ placeholder="pick a handler…"
130
+ data-testid="dispatch-filter"
194
131
  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
132
  />
196
133
  </div>
197
134
  </div>
198
135
  <div class="flex-1 overflow-auto">
136
+ <div v-if="isError" class="p-4 text-xs text-rose-300">{{ error }}</div>
137
+ <div v-else-if="isLoading && !targets.length" class="p-4 text-xs text-zinc-500">
138
+ Loading handlers…
139
+ </div>
140
+ <div v-else-if="!filtered.length" class="p-4 text-xs text-zinc-500">No handlers match.</div>
199
141
  <button
200
- v-for="a in filtered"
201
- :key="`${a.app}::${a.name}`"
142
+ v-for="t in filtered"
143
+ :key="`${t.kind}::${t.name}`"
202
144
  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"
145
+ :class="{ 'bg-zinc-900': selectedName === t.name }"
146
+ :data-testid="`dispatch-row-${t.name}`"
147
+ @click="selectedName = t.name"
205
148
  >
206
- <div class="flex items-center justify-between">
149
+ <div class="flex items-center justify-between gap-2">
207
150
  <div class="flex items-center gap-2 min-w-0">
208
151
  <Zap class="w-3 h-3 text-amber-400 shrink-0" />
209
- <span class="font-mono text-sm truncate">{{ a.name }}</span>
152
+ <span class="font-mono text-sm truncate">{{ t.name }}</span>
210
153
  </div>
211
- <span class="text-[10px] text-zinc-500">{{ a.app }}</span>
154
+ <span class="text-[10px] text-zinc-500 shrink-0">{{ t.app || t.kind }}</span>
212
155
  </div>
213
- <div v-if="a.description" class="text-[10px] text-zinc-500 ml-5 mt-0.5 line-clamp-1">
214
- {{ a.description }}
156
+ <div v-if="t.description" class="text-[10px] text-zinc-500 ml-5 mt-0.5 line-clamp-1">
157
+ {{ t.description }}
215
158
  </div>
216
159
  </button>
217
160
  </div>
218
161
  </div>
219
162
 
220
163
  <!-- 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>
164
+ <div class="flex-1 overflow-auto min-w-0">
165
+ <EmptyState
166
+ v-if="!selected"
167
+ :icon="Send"
168
+ title="Pick a handler to dispatch"
169
+ hint="Actions and queries from the manifest. The form is derived from the input schema."
170
+ />
223
171
  <div v-else class="p-6 space-y-5">
224
172
  <div>
225
173
  <div class="text-[10px] uppercase tracking-wide text-zinc-500">
226
- {{ selected.app }}
174
+ {{ selected.app || selected.kind }}
227
175
  </div>
228
176
  <h2 class="font-mono text-xl mt-1">{{ selected.name }}</h2>
229
177
  <p v-if="selected.description" class="text-sm text-zinc-400 mt-2 max-w-2xl">
@@ -254,80 +202,37 @@ async function dispatch() {
254
202
  </div>
255
203
  </div>
256
204
 
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
205
  <div>
294
206
  <label
295
207
  class="text-[10px] uppercase tracking-wide text-zinc-500 flex items-center justify-between mb-1"
296
208
  >
297
209
  <span>Input</span>
298
- <span class="flex items-center gap-2">
210
+ <span v-if="allInline" class="flex items-center gap-2">
299
211
  <button
300
- v-if="allFieldsInlineRenderable"
301
212
  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'"
213
+ :class="inputMode === 'form' ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-500'"
307
214
  data-testid="dispatch-mode-form"
215
+ @click="inputMode = 'form'"
308
216
  >
309
217
  Form
310
218
  </button>
311
219
  <button
312
220
  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'"
221
+ :class="inputMode === 'json' ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-500'"
318
222
  data-testid="dispatch-mode-json"
223
+ @click="inputMode = 'json'"
319
224
  >
320
225
  JSON
321
226
  </button>
322
227
  </span>
323
228
  </label>
324
- <!-- Form view — one input per schema field, primitive types only. -->
229
+
325
230
  <div
326
- v-if="inputMode === 'form' && allFieldsInlineRenderable"
231
+ v-if="inputMode === 'form' && allInline"
327
232
  class="space-y-2 rounded border border-zinc-800 bg-zinc-950/50 p-3"
328
233
  data-testid="dispatch-form"
329
234
  >
330
- <div v-for="f in schemaFields" :key="f.name" class="flex flex-col gap-1">
235
+ <div v-for="f in fields" :key="f.name" class="flex flex-col gap-1">
331
236
  <label
332
237
  class="text-[10px] uppercase tracking-wide text-zinc-500 flex items-center gap-2"
333
238
  >
@@ -338,7 +243,6 @@ async function dispatch() {
338
243
  >req</span
339
244
  >
340
245
  </label>
341
- <!-- enum → select -->
342
246
  <select
343
247
  v-if="f.enum"
344
248
  class="bg-zinc-900 border border-zinc-800 rounded px-2 py-1.5 text-sm focus:outline-none focus:border-zinc-600"
@@ -347,7 +251,6 @@ async function dispatch() {
347
251
  >
348
252
  <option v-for="e in f.enum" :key="e" :value="e">{{ e }}</option>
349
253
  </select>
350
- <!-- boolean → checkbox -->
351
254
  <label
352
255
  v-else-if="f.type.startsWith('boolean')"
353
256
  class="flex items-center gap-2 text-sm"
@@ -359,7 +262,6 @@ async function dispatch() {
359
262
  />
360
263
  <span class="text-zinc-400">{{ getField(f.name) ? "true" : "false" }}</span>
361
264
  </label>
362
- <!-- number/integer → number input -->
363
265
  <input
364
266
  v-else-if="f.type.startsWith('number') || f.type.startsWith('integer')"
365
267
  type="number"
@@ -367,7 +269,6 @@ async function dispatch() {
367
269
  :value="getField(f.name) as number"
368
270
  @input="setField(f.name, Number(($event.target as HTMLInputElement).value))"
369
271
  />
370
- <!-- string → text input -->
371
272
  <input
372
273
  v-else
373
274
  type="text"
@@ -377,12 +278,12 @@ async function dispatch() {
377
278
  />
378
279
  </div>
379
280
  </div>
380
- <!-- JSON view — raw textarea fallback for nested/complex shapes. -->
381
281
  <textarea
382
282
  v-else
383
283
  v-model="input"
384
284
  rows="10"
385
285
  spellcheck="false"
286
+ data-testid="dispatch-json"
386
287
  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
288
  ></textarea>
388
289
  </div>
@@ -390,29 +291,23 @@ async function dispatch() {
390
291
  <button
391
292
  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
293
  :disabled="busy"
294
+ data-testid="dispatch-submit"
393
295
  @click="dispatch"
394
296
  >
395
297
  <Send class="w-4 h-4" />
396
298
  {{ busy ? "Dispatching…" : "Dispatch" }}
397
299
  </button>
398
300
 
399
- <div v-if="result">
301
+ <div v-if="result" data-testid="dispatch-result">
400
302
  <div
401
- v-if="result.ok"
303
+ v-if="resultOk"
402
304
  class="rounded border border-emerald-900 bg-emerald-950/30 p-3 space-y-2"
403
305
  >
404
306
  <div class="flex items-center gap-2 text-emerald-300">
405
307
  <CheckCircle2 class="w-4 h-4" />
406
308
  <span class="font-medium">Accepted</span>
407
309
  </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>
310
+ <MetadataInspector :data="resultBody" label="Result" />
416
311
  </div>
417
312
  <div
418
313
  v-else
@@ -421,7 +316,9 @@ async function dispatch() {
421
316
  <XCircle class="w-4 h-4 text-rose-400 mt-0.5" />
422
317
  <div>
423
318
  <div class="font-medium text-rose-300">Rejected</div>
424
- <div class="text-sm text-rose-200 mt-1 font-mono">{{ result.error }}</div>
319
+ <div class="text-sm text-rose-200 mt-1 font-mono">
320
+ {{ resultError }}
321
+ </div>
425
322
  </div>
426
323
  </div>
427
324
  </div>