@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/vite.config.ts CHANGED
@@ -3,7 +3,15 @@ import { resolve, dirname, sep } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import vue from "@vitejs/plugin-vue";
5
5
  import tailwindcss from "@tailwindcss/vite";
6
- import { readFileSync, existsSync, readdirSync } from "node:fs";
6
+ import {
7
+ closeSync,
8
+ existsSync,
9
+ openSync,
10
+ readdirSync,
11
+ readFileSync,
12
+ readSync,
13
+ statSync,
14
+ } from "node:fs";
7
15
  import { spawnSync } from "node:child_process";
8
16
  import { RunnerSupervisor, inspectHealthCheck } from "@nwire/supervisor";
9
17
  import type { IncomingMessage, ServerResponse } from "node:http";
@@ -46,6 +54,141 @@ function isPidAlive(pid: number): boolean {
46
54
  }
47
55
  }
48
56
 
57
+ interface ExternalProcessRecord {
58
+ id: string;
59
+ topology: string;
60
+ port?: number;
61
+ startedAt: string;
62
+ cwd: string;
63
+ pid: number;
64
+ status: "starting" | "running" | "stopping" | "exited" | "crashed";
65
+ logPath: string;
66
+ }
67
+
68
+ /**
69
+ * Read `.nwire/processes/*.json` for the given cwd and project each
70
+ * `ProcessRecord` (written by `nwire dev`) into the shape Studio's Run
71
+ * endpoint returns. Dead pids are filtered. Used to surface processes
72
+ * the user started in another terminal alongside Studio-spawned ones.
73
+ */
74
+ interface ExternalLogLine {
75
+ seq: number;
76
+ ts: string;
77
+ stream: "stdout";
78
+ line: string;
79
+ }
80
+
81
+ /**
82
+ * Read the last N lines of a process log file. Each line becomes one
83
+ * `ExternalLogLine` so the SSE consumer sees the same shape Studio's
84
+ * supervisor emits for in-process children.
85
+ */
86
+ function tailLogFile(path: string, n: number): ExternalLogLine[] {
87
+ if (!path || !existsSync(path)) return [];
88
+ try {
89
+ const text = readFileSync(path, "utf8");
90
+ const lines = text.split(/\r?\n/).filter(Boolean);
91
+ const tail = lines.slice(-n);
92
+ const out: ExternalLogLine[] = [];
93
+ const now = new Date().toISOString();
94
+ for (let i = 0; i < tail.length; i++) {
95
+ out.push({ seq: i, ts: now, stream: "stdout", line: tail[i]! });
96
+ }
97
+ return out;
98
+ } catch {
99
+ return [];
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Poll a log file for new content and call `onLine` with each newly
105
+ * appended line. Returns a stop handle to clean up. Used to live-tail
106
+ * external `nwire dev` processes — we don't own their stdout so we
107
+ * watch the disk file they tee into.
108
+ */
109
+ function openLogTail(path: string, onLine: (line: ExternalLogLine) => void): { stop: () => void } {
110
+ if (!path || !existsSync(path)) return { stop: () => {} };
111
+ let offset = 0;
112
+ try {
113
+ offset = statSync(path).size;
114
+ } catch {
115
+ offset = 0;
116
+ }
117
+ let seq = 0;
118
+ let stopped = false;
119
+ const poll = setInterval(() => {
120
+ if (stopped) return;
121
+ try {
122
+ const size = statSync(path).size;
123
+ if (size <= offset) return;
124
+ const fd = openSync(path, "r");
125
+ try {
126
+ const buf = Buffer.alloc(size - offset);
127
+ readSync(fd, buf, 0, buf.length, offset);
128
+ offset = size;
129
+ const text = buf.toString("utf8");
130
+ for (const raw of text.split(/\r?\n/)) {
131
+ if (!raw) continue;
132
+ onLine({ seq: seq++, ts: new Date().toISOString(), stream: "stdout", line: raw });
133
+ }
134
+ } finally {
135
+ closeSync(fd);
136
+ }
137
+ } catch {
138
+ // file rotated or removed — surrender quietly
139
+ }
140
+ }, 500);
141
+ return {
142
+ stop: () => {
143
+ stopped = true;
144
+ clearInterval(poll);
145
+ },
146
+ };
147
+ }
148
+
149
+ function readExternalProcesses(cwd: string): ExternalProcessRecord[] {
150
+ const dir = resolve(cwd, ".nwire", "processes");
151
+ if (!existsSync(dir)) return [];
152
+ const out: ExternalProcessRecord[] = [];
153
+ let entries: string[];
154
+ try {
155
+ entries = readdirSync(dir);
156
+ } catch {
157
+ return [];
158
+ }
159
+ for (const f of entries) {
160
+ if (!f.endsWith(".json")) continue;
161
+ try {
162
+ const rec = JSON.parse(readFileSync(resolve(dir, f), "utf8")) as {
163
+ id?: string;
164
+ name?: string;
165
+ pid?: number;
166
+ port?: number;
167
+ status?: string;
168
+ startedAt?: string;
169
+ cwd?: string;
170
+ logPath?: string;
171
+ };
172
+ if (typeof rec.pid !== "number" || rec.pid <= 0) continue;
173
+ if (!isPidAlive(rec.pid)) continue;
174
+ out.push({
175
+ id: rec.id ?? f.replace(/\.json$/, ""),
176
+ topology: rec.name ?? "external",
177
+ port: typeof rec.port === "number" ? rec.port : undefined,
178
+ startedAt: rec.startedAt ?? new Date(0).toISOString(),
179
+ cwd: rec.cwd ?? cwd,
180
+ pid: rec.pid,
181
+ status:
182
+ (rec.status as ExternalProcessRecord["status"]) === "running" ? "running" : "running",
183
+ logPath: rec.logPath ?? "",
184
+ });
185
+ } catch {
186
+ // malformed entry — skip
187
+ }
188
+ }
189
+ return out;
190
+ }
191
+
49
192
  /**
50
193
  * Set of CWDs the frontend has registered as "known projects." Populated by
51
194
  * POST /__nwire/projects/register (called on Studio mount with every cwd
@@ -68,24 +211,35 @@ function targetCwd(req: IncomingMessage): string | null {
68
211
  }
69
212
 
70
213
  /**
71
- * Build the `.nwire/` cache on demand by running the CLI's cache-runner in
72
- * the consumer's project. Returns true if the manifest is present after the
73
- * attempt. Synchronous so the Vite middleware can serve the freshly built
214
+ * Build the `.nwire/` cache on demand, or refresh it when source changed.
215
+ * Uses the CLI's fingerprint cheap-path (a SHA over the apps entry +
216
+ * package.json + workspace handshake) so re-running is free when nothing
217
+ * moved. Synchronous so the Vite middleware can serve the freshly built
74
218
  * file on the same request.
219
+ *
220
+ * Studio calls this on every `GET /__nwire/manifest.json` so a user
221
+ * editing source never sees stale data — the cache rebuilds itself on
222
+ * the next page load.
75
223
  */
76
- function ensureCacheBuilt(cwd: string = consumerCwd): boolean {
224
+ function ensureCacheBuilt(cwd: string = consumerCwd, opts: { force?: boolean } = {}): boolean {
77
225
  const manifestPath = resolve(cwd, ".nwire", "manifest.json");
78
- if (existsSync(manifestPath)) return true;
79
- const cacheRunnerPath = resolve(here, "..", "nwire-cli", "dist", "cache-runner.js");
80
- if (!existsSync(cacheRunnerPath)) return false;
81
- const result = spawnSync("pnpm", ["exec", "vite-node", cacheRunnerPath], {
226
+ const cliPath = resolve(here, "..", "nwire-cli", "dist", "cli.js");
227
+ if (!existsSync(cliPath)) {
228
+ return existsSync(manifestPath);
229
+ }
230
+ // `nwire cache --if-stale` uses the CLI's fingerprint cheap-path —
231
+ // when nothing changed it's a few ms; when source moved it rebuilds
232
+ // before returning. `--quiet` keeps Studio's dev-server log clean.
233
+ const args = ["exec", "node", cliPath, "cache", "--quiet"];
234
+ if (!opts.force) args.push("--if-stale");
235
+ const result = spawnSync("pnpm", args, {
82
236
  cwd,
83
237
  stdio: "inherit",
84
238
  // Windows: `pnpm` resolves to a `.cmd` shim; `shell:true` is needed
85
239
  // for `spawn` to find it. POSIX is unaffected.
86
240
  shell: process.platform === "win32",
87
241
  });
88
- if (result.status !== 0) return false;
242
+ if (result.status !== 0) return existsSync(manifestPath);
89
243
  return existsSync(manifestPath);
90
244
  }
91
245
 
@@ -237,7 +391,16 @@ function nwireDataPlugin() {
237
391
  return;
238
392
  }
239
393
  const manifestPath = resolve(cwd, ".nwire", "manifest.json");
240
- if (!existsSync(manifestPath) && !ensureCacheBuilt(cwd)) {
394
+ // Always run the if-stale path. When the fingerprint matches,
395
+ // the CLI exits in a few ms with no spawn cost beyond its own
396
+ // bootstrap; when source moved, the manifest is rebuilt before
397
+ // we serve it. That's how Studio stays honest while a user is
398
+ // actively editing.
399
+ //
400
+ // `?force=true` is the manual override for the Rebuild button.
401
+ const u = new URL(req.url ?? "/", "http://localhost");
402
+ const force = u.searchParams.get("force") === "true";
403
+ if (!ensureCacheBuilt(cwd, { force })) {
241
404
  res.statusCode = 404;
242
405
  res.end(
243
406
  JSON.stringify({
@@ -513,6 +676,21 @@ function nwireProxyPlugin() {
513
676
  res.end(JSON.stringify({ error: "unknown project" }));
514
677
  return;
515
678
  }
679
+ // Read the body up-front. If we issue the upstream request before
680
+ // the body is in hand, Node flushes the proxy headers on the next
681
+ // tick and the wire waits forever for Content-Length bytes that
682
+ // arrive too late, surfacing here as "socket hang up". For SSE we
683
+ // skip body collection entirely (the stream IS the body).
684
+ const method = (req.method ?? "GET").toUpperCase();
685
+ const hasBody = method !== "GET" && method !== "HEAD" && method !== "DELETE";
686
+ const body: Buffer = hasBody
687
+ ? await new Promise<Buffer>((resolveBody, rejectBody) => {
688
+ const chunks: Buffer[] = [];
689
+ req.on("data", (chunk: Buffer) => chunks.push(chunk));
690
+ req.on("end", () => resolveBody(Buffer.concat(chunks)));
691
+ req.on("error", rejectBody);
692
+ })
693
+ : Buffer.alloc(0);
516
694
  const port = await activeProcessPort(cwd);
517
695
  if (!port) {
518
696
  // No managed process — let Vite's static proxy take it.
@@ -565,16 +743,8 @@ function nwireProxyPlugin() {
565
743
  }),
566
744
  );
567
745
  });
568
- // GET / DELETE / HEAD have no body calling pipe() on a Vite-parsed
569
- // IncomingMessage that's already been consumed by upstream middleware
570
- // can hang upstream waiting for bytes that never arrive. End directly
571
- // for bodyless methods.
572
- const method = (req.method ?? "GET").toUpperCase();
573
- if (method === "GET" || method === "HEAD" || method === "DELETE") {
574
- upstream.end();
575
- } else {
576
- req.pipe(upstream);
577
- }
746
+ if (body.length > 0) upstream.write(body);
747
+ upstream.end();
578
748
  // SSE / long-lived: tear down upstream when client disconnects.
579
749
  req.on("close", () => upstream.destroy());
580
750
  });
@@ -690,24 +860,31 @@ function nwireRunnerPlugin() {
690
860
 
691
861
  if (method === "POST" && pathname === "/__nwire/run/exec-script") {
692
862
  // Launch `pnpm run <script>` from the active project's cwd via
693
- // the shared supervisor. The `topology` label becomes `pnpm
863
+ // the shared supervisor. Body:
864
+ // { script, port?, env? }
865
+ //
866
+ // `port` defaults to an auto-allocated free port; pass an
867
+ // explicit number to pin a specific port. `env` is forwarded
868
+ // to the child process and merged into the existing env, so
869
+ // user overrides win. The `topology` label becomes `pnpm
694
870
  // <script>` so the existing UI renders it interchangeably with
695
871
  // topology + nwire-command rows.
696
872
  void (async () => {
697
- const body = (await readBody(req)) as { script?: string } | undefined;
873
+ const body = (await readBody(req)) as
874
+ | { script?: string; port?: number; env?: Record<string, string> }
875
+ | undefined;
698
876
  if (!body?.script) {
699
877
  return send(res, 400, { error: "body.script required" });
700
878
  }
701
879
  try {
702
- // Same auto-port story as topology start. Scripts that read
703
- // process.env.PORT pick it up; scripts that don't ignore it
704
- // harmlessly.
705
- const port = await findFreePort();
880
+ const port =
881
+ typeof body.port === "number" && body.port > 0 ? body.port : await findFreePort();
706
882
  const proc = await supervisor.start({
707
883
  topology: `pnpm ${body.script}`,
708
884
  cwd,
709
885
  command: ["pnpm", "run", body.script],
710
886
  port,
887
+ env: body.env,
711
888
  });
712
889
  return send(res, 200, { process: proc });
713
890
  } catch (err) {
@@ -737,15 +914,39 @@ function nwireRunnerPlugin() {
737
914
  }
738
915
 
739
916
  if (method === "GET" && pathname === "/__nwire/run/processes") {
740
- // Scoped to active project users see only processes started
741
- // from this project's cwd (managed via Studio's Run page).
742
- const processes = supervisor.list().filter((p) => (p as { cwd?: string }).cwd === cwd);
743
- return send(res, 200, { processes });
917
+ // Scoped to active project. Two sources are merged so the user
918
+ // sees one panel for every running process they care about:
919
+ //
920
+ // 1. Studio's in-process supervisor anything started via the
921
+ // Run page in *this* Studio session.
922
+ // 2. The project's `.nwire/processes/*.json` registry — written
923
+ // by `nwire dev` from another terminal. We dedup by pid, so
924
+ // a process Studio spawned isn't listed twice.
925
+ //
926
+ // External records are surfaced with `source: "external"` so
927
+ // the UI can show "started outside Studio" hints and SSE-stream
928
+ // log tailing reads the log file on disk for those.
929
+ const local = supervisor.list().filter((p) => p.cwd === cwd);
930
+ const localPids = new Set(local.map((p) => p.pid).filter((x): x is number => !!x));
931
+ const external = readExternalProcesses(cwd).filter(
932
+ (p) => p.pid > 0 && !localPids.has(p.pid),
933
+ );
934
+ const combined = [
935
+ ...local.map((p) => ({ ...p, source: "studio" as const })),
936
+ ...external.map((p) => ({ ...p, source: "external" as const })),
937
+ ];
938
+ return send(res, 200, { processes: combined });
744
939
  }
745
940
 
746
941
  if (method === "POST" && pathname === "/__nwire/run/start") {
747
942
  void (async () => {
748
- const body = (await readBody(req)) as { topology?: string; port?: number } | undefined;
943
+ const body = (await readBody(req)) as
944
+ | {
945
+ topology?: string;
946
+ port?: number;
947
+ env?: Record<string, string>;
948
+ }
949
+ | undefined;
749
950
  if (!body?.topology) {
750
951
  return send(res, 400, { error: "body.topology required" });
751
952
  }
@@ -758,6 +959,7 @@ function nwireRunnerPlugin() {
758
959
  topology: body.topology,
759
960
  port,
760
961
  cwd,
962
+ env: body.env,
761
963
  healthCheck: inspectHealthCheck(port),
762
964
  healthIntervalMs: 400,
763
965
  healthTimeoutMs: 60_000,
@@ -824,7 +1026,16 @@ function nwireRunnerPlugin() {
824
1026
  const id = logsRecentMatch[1]!;
825
1027
  const search = new URL(url, "http://x").searchParams;
826
1028
  const limit = search.get("limit") ? Number(search.get("limit")) : undefined;
827
- return send(res, 200, { logs: supervisor.recentLogs(id, limit) });
1029
+ // Studio-spawned: read the in-memory ring buffer.
1030
+ // External (from `nwire dev`): tail the log file from disk.
1031
+ if (supervisor.get(id)) {
1032
+ return send(res, 200, { logs: supervisor.recentLogs(id, limit) });
1033
+ }
1034
+ const ext = readExternalProcesses(cwd).find((p) => p.id === id);
1035
+ if (ext) {
1036
+ return send(res, 200, { logs: tailLogFile(ext.logPath, limit ?? 200) });
1037
+ }
1038
+ return send(res, 404, { error: "unknown process id" });
828
1039
  }
829
1040
 
830
1041
  const logsStreamMatch = /^\/__nwire\/run\/logs\/([^/]+)\/stream$/.exec(pathname);
@@ -834,7 +1045,37 @@ function nwireRunnerPlugin() {
834
1045
  res.setHeader("Content-Type", "text/event-stream");
835
1046
  res.setHeader("Cache-Control", "no-cache, no-transform");
836
1047
  res.setHeader("Connection", "keep-alive");
837
- // Backfill.
1048
+
1049
+ // External processes have no supervisor event source — we tail
1050
+ // the log file by polling its size. Studio-spawned processes
1051
+ // stream via the supervisor's `log` event.
1052
+ const ext = readExternalProcesses(cwd).find((p) => p.id === id);
1053
+ if (ext) {
1054
+ for (const line of tailLogFile(ext.logPath, 200)) {
1055
+ res.write(`data: ${JSON.stringify(line)}\n\n`);
1056
+ }
1057
+ const tail = openLogTail(ext.logPath, (line) => {
1058
+ try {
1059
+ res.write(`data: ${JSON.stringify(line)}\n\n`);
1060
+ } catch {
1061
+ // socket gone
1062
+ }
1063
+ });
1064
+ const keepalive = setInterval(() => {
1065
+ try {
1066
+ res.write(": keepalive\n\n");
1067
+ } catch {
1068
+ // socket gone
1069
+ }
1070
+ }, 25_000);
1071
+ req.on("close", () => {
1072
+ tail.stop();
1073
+ clearInterval(keepalive);
1074
+ });
1075
+ return;
1076
+ }
1077
+
1078
+ // Studio-spawned path.
838
1079
  for (const line of supervisor.recentLogs(id, 200)) {
839
1080
  res.write(`data: ${JSON.stringify(line)}\n\n`);
840
1081
  }
@@ -1,174 +0,0 @@
1
- <script setup lang="ts">
2
- import { nextTick, onMounted, ref, watch } from "vue";
3
- import { useRoute, useRouter } from "vue-router";
4
- import { useCache } from "@/lib/cache";
5
- import { Boxes, ArrowDownRight, ArrowUpRight } from "lucide-vue-next";
6
-
7
- const route = useRoute();
8
- const router = useRouter();
9
- const { cache } = useCache();
10
-
11
- /**
12
- * Preselect — /modules?name=<moduleName>. The page is grid-of-cards (no
13
- * master/detail), so "preselect" means: highlight the card + scroll it
14
- * into view. First module with the matching name wins (modules are unique
15
- * within an app, and module-name collisions across apps are rare in
16
- * practice — if it ever happens the operator can disambiguate visually).
17
- */
18
- const selected = ref<string | null>(null);
19
-
20
- function applyQueryPreselect(): Promise<void> | void {
21
- const name = route.query.name;
22
- if (typeof name !== "string" || name.length === 0) {
23
- selected.value = null;
24
- return;
25
- }
26
- const found = cache.value?.modules.find((m) => m.name === name);
27
- if (!found) return;
28
- const key = `${found.app}::${found.name}`;
29
- selected.value = key;
30
- return nextTick(() => {
31
- const el = document.querySelector(`[data-module-key="${key}"]`);
32
- if (el instanceof HTMLElement) {
33
- el.scrollIntoView({ block: "center", behavior: "smooth" });
34
- }
35
- });
36
- }
37
-
38
- onMounted(() => {
39
- void applyQueryPreselect();
40
- });
41
- watch(
42
- () => route.query.name,
43
- () => {
44
- void applyQueryPreselect();
45
- },
46
- );
47
- watch(
48
- () => cache.value,
49
- () => {
50
- void applyQueryPreselect();
51
- },
52
- );
53
-
54
- const moduleKey = (m: { app: string; name: string }) => `${m.app}::${m.name}`;
55
-
56
- function openEvent(name: string): void {
57
- void router.push({ path: "/events", query: { name } });
58
- }
59
- function openAction(name: string): void {
60
- void router.push({ path: "/actions", query: { name } });
61
- }
62
- </script>
63
-
64
- <template>
65
- <div v-if="cache" class="p-6 space-y-3">
66
- <div>
67
- <h1 class="text-2xl font-semibold tracking-tight">Modules</h1>
68
- <p class="text-sm text-zinc-500 mt-1">Bounded contexts and their dependency graph</p>
69
- </div>
70
-
71
- <div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
72
- <div
73
- v-for="m in cache.modules"
74
- :key="moduleKey(m)"
75
- :data-module-key="moduleKey(m)"
76
- :data-testid="`module-card-${m.name}`"
77
- class="rounded-lg border bg-zinc-900/30 px-4 py-3 space-y-2 transition-colors"
78
- :class="
79
- selected === moduleKey(m)
80
- ? 'border-emerald-500/70 ring-1 ring-emerald-500/40'
81
- : 'border-zinc-800'
82
- "
83
- >
84
- <div class="flex items-center justify-between">
85
- <div class="flex items-center gap-2">
86
- <Boxes class="w-4 h-4 text-emerald-400" />
87
- <span class="font-mono font-medium">{{ m.name }}</span>
88
- <span
89
- class="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-zinc-800 text-zinc-400"
90
- >
91
- {{ m.app }}
92
- </span>
93
- </div>
94
- <div class="text-xs text-zinc-500 tabular-nums" data-testid="module-counts">
95
- {{ m.counts.actions }}A · {{ m.counts.events }}E · {{ m.counts.actors }}@ ·
96
- {{ m.counts.projections }}P · {{ m.counts.queries }}Q · {{ m.counts.workflows }}W
97
- </div>
98
- </div>
99
-
100
- <div v-if="m.provides.events.length || m.provides.actions.length" class="text-xs">
101
- <div class="flex items-center gap-1 text-zinc-500 mb-1">
102
- <ArrowUpRight class="w-3 h-3 text-emerald-400" />
103
- Provides
104
- </div>
105
- <div class="flex flex-wrap gap-1 pl-4">
106
- <button
107
- v-for="e in m.provides.events"
108
- :key="`p-e-${e}`"
109
- type="button"
110
- class="font-mono text-[10px] px-1.5 py-0.5 rounded bg-purple-950/30 border border-purple-900/50 text-purple-300 hover:bg-purple-900/40"
111
- :data-testid="`event-link-${e}`"
112
- @click="openEvent(e)"
113
- >
114
- {{ e }}
115
- </button>
116
- <button
117
- v-for="a in m.provides.actions"
118
- :key="`p-a-${a}`"
119
- type="button"
120
- class="font-mono text-[10px] px-1.5 py-0.5 rounded bg-amber-950/30 border border-amber-900/50 text-amber-300 hover:bg-amber-900/40"
121
- :data-testid="`action-link-${a}`"
122
- @click="openAction(a)"
123
- >
124
- {{ a }}
125
- </button>
126
- </div>
127
- </div>
128
-
129
- <div
130
- v-if="m.needs.events.length || m.needs.externalEvents.length || m.needs.actions.length"
131
- class="text-xs"
132
- >
133
- <div class="flex items-center gap-1 text-zinc-500 mb-1">
134
- <ArrowDownRight class="w-3 h-3 text-blue-400" />
135
- Needs
136
- </div>
137
- <div class="flex flex-wrap gap-1 pl-4">
138
- <button
139
- v-for="e in m.needs.events"
140
- :key="`n-e-${e}`"
141
- type="button"
142
- class="font-mono text-[10px] px-1.5 py-0.5 rounded bg-zinc-900 border border-zinc-700 text-zinc-300 hover:bg-zinc-800"
143
- :data-testid="`event-link-${e}`"
144
- @click="openEvent(e)"
145
- >
146
- {{ e }}
147
- </button>
148
- <button
149
- v-for="e in m.needs.externalEvents"
150
- :key="`n-x-${e}`"
151
- type="button"
152
- class="font-mono text-[10px] px-1.5 py-0.5 rounded bg-blue-950/30 border border-blue-900/50 text-blue-300 hover:bg-blue-900/40"
153
- :data-testid="`event-link-${e}`"
154
- title="cross-service"
155
- @click="openEvent(e)"
156
- >
157
- {{ e }} ⨯
158
- </button>
159
- <button
160
- v-for="a in m.needs.actions"
161
- :key="`n-a-${a}`"
162
- type="button"
163
- class="font-mono text-[10px] px-1.5 py-0.5 rounded bg-zinc-900 border border-zinc-700 text-zinc-300 hover:bg-zinc-800"
164
- :data-testid="`action-link-${a}`"
165
- @click="openAction(a)"
166
- >
167
- {{ a }}
168
- </button>
169
- </div>
170
- </div>
171
- </div>
172
- </div>
173
- </div>
174
- </template>