@nwire/studio 0.10.1 → 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
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 {
|
|
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
|
|
72
|
-
* the
|
|
73
|
-
*
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
569
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
703
|
-
|
|
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
|
|
741
|
-
//
|
|
742
|
-
|
|
743
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/pages/Modules.vue
DELETED
|
@@ -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>
|