@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.
@@ -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({ topology: startTopology.value, port: startPort.value }),
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 class="p-4 space-y-3">
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&#10;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>