@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.
- package/package.json +5 -3
- package/src/App.vue +62 -56
- package/src/components/BcCard.stories.ts +47 -0
- package/src/components/BcCard.vue +152 -0
- package/src/components/DurationBar.stories.ts +55 -0
- package/src/components/DurationBar.vue +72 -0
- package/src/components/ErrorCard.stories.ts +133 -0
- package/src/components/ErrorCard.vue +153 -0
- package/src/components/GraphCanvas.stories.ts +48 -0
- package/src/components/GraphCanvas.vue +88 -0
- package/src/components/KpiTile.stories.ts +32 -0
- package/src/components/KpiTile.vue +39 -0
- package/src/components/LiveTable.stories.ts +78 -0
- package/src/components/LiveTable.vue +186 -0
- package/src/components/MetadataInspector.stories.ts +53 -0
- package/src/components/MetadataInspector.vue +105 -0
- package/src/components/NodeCard.stories.ts +44 -0
- package/src/components/NodeCard.vue +150 -0
- package/src/components/RcaPanel.stories.ts +95 -0
- package/src/components/RcaPanel.vue +223 -0
- package/src/components/ServiceNode.vue +134 -0
- package/src/components/SourceDrawer.vue +6 -4
- package/src/components/SourcePill.vue +10 -3
- package/src/components/StatusBadge.stories.ts +33 -0
- package/src/components/StatusBadge.vue +54 -0
- package/src/components/Waterfall.stories.ts +85 -0
- package/src/components/Waterfall.vue +53 -0
- package/src/components/WaterfallRow.vue +74 -0
- package/src/components/__tests__/BcCard.test.ts +53 -0
- package/src/components/__tests__/DurationBar.test.ts +31 -0
- package/src/components/__tests__/ErrorCard.test.ts +71 -0
- package/src/components/__tests__/KpiTile.test.ts +23 -0
- package/src/components/__tests__/LiveTable.test.ts +100 -0
- package/src/components/__tests__/MetadataInspector.test.ts +38 -0
- package/src/components/__tests__/NodeCard.test.ts +116 -0
- package/src/components/__tests__/RcaPanel.test.ts +81 -0
- package/src/components/__tests__/StatusBadge.test.ts +23 -0
- package/src/components/__tests__/Waterfall.test.ts +54 -0
- package/src/components/index.ts +13 -0
- package/src/composables/__tests__/composables-context.test.ts +107 -0
- package/src/composables/__tests__/useTelemetry.test.ts +104 -0
- package/src/composables/__tests__/useTelemetryRuns.test.ts +282 -0
- package/src/composables/useDiscovery.ts +73 -0
- package/src/composables/useEndpoints.ts +94 -0
- package/src/composables/useLogTail.ts +51 -0
- package/src/composables/useManifest.ts +43 -0
- package/src/composables/useProcesses.ts +114 -0
- package/src/composables/useProject.ts +34 -0
- package/src/composables/useTelemetry.ts +270 -0
- package/src/lib/__tests__/bc-graph.test.ts +218 -0
- package/src/lib/__tests__/dispatch-form.test.ts +113 -0
- package/src/lib/__tests__/error-friendly.test.ts +198 -0
- package/src/lib/__tests__/home.test.ts +231 -0
- package/src/lib/__tests__/inspect.test.ts +160 -0
- package/src/lib/__tests__/kind-colors.test.ts +59 -0
- package/src/lib/__tests__/live-table.test.ts +194 -0
- package/src/lib/__tests__/manifest-health.test.ts +120 -0
- package/src/lib/__tests__/manifest.test.ts +87 -0
- package/src/lib/__tests__/metadata.test.ts +47 -0
- package/src/lib/__tests__/node-metrics.test.ts +144 -0
- package/src/lib/__tests__/operate.test.ts +97 -0
- package/src/lib/__tests__/pipeline-flow.test.ts +79 -0
- package/src/lib/__tests__/rca.test.ts +124 -0
- package/src/lib/__tests__/telemetry.test.ts +91 -0
- package/src/lib/__tests__/topology-graph.test.ts +331 -0
- package/src/lib/__tests__/topology-view.test.ts +154 -0
- package/src/lib/__tests__/waterfall.test.ts +165 -0
- package/src/lib/bc-graph.ts +298 -0
- package/src/lib/dispatch-form.ts +160 -0
- package/src/lib/error-friendly.ts +288 -0
- package/src/lib/home.ts +191 -0
- package/src/lib/inspect.ts +226 -0
- package/src/lib/kind-colors.ts +132 -0
- package/src/lib/live-table.ts +204 -0
- package/src/lib/manifest-health.ts +71 -0
- package/src/lib/manifest.ts +139 -0
- package/src/lib/metadata.ts +52 -0
- package/src/lib/node-metrics.ts +242 -0
- package/src/lib/operate.ts +114 -0
- package/src/lib/pipeline-flow.ts +120 -0
- package/src/lib/rca.ts +193 -0
- package/src/lib/telemetry.ts +155 -0
- package/src/lib/topology-graph.ts +551 -0
- package/src/lib/topology-view.ts +185 -0
- package/src/lib/waterfall.ts +148 -0
- package/src/main.ts +63 -29
- package/src/pages/Errors.vue +272 -0
- package/src/pages/Home.stories.ts +7 -8
- package/src/pages/Home.vue +255 -540
- package/src/pages/Hooks.stories.ts +44 -0
- package/src/pages/Hooks.vue +165 -164
- package/src/pages/Inspect.vue +240 -0
- package/src/pages/Map.vue +187 -0
- package/src/pages/Operate.vue +74 -0
- package/src/pages/Plugins.stories.ts +1 -1
- package/src/pages/Plugins.vue +174 -238
- package/src/pages/Projects.vue +62 -60
- package/src/pages/Streams.vue +344 -0
- package/src/pages/Topology.vue +318 -136
- package/src/pages/Trace.vue +174 -412
- package/src/pages/__tests__/Home.test.ts +109 -54
- package/src/pages/__tests__/Hooks.test.ts +5 -5
- package/src/pages/__tests__/Inspect.test.ts +111 -0
- package/src/pages/__tests__/Plugins.test.ts +85 -35
- package/src/pages/__tests__/Trace.test.ts +117 -0
- package/src/pages/operate/CommandsPanel.vue +186 -0
- package/src/pages/{Dispatch.vue → operate/DispatchPanel.vue} +100 -203
- package/src/pages/operate/EndpointPicker.vue +56 -0
- package/src/pages/operate/RunPanel.vue +316 -0
- package/src/server/__tests__/nwire-read.test.ts +80 -0
- package/src/server/nwire-read.ts +63 -0
- package/vite.config.ts +220 -2
- package/src/lib/__tests__/normalize-cache.test.ts +0 -105
- package/src/lib/cache.ts +0 -312
- package/src/lib/normalize-cache.ts +0 -92
- package/src/pages/Actions.vue +0 -171
- package/src/pages/Apps.vue +0 -177
- package/src/pages/Commands.vue +0 -262
- package/src/pages/Events.vue +0 -210
- package/src/pages/Live.vue +0 -249
- package/src/pages/Overview.vue +0 -161
- package/src/pages/Projections.vue +0 -148
- package/src/pages/Queries.vue +0 -148
- package/src/pages/Run.vue +0 -618
- package/src/pages/Sinks.vue +0 -124
- package/src/pages/TraceNode.vue +0 -164
- package/src/pages/Workflows.vue +0 -184
- package/src/pages/__tests__/Actions.test.ts +0 -98
- package/src/pages/__tests__/Projections.test.ts +0 -90
- 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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
| {
|
|
26
|
-
| {
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
51
|
+
const fields = computed(() => schemaFields(selected.value?.inputSchema));
|
|
52
|
+
const allInline = computed(() => allInlineRenderable(fields.value));
|
|
44
53
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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 = {
|
|
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
|
|
185
|
-
<!--
|
|
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
|
-
<
|
|
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
|
|
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="
|
|
201
|
-
:key="`${
|
|
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 ===
|
|
204
|
-
|
|
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">{{
|
|
152
|
+
<span class="font-mono text-sm truncate">{{ t.name }}</span>
|
|
210
153
|
</div>
|
|
211
|
-
<span class="text-[10px] text-zinc-500">{{
|
|
154
|
+
<span class="text-[10px] text-zinc-500 shrink-0">{{ t.app || t.kind }}</span>
|
|
212
155
|
</div>
|
|
213
|
-
<div v-if="
|
|
214
|
-
{{
|
|
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
|
-
<
|
|
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
|
-
|
|
229
|
+
|
|
325
230
|
<div
|
|
326
|
-
v-if="inputMode === 'form' &&
|
|
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
|
|
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="
|
|
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
|
-
<
|
|
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">
|
|
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>
|