@nwire/studio 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -16
- package/package.json +2 -2
- package/src/App.vue +142 -27
- package/src/components/SourceDrawer.vue +46 -9
- package/src/components/SourcePill.vue +18 -53
- package/src/lib/__tests__/normalize-cache.test.ts +6 -5
- package/src/lib/cache.ts +60 -82
- package/src/lib/normalize-cache.ts +1 -1
- package/src/lib/project-catalog.ts +39 -1
- package/src/main.ts +52 -16
- package/src/pages/Actions.vue +5 -14
- package/src/pages/Apps.vue +177 -0
- package/src/pages/Dispatch.vue +4 -4
- package/src/pages/Events.vue +84 -40
- package/src/pages/Home.vue +133 -19
- package/src/pages/Hooks.vue +3 -8
- package/src/pages/Overview.vue +6 -4
- package/src/pages/Plugins.vue +3 -8
- package/src/pages/Projections.vue +148 -0
- package/src/pages/Projects.vue +2 -2
- package/src/pages/Queries.vue +148 -0
- package/src/pages/Run.vue +144 -5
- package/src/pages/Sinks.vue +124 -0
- package/src/pages/Topology.vue +91 -91
- package/src/pages/Trace.vue +2 -21
- package/src/pages/TraceNode.vue +2 -4
- package/src/pages/Workflows.vue +19 -26
- package/src/pages/__tests__/Projections.test.ts +90 -0
- package/src/pages/__tests__/Queries.test.ts +86 -0
- package/vite.config.ts +275 -34
- package/src/pages/Modules.vue +0 -174
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Queries — read endpoints over the CQRS surface. Each query reads from
|
|
4
|
+
* a projection (eventually consistent over events) or runs a handler
|
|
5
|
+
* (anything outside the projection store).
|
|
6
|
+
*/
|
|
7
|
+
import { computed, onMounted, ref, watch } from "vue";
|
|
8
|
+
import { useRoute, useRouter } from "vue-router";
|
|
9
|
+
import { useCache } from "@/lib/cache";
|
|
10
|
+
import { Search, Database, Globe, Lock } from "lucide-vue-next";
|
|
11
|
+
import {
|
|
12
|
+
PageHeader,
|
|
13
|
+
FilterInput,
|
|
14
|
+
KindBadge,
|
|
15
|
+
EmptyState,
|
|
16
|
+
MasterDetail,
|
|
17
|
+
SourcePill,
|
|
18
|
+
SourceDrawer,
|
|
19
|
+
ListRow,
|
|
20
|
+
} from "@/components";
|
|
21
|
+
|
|
22
|
+
const route = useRoute();
|
|
23
|
+
const router = useRouter();
|
|
24
|
+
const { cache } = useCache();
|
|
25
|
+
const filter = ref("");
|
|
26
|
+
const selected = ref<string | null>(null);
|
|
27
|
+
const sourcePreview = ref<{ file: string; line: number; column?: number } | null>(null);
|
|
28
|
+
|
|
29
|
+
function applyQueryPreselect(): void {
|
|
30
|
+
const name = route.query.name;
|
|
31
|
+
if (typeof name !== "string" || name.length === 0) return;
|
|
32
|
+
const found = cache.value?.queries.find((q) => q.name === name);
|
|
33
|
+
if (found) selected.value = `${found.app}::${found.name}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
onMounted(applyQueryPreselect);
|
|
37
|
+
watch(() => route.query.name, applyQueryPreselect);
|
|
38
|
+
watch(() => cache.value, applyQueryPreselect);
|
|
39
|
+
|
|
40
|
+
const filtered = computed(() => {
|
|
41
|
+
if (!cache.value) return [];
|
|
42
|
+
const q = filter.value.toLowerCase();
|
|
43
|
+
return cache.value.queries.filter(
|
|
44
|
+
(qe) =>
|
|
45
|
+
!q ||
|
|
46
|
+
qe.name.toLowerCase().includes(q) ||
|
|
47
|
+
qe.app.toLowerCase().includes(q) ||
|
|
48
|
+
(qe.projection ?? "").toLowerCase().includes(q),
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const key = (q: { app: string; name: string }) => `${q.app}::${q.name}`;
|
|
53
|
+
const detail = computed(() => filtered.value.find((q) => key(q) === selected.value) ?? null);
|
|
54
|
+
</script>
|
|
55
|
+
|
|
56
|
+
<template>
|
|
57
|
+
<div v-if="cache" class="h-full flex flex-col" data-testid="queries-page">
|
|
58
|
+
<div class="p-6 pb-3 border-b border-zinc-800">
|
|
59
|
+
<PageHeader
|
|
60
|
+
title="Queries"
|
|
61
|
+
subtitle="Read endpoints — projection-backed or direct-handler."
|
|
62
|
+
:icon="Search"
|
|
63
|
+
icon-color="text-emerald-400"
|
|
64
|
+
:count="filtered.length"
|
|
65
|
+
:total="cache.queries.length"
|
|
66
|
+
/>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<EmptyState
|
|
70
|
+
v-if="cache.queries.length === 0"
|
|
71
|
+
title="No queries in cache"
|
|
72
|
+
hint="Queries are declared via defineQuery(projection, { name, input, execute }) or defineQuery({ name, input, handler })."
|
|
73
|
+
:icon="Search"
|
|
74
|
+
/>
|
|
75
|
+
|
|
76
|
+
<MasterDetail v-else class="flex-1">
|
|
77
|
+
<template #listHeader>
|
|
78
|
+
<FilterInput v-model="filter" placeholder="filter by name, app, projection…" />
|
|
79
|
+
</template>
|
|
80
|
+
|
|
81
|
+
<template #list>
|
|
82
|
+
<ListRow
|
|
83
|
+
v-for="q in filtered"
|
|
84
|
+
:key="key(q)"
|
|
85
|
+
:selected="selected === key(q)"
|
|
86
|
+
@click="selected = key(q)"
|
|
87
|
+
>
|
|
88
|
+
<template #title>
|
|
89
|
+
<Search class="w-3 h-3 text-emerald-400 shrink-0" />
|
|
90
|
+
<span class="font-mono text-sm truncate">{{ q.name }}</span>
|
|
91
|
+
</template>
|
|
92
|
+
<template #meta>
|
|
93
|
+
<component
|
|
94
|
+
:is="q.public ? Globe : Lock"
|
|
95
|
+
class="w-3 h-3"
|
|
96
|
+
:class="q.public ? 'text-emerald-400' : 'text-zinc-500'"
|
|
97
|
+
:title="q.public ? 'public — exposed across the system' : 'private'"
|
|
98
|
+
/>
|
|
99
|
+
<span class="text-[10px] text-zinc-500">{{ q.app }}</span>
|
|
100
|
+
</template>
|
|
101
|
+
<template v-if="q.projection" #description>
|
|
102
|
+
<div>
|
|
103
|
+
<span class="text-zinc-500">reads</span>
|
|
104
|
+
<span class="font-mono ml-1">{{ q.projection }}</span>
|
|
105
|
+
</div>
|
|
106
|
+
</template>
|
|
107
|
+
</ListRow>
|
|
108
|
+
</template>
|
|
109
|
+
|
|
110
|
+
<template #empty>Select a query to view its projection and source.</template>
|
|
111
|
+
|
|
112
|
+
<template v-if="detail" #detail>
|
|
113
|
+
<div class="p-6 space-y-5" data-testid="query-detail">
|
|
114
|
+
<div>
|
|
115
|
+
<div class="text-[10px] uppercase tracking-wide text-zinc-500">{{ detail.app }}</div>
|
|
116
|
+
<h2 class="font-mono text-xl mt-1">{{ detail.name }}</h2>
|
|
117
|
+
<p v-if="detail.description" class="text-sm text-zinc-400 mt-2 max-w-2xl">
|
|
118
|
+
{{ detail.description }}
|
|
119
|
+
</p>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<div class="flex flex-wrap gap-2">
|
|
123
|
+
<KindBadge :variant="detail.public ? 'public' : 'private'">
|
|
124
|
+
{{ detail.public ? "public" : "private" }}
|
|
125
|
+
</KindBadge>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
<div v-if="detail.projection" class="space-y-3">
|
|
129
|
+
<h3 class="text-xs uppercase tracking-wide text-zinc-500">Reads from</h3>
|
|
130
|
+
<button
|
|
131
|
+
type="button"
|
|
132
|
+
class="flex items-center gap-2 font-mono text-sm text-left hover:text-cyan-300"
|
|
133
|
+
@click="router.push({ path: '/projections', query: { name: detail.projection } })"
|
|
134
|
+
>
|
|
135
|
+
<Database class="w-3.5 h-3.5 text-cyan-400" />
|
|
136
|
+
<span class="underline-offset-2 hover:underline">{{ detail.projection }}</span>
|
|
137
|
+
</button>
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
<div v-if="detail.source" class="pt-2">
|
|
141
|
+
<SourcePill :source="detail.source" @click="sourcePreview = detail.source!" />
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
</template>
|
|
145
|
+
</MasterDetail>
|
|
146
|
+
<SourceDrawer :source="sourcePreview" @close="sourcePreview = null" />
|
|
147
|
+
</div>
|
|
148
|
+
</template>
|
package/src/pages/Run.vue
CHANGED
|
@@ -32,6 +32,10 @@ interface ManagedProcess {
|
|
|
32
32
|
exitCode?: number | null;
|
|
33
33
|
signal?: string | null;
|
|
34
34
|
errorMessage?: string;
|
|
35
|
+
/** "studio" — spawned via this Studio session.
|
|
36
|
+
* "external" — discovered from .nwire/processes/*.json (e.g. nwire dev). */
|
|
37
|
+
source?: "studio" | "external";
|
|
38
|
+
env?: Record<string, string>;
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
interface LogLine {
|
|
@@ -50,6 +54,30 @@ const startTopology = ref<string>("");
|
|
|
50
54
|
const startPort = ref<number>(3000);
|
|
51
55
|
const startBusy = ref(false);
|
|
52
56
|
const startError = ref<string | null>(null);
|
|
57
|
+
|
|
58
|
+
/** KEY=value lines the operator typed; parsed on submit. */
|
|
59
|
+
const envInput = ref<string>("");
|
|
60
|
+
const envOpen = ref(false);
|
|
61
|
+
|
|
62
|
+
function parseEnvInput(raw: string): Record<string, string> {
|
|
63
|
+
const out: Record<string, string> = {};
|
|
64
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
65
|
+
const trimmed = line.trim();
|
|
66
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
67
|
+
const eq = trimmed.indexOf("=");
|
|
68
|
+
if (eq < 1) continue;
|
|
69
|
+
const k = trimmed.slice(0, eq).trim();
|
|
70
|
+
const v = trimmed.slice(eq + 1).trim();
|
|
71
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) continue;
|
|
72
|
+
// Strip matching surrounding quotes — "value" or 'value'.
|
|
73
|
+
const dequoted =
|
|
74
|
+
(v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))
|
|
75
|
+
? v.slice(1, -1)
|
|
76
|
+
: v;
|
|
77
|
+
out[k] = dequoted;
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
53
81
|
const autoScroll = ref(true);
|
|
54
82
|
const filterStream = ref<"all" | "stdout" | "stderr">("all");
|
|
55
83
|
|
|
@@ -102,10 +130,12 @@ async function runScript(name: string) {
|
|
|
102
130
|
scriptBusy.value = name;
|
|
103
131
|
startError.value = null;
|
|
104
132
|
try {
|
|
133
|
+
const env = parseEnvInput(envInput.value);
|
|
134
|
+
const port = startPort.value > 0 ? startPort.value : undefined;
|
|
105
135
|
const res = await fetch("/__nwire/run/exec-script", {
|
|
106
136
|
method: "POST",
|
|
107
137
|
headers: { "Content-Type": "application/json" },
|
|
108
|
-
body: JSON.stringify({ script: name }),
|
|
138
|
+
body: JSON.stringify({ script: name, port, env: Object.keys(env).length ? env : undefined }),
|
|
109
139
|
});
|
|
110
140
|
const body = (await res.json()) as { process?: ManagedProcess; error?: string };
|
|
111
141
|
if (!res.ok) {
|
|
@@ -145,7 +175,14 @@ async function start() {
|
|
|
145
175
|
const res = await fetch("/__nwire/run/start", {
|
|
146
176
|
method: "POST",
|
|
147
177
|
headers: { "Content-Type": "application/json" },
|
|
148
|
-
body: JSON.stringify({
|
|
178
|
+
body: JSON.stringify({
|
|
179
|
+
topology: startTopology.value,
|
|
180
|
+
port: startPort.value,
|
|
181
|
+
env: (() => {
|
|
182
|
+
const e = parseEnvInput(envInput.value);
|
|
183
|
+
return Object.keys(e).length ? e : undefined;
|
|
184
|
+
})(),
|
|
185
|
+
}),
|
|
149
186
|
});
|
|
150
187
|
const body = (await res.json()) as { process?: ManagedProcess; error?: string };
|
|
151
188
|
if (!res.ok) {
|
|
@@ -262,7 +299,48 @@ function timeAgo(iso: string): string {
|
|
|
262
299
|
/_nwire/* → static fallback
|
|
263
300
|
</div>
|
|
264
301
|
</div>
|
|
265
|
-
<div
|
|
302
|
+
<div
|
|
303
|
+
v-if="topologies.length === 0 && scripts.length === 0"
|
|
304
|
+
class="p-4 text-xs text-zinc-400 space-y-2 border-b border-zinc-800"
|
|
305
|
+
>
|
|
306
|
+
<div class="flex items-start gap-2 text-amber-300">
|
|
307
|
+
<AlertTriangle class="w-3.5 h-3.5 mt-0.5 shrink-0" />
|
|
308
|
+
<div class="space-y-1">
|
|
309
|
+
<div class="font-medium">No way to start this project</div>
|
|
310
|
+
<div class="text-zinc-500">
|
|
311
|
+
Studio looks for two things:
|
|
312
|
+
<ul class="list-disc list-inside mt-1 space-y-0.5">
|
|
313
|
+
<li>
|
|
314
|
+
<code class="text-zinc-300">apps/topologies/*.topology.ts</code> — multi-app
|
|
315
|
+
projects
|
|
316
|
+
</li>
|
|
317
|
+
<li>
|
|
318
|
+
<code class="text-zinc-300">package.json</code> scripts (<code>dev</code>,
|
|
319
|
+
<code>start</code>) — single-app projects
|
|
320
|
+
</li>
|
|
321
|
+
</ul>
|
|
322
|
+
</div>
|
|
323
|
+
<div class="text-zinc-500 mt-2">
|
|
324
|
+
Most projects use <code>pnpm dev</code>. Add a <code>"dev"</code> script to your
|
|
325
|
+
package.json and refresh.
|
|
326
|
+
</div>
|
|
327
|
+
</div>
|
|
328
|
+
</div>
|
|
329
|
+
</div>
|
|
330
|
+
<div
|
|
331
|
+
v-else-if="topologies.length === 0 && scripts.length > 0"
|
|
332
|
+
class="p-3 text-[11px] text-zinc-500 border-b border-zinc-800"
|
|
333
|
+
>
|
|
334
|
+
<div class="flex items-start gap-1.5">
|
|
335
|
+
<CircleDot class="w-3 h-3 mt-0.5 text-emerald-400 shrink-0" />
|
|
336
|
+
<div>
|
|
337
|
+
No topology files. Use the
|
|
338
|
+
<span class="text-zinc-300">package.json scripts</span> below — usually
|
|
339
|
+
<code class="text-zinc-300">dev</code> is what you want.
|
|
340
|
+
</div>
|
|
341
|
+
</div>
|
|
342
|
+
</div>
|
|
343
|
+
<div class="p-4 space-y-3" v-if="topologies.length > 0">
|
|
266
344
|
<div>
|
|
267
345
|
<label class="text-[10px] uppercase tracking-wide text-zinc-500">Topology</label>
|
|
268
346
|
<select
|
|
@@ -270,7 +348,6 @@ function timeAgo(iso: string): string {
|
|
|
270
348
|
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"
|
|
271
349
|
>
|
|
272
350
|
<option v-for="t in topologies" :key="t" :value="t">{{ t }}</option>
|
|
273
|
-
<option v-if="topologies.length === 0" disabled>No topology files found</option>
|
|
274
351
|
</select>
|
|
275
352
|
</div>
|
|
276
353
|
<div>
|
|
@@ -305,6 +382,62 @@ function timeAgo(iso: string): string {
|
|
|
305
382
|
refresh topologies
|
|
306
383
|
</button>
|
|
307
384
|
</div>
|
|
385
|
+
<!-- Env vars + port shared between topology start and script run.
|
|
386
|
+
Collapsed by default. Lines like `PORT=4000`, `LOG_LEVEL=debug`
|
|
387
|
+
are forwarded to the child process. -->
|
|
388
|
+
<div
|
|
389
|
+
class="border-t border-zinc-800 px-4 py-3"
|
|
390
|
+
v-if="scripts.length > 0 || topologies.length > 0"
|
|
391
|
+
>
|
|
392
|
+
<button
|
|
393
|
+
class="w-full flex items-center justify-between text-[10px] uppercase tracking-wide text-zinc-500 hover:text-zinc-300"
|
|
394
|
+
@click="envOpen = !envOpen"
|
|
395
|
+
>
|
|
396
|
+
<span class="flex items-center gap-1.5">
|
|
397
|
+
<span
|
|
398
|
+
:class="
|
|
399
|
+
envOpen
|
|
400
|
+
? 'rotate-90 inline-block transition-transform'
|
|
401
|
+
: 'inline-block transition-transform'
|
|
402
|
+
"
|
|
403
|
+
>▸</span
|
|
404
|
+
>
|
|
405
|
+
env + port overrides
|
|
406
|
+
</span>
|
|
407
|
+
<span
|
|
408
|
+
v-if="Object.keys(parseEnvInput(envInput)).length > 0"
|
|
409
|
+
class="text-emerald-300 normal-case"
|
|
410
|
+
>
|
|
411
|
+
{{ Object.keys(parseEnvInput(envInput)).length }} var(s) set
|
|
412
|
+
</span>
|
|
413
|
+
</button>
|
|
414
|
+
<div v-if="envOpen" class="mt-2 space-y-2">
|
|
415
|
+
<div>
|
|
416
|
+
<label class="text-[10px] uppercase tracking-wide text-zinc-500">Port (PORT env)</label>
|
|
417
|
+
<input
|
|
418
|
+
v-model.number="startPort"
|
|
419
|
+
type="number"
|
|
420
|
+
placeholder="auto"
|
|
421
|
+
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"
|
|
422
|
+
/>
|
|
423
|
+
</div>
|
|
424
|
+
<div>
|
|
425
|
+
<label class="text-[10px] uppercase tracking-wide text-zinc-500"
|
|
426
|
+
>Other env (KEY=value, one per line)</label
|
|
427
|
+
>
|
|
428
|
+
<textarea
|
|
429
|
+
v-model="envInput"
|
|
430
|
+
rows="4"
|
|
431
|
+
placeholder="LOG_LEVEL=debug NODE_ENV=development"
|
|
432
|
+
class="w-full mt-1 bg-zinc-900 border border-zinc-800 rounded px-2 py-1.5 text-xs font-mono focus:outline-none focus:border-zinc-600"
|
|
433
|
+
></textarea>
|
|
434
|
+
<div class="text-[10px] text-zinc-600 mt-1">
|
|
435
|
+
Applied to both topology starts and script runs.
|
|
436
|
+
</div>
|
|
437
|
+
</div>
|
|
438
|
+
</div>
|
|
439
|
+
</div>
|
|
440
|
+
|
|
308
441
|
<!-- package.json scripts — fallback for single-app projects without
|
|
309
442
|
apps/topologies/. Always shown so multi-app projects can also
|
|
310
443
|
use it for one-shot `pnpm test`, `pnpm build`, etc. -->
|
|
@@ -388,10 +521,16 @@ function timeAgo(iso: string): string {
|
|
|
388
521
|
</div>
|
|
389
522
|
<span class="text-[10px] text-zinc-500">{{ timeAgo(p.startedAt) }}</span>
|
|
390
523
|
</div>
|
|
391
|
-
<div class="text-[10px] text-zinc-500 mt-0.5 font-mono flex items-center gap-2">
|
|
524
|
+
<div class="text-[10px] text-zinc-500 mt-0.5 font-mono flex items-center gap-2 flex-wrap">
|
|
392
525
|
<span class="uppercase">{{ p.status }}</span>
|
|
393
526
|
<span v-if="p.pid">pid {{ p.pid }}</span>
|
|
394
527
|
<span class="text-zinc-600">{{ shortId(p.id) }}</span>
|
|
528
|
+
<span
|
|
529
|
+
v-if="p.source === 'external'"
|
|
530
|
+
class="text-[9px] uppercase tracking-wider px-1 py-0 rounded bg-amber-950/40 text-amber-300 border border-amber-900"
|
|
531
|
+
title="Discovered from .nwire/processes/*.json — started outside Studio"
|
|
532
|
+
>external</span
|
|
533
|
+
>
|
|
395
534
|
</div>
|
|
396
535
|
<div v-if="p.errorMessage" class="text-[10px] text-rose-300 mt-1">
|
|
397
536
|
{{ p.errorMessage }}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Sinks — outbound delivery surface. Each sink is a stage on the
|
|
4
|
+
* runtime's outbound chain (early → middle → terminal). Endpoint
|
|
5
|
+
* adapters install them at boot via `ctx.installSinkStage`.
|
|
6
|
+
*/
|
|
7
|
+
import { computed, ref } from "vue";
|
|
8
|
+
import { useCache } from "@/lib/cache";
|
|
9
|
+
import { Waves, ArrowRight } from "lucide-vue-next";
|
|
10
|
+
import {
|
|
11
|
+
PageHeader,
|
|
12
|
+
FilterInput,
|
|
13
|
+
EmptyState,
|
|
14
|
+
MasterDetail,
|
|
15
|
+
KindBadge,
|
|
16
|
+
ListRow,
|
|
17
|
+
} from "@/components";
|
|
18
|
+
|
|
19
|
+
const { cache } = useCache();
|
|
20
|
+
const filter = ref("");
|
|
21
|
+
const selected = ref<string | null>(null);
|
|
22
|
+
|
|
23
|
+
const sinks = computed(() => cache.value?.sinks ?? []);
|
|
24
|
+
|
|
25
|
+
const filtered = computed(() => {
|
|
26
|
+
if (!cache.value) return [];
|
|
27
|
+
const q = filter.value.toLowerCase();
|
|
28
|
+
return sinks.value.filter(
|
|
29
|
+
(s) =>
|
|
30
|
+
!q ||
|
|
31
|
+
s.name.toLowerCase().includes(q) ||
|
|
32
|
+
s.app.toLowerCase().includes(q) ||
|
|
33
|
+
(s.kind ?? "").toLowerCase().includes(q),
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const key = (s: { app: string; name: string }) => `${s.app}::${s.name}`;
|
|
38
|
+
const detail = computed(() => filtered.value.find((s) => key(s) === selected.value) ?? null);
|
|
39
|
+
|
|
40
|
+
const positionVariant = (p: "early" | "middle" | "terminal") =>
|
|
41
|
+
p === "terminal" ? "public" : p === "middle" ? "neutral" : "private";
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<template>
|
|
45
|
+
<div v-if="cache" class="h-full flex flex-col" data-testid="sinks-page">
|
|
46
|
+
<div class="p-6 pb-3 border-b border-zinc-800">
|
|
47
|
+
<PageHeader
|
|
48
|
+
title="Sinks"
|
|
49
|
+
subtitle="Outbound stages — every step in the runtime's outbound delivery chain."
|
|
50
|
+
:icon="Waves"
|
|
51
|
+
icon-color="text-amber-400"
|
|
52
|
+
:count="filtered.length"
|
|
53
|
+
:total="sinks.length"
|
|
54
|
+
/>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<EmptyState
|
|
58
|
+
v-if="sinks.length === 0"
|
|
59
|
+
title="No outbound sinks installed"
|
|
60
|
+
hint="Outbound adapters (queue publisher, NATS, webhook, OTLP) install sinks at endpoint boot via ctx.installSinkStage. Apps without one keep events in-process."
|
|
61
|
+
:icon="Waves"
|
|
62
|
+
/>
|
|
63
|
+
|
|
64
|
+
<MasterDetail v-else class="flex-1">
|
|
65
|
+
<template #listHeader>
|
|
66
|
+
<FilterInput v-model="filter" placeholder="filter by name, app, kind…" />
|
|
67
|
+
</template>
|
|
68
|
+
|
|
69
|
+
<template #list>
|
|
70
|
+
<ListRow
|
|
71
|
+
v-for="s in filtered"
|
|
72
|
+
:key="key(s)"
|
|
73
|
+
:selected="selected === key(s)"
|
|
74
|
+
@click="selected = key(s)"
|
|
75
|
+
>
|
|
76
|
+
<template #title>
|
|
77
|
+
<Waves class="w-3 h-3 text-amber-400 shrink-0" />
|
|
78
|
+
<span class="font-mono text-sm truncate">{{ s.name }}</span>
|
|
79
|
+
</template>
|
|
80
|
+
<template #meta>
|
|
81
|
+
<KindBadge :variant="positionVariant(s.position)">{{ s.position }}</KindBadge>
|
|
82
|
+
<span class="text-[10px] text-zinc-500">{{ s.app }}</span>
|
|
83
|
+
</template>
|
|
84
|
+
<template v-if="s.kind" #description>
|
|
85
|
+
<span class="text-zinc-500">kind</span>
|
|
86
|
+
<span class="ml-1 font-mono">{{ s.kind }}</span>
|
|
87
|
+
</template>
|
|
88
|
+
</ListRow>
|
|
89
|
+
</template>
|
|
90
|
+
|
|
91
|
+
<template #empty>Select a sink to see its position and adapter kind.</template>
|
|
92
|
+
|
|
93
|
+
<template v-if="detail" #detail>
|
|
94
|
+
<div class="p-6 space-y-5" data-testid="sink-detail">
|
|
95
|
+
<div>
|
|
96
|
+
<div class="text-[10px] uppercase tracking-wide text-zinc-500">{{ detail.app }}</div>
|
|
97
|
+
<h2 class="font-mono text-xl mt-1">{{ detail.name }}</h2>
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
<div class="flex flex-wrap gap-2">
|
|
101
|
+
<KindBadge :variant="positionVariant(detail.position)">
|
|
102
|
+
{{ detail.position }}
|
|
103
|
+
</KindBadge>
|
|
104
|
+
<KindBadge variant="neutral">{{ detail.direction }}</KindBadge>
|
|
105
|
+
<KindBadge v-if="detail.kind" variant="neutral">kind: {{ detail.kind }}</KindBadge>
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<div class="text-xs text-zinc-400 max-w-xl space-y-2">
|
|
109
|
+
<p>
|
|
110
|
+
<ArrowRight class="w-3 h-3 inline mr-1 text-amber-400" />
|
|
111
|
+
Every public event the App publishes runs through the outbound chain in
|
|
112
|
+
<em>position</em> order: early → middle → terminal. Terminal stages do the transport
|
|
113
|
+
delivery; early/middle do logging, metrics, routing.
|
|
114
|
+
</p>
|
|
115
|
+
<p v-if="detail.position === 'terminal'">
|
|
116
|
+
Only one terminal stage per <em>kind</em> is allowed — the runtime rejects a second
|
|
117
|
+
registration. Swap implementations by installing a different kind.
|
|
118
|
+
</p>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
</template>
|
|
122
|
+
</MasterDetail>
|
|
123
|
+
</div>
|
|
124
|
+
</template>
|