@nwire/studio 0.9.1
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/LICENSE +21 -0
- package/README.md +72 -0
- package/components.json +19 -0
- package/index.html +12 -0
- package/package.json +66 -0
- package/src/App.vue +305 -0
- package/src/components/EmptyState.stories.ts +53 -0
- package/src/components/EmptyState.vue +28 -0
- package/src/components/ErrorBoundary.vue +60 -0
- package/src/components/FilterInput.stories.ts +32 -0
- package/src/components/FilterInput.vue +33 -0
- package/src/components/JsonView.stories.ts +38 -0
- package/src/components/JsonView.vue +34 -0
- package/src/components/KindBadge.stories.ts +72 -0
- package/src/components/KindBadge.vue +59 -0
- package/src/components/ListRow.stories.ts +56 -0
- package/src/components/ListRow.vue +48 -0
- package/src/components/MasterDetail.stories.ts +74 -0
- package/src/components/MasterDetail.vue +35 -0
- package/src/components/MonacoViewer.vue +143 -0
- package/src/components/PageHeader.stories.ts +45 -0
- package/src/components/PageHeader.vue +46 -0
- package/src/components/SchemaNode.vue +208 -0
- package/src/components/SchemaTree.vue +65 -0
- package/src/components/SourceDrawer.vue +136 -0
- package/src/components/SourcePill.vue +103 -0
- package/src/components/__tests__/EmptyState.test.ts +28 -0
- package/src/components/__tests__/ErrorBoundary.test.ts +52 -0
- package/src/components/__tests__/FilterInput.test.ts +38 -0
- package/src/components/__tests__/JsonView.test.ts +33 -0
- package/src/components/__tests__/KindBadge.test.ts +39 -0
- package/src/components/__tests__/ListRow.test.ts +39 -0
- package/src/components/__tests__/MasterDetail.test.ts +40 -0
- package/src/components/__tests__/PageHeader.test.ts +42 -0
- package/src/components/index.ts +17 -0
- package/src/components/ui/badge/Badge.vue +17 -0
- package/src/components/ui/badge/index.ts +25 -0
- package/src/components/ui/button/Button.vue +28 -0
- package/src/components/ui/button/index.ts +34 -0
- package/src/components/ui/card/Card.vue +14 -0
- package/src/components/ui/card/CardContent.vue +14 -0
- package/src/components/ui/card/CardDescription.vue +14 -0
- package/src/components/ui/card/CardFooter.vue +14 -0
- package/src/components/ui/card/CardHeader.vue +14 -0
- package/src/components/ui/card/CardTitle.vue +14 -0
- package/src/components/ui/card/index.ts +6 -0
- package/src/components/ui/dialog/Dialog.vue +15 -0
- package/src/components/ui/dialog/DialogClose.vue +12 -0
- package/src/components/ui/dialog/DialogContent.vue +47 -0
- package/src/components/ui/dialog/DialogDescription.vue +22 -0
- package/src/components/ui/dialog/DialogFooter.vue +12 -0
- package/src/components/ui/dialog/DialogHeader.vue +14 -0
- package/src/components/ui/dialog/DialogScrollContent.vue +60 -0
- package/src/components/ui/dialog/DialogTitle.vue +22 -0
- package/src/components/ui/dialog/DialogTrigger.vue +12 -0
- package/src/components/ui/dialog/index.ts +9 -0
- package/src/components/ui/input/Input.vue +32 -0
- package/src/components/ui/input/index.ts +1 -0
- package/src/components/ui/scroll-area/ScrollArea.vue +22 -0
- package/src/components/ui/scroll-area/ScrollBar.vue +32 -0
- package/src/components/ui/scroll-area/index.ts +2 -0
- package/src/components/ui/separator/Separator.vue +27 -0
- package/src/components/ui/separator/index.ts +1 -0
- package/src/components/ui/skeleton/Skeleton.vue +14 -0
- package/src/components/ui/skeleton/index.ts +1 -0
- package/src/components/ui/tabs/Tabs.vue +15 -0
- package/src/components/ui/tabs/TabsContent.vue +25 -0
- package/src/components/ui/tabs/TabsList.vue +25 -0
- package/src/components/ui/tabs/TabsTrigger.vue +29 -0
- package/src/components/ui/tabs/index.ts +4 -0
- package/src/components/ui/tooltip/Tooltip.vue +15 -0
- package/src/components/ui/tooltip/TooltipContent.vue +40 -0
- package/src/components/ui/tooltip/TooltipProvider.vue +12 -0
- package/src/components/ui/tooltip/TooltipTrigger.vue +12 -0
- package/src/components/ui/tooltip/index.ts +4 -0
- package/src/composables/useCopy.ts +31 -0
- package/src/lib/__tests__/normalize-cache.test.ts +104 -0
- package/src/lib/cache.ts +334 -0
- package/src/lib/normalize-cache.ts +92 -0
- package/src/lib/project-catalog.ts +125 -0
- package/src/lib/utils.ts +6 -0
- package/src/main.ts +112 -0
- package/src/pages/Actions.vue +180 -0
- package/src/pages/Commands.vue +262 -0
- package/src/pages/Dispatch.vue +431 -0
- package/src/pages/Events.vue +166 -0
- package/src/pages/Home.stories.ts +47 -0
- package/src/pages/Home.vue +485 -0
- package/src/pages/Hooks.vue +297 -0
- package/src/pages/Live.vue +249 -0
- package/src/pages/Modules.vue +174 -0
- package/src/pages/Overview.vue +159 -0
- package/src/pages/Plugins.stories.ts +44 -0
- package/src/pages/Plugins.vue +403 -0
- package/src/pages/Projects.vue +272 -0
- package/src/pages/Run.vue +479 -0
- package/src/pages/Topology.vue +164 -0
- package/src/pages/Trace.vue +511 -0
- package/src/pages/TraceNode.vue +166 -0
- package/src/pages/Workflows.vue +191 -0
- package/src/pages/__tests__/Actions.test.ts +98 -0
- package/src/pages/__tests__/Home.test.ts +98 -0
- package/src/pages/__tests__/Hooks.test.ts +119 -0
- package/src/pages/__tests__/Plugins.test.ts +80 -0
- package/src/style.css +40 -0
- package/tsconfig.json +20 -0
- package/vite.config.ts +892 -0
package/src/lib/cache.ts
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
// Studio data layer — fetch + cache the `.nwire/manifest.json` (built by
|
|
2
|
+
// `nwire cache`). The shape mirrors `@nwire/scan`'s Cache interface but we
|
|
3
|
+
// duplicate it locally so the Studio package doesn't need to depend on
|
|
4
|
+
// scan at runtime (the manifest is just JSON).
|
|
5
|
+
|
|
6
|
+
import { ref, shallowRef, type Ref } from "vue";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `file:line:column` of a `defineX()` call. Studio uses it to render
|
|
10
|
+
* IDE-open chips ("source" pill) next to every primitive.
|
|
11
|
+
*/
|
|
12
|
+
export interface SourceLocationEntry {
|
|
13
|
+
file: string;
|
|
14
|
+
line: number;
|
|
15
|
+
column?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ActionEntry {
|
|
19
|
+
name: string;
|
|
20
|
+
description?: string;
|
|
21
|
+
module: string;
|
|
22
|
+
app: string;
|
|
23
|
+
schema: object;
|
|
24
|
+
retry?: object;
|
|
25
|
+
policy?: string | readonly string[];
|
|
26
|
+
hasInlineHandler: boolean;
|
|
27
|
+
emits: string[];
|
|
28
|
+
/** True when the module's manifest marked this action `.public()`. */
|
|
29
|
+
public?: boolean;
|
|
30
|
+
persona?: string;
|
|
31
|
+
journeyStep?: string;
|
|
32
|
+
capability?: string;
|
|
33
|
+
slo?: { p95LatencyMs?: number; successRate?: number };
|
|
34
|
+
tags?: string[];
|
|
35
|
+
source?: SourceLocationEntry;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ExternalCallEntry {
|
|
39
|
+
name: string;
|
|
40
|
+
description?: string;
|
|
41
|
+
module: string;
|
|
42
|
+
app: string;
|
|
43
|
+
target: { provider: string; endpoint: string; region?: string };
|
|
44
|
+
request: object;
|
|
45
|
+
response?: object;
|
|
46
|
+
hasIdempotencyKey: boolean;
|
|
47
|
+
slo?: { p95LatencyMs?: number; successRate?: number };
|
|
48
|
+
retry?: { max: number; backoff?: string };
|
|
49
|
+
tags?: string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface InboundWebhookEntry {
|
|
53
|
+
name: string;
|
|
54
|
+
description?: string;
|
|
55
|
+
module: string;
|
|
56
|
+
app: string;
|
|
57
|
+
source: string;
|
|
58
|
+
path: string;
|
|
59
|
+
hasSignatureVerifier: boolean;
|
|
60
|
+
dedupe?: { window: string };
|
|
61
|
+
discriminator: string;
|
|
62
|
+
routes: Record<string, string>;
|
|
63
|
+
tags?: string[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface OutboxEntry {
|
|
67
|
+
name: string;
|
|
68
|
+
description?: string;
|
|
69
|
+
module: string;
|
|
70
|
+
app: string;
|
|
71
|
+
publishes: string[];
|
|
72
|
+
flushIntervalMs: number;
|
|
73
|
+
maxBatch: number;
|
|
74
|
+
tags?: string[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface InboxEntry {
|
|
78
|
+
name: string;
|
|
79
|
+
description?: string;
|
|
80
|
+
module: string;
|
|
81
|
+
app: string;
|
|
82
|
+
window: string;
|
|
83
|
+
on: string[];
|
|
84
|
+
tags?: string[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface CronEntry {
|
|
88
|
+
name: string;
|
|
89
|
+
description?: string;
|
|
90
|
+
module: string;
|
|
91
|
+
app: string;
|
|
92
|
+
schedule: string;
|
|
93
|
+
dispatches: string;
|
|
94
|
+
timezone?: string;
|
|
95
|
+
tags?: string[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Declared workflow (the unified primitive for reactions, translators, and
|
|
100
|
+
* sagas). EventStorming renders each as a synthesized policy node fanning
|
|
101
|
+
* out from its subscribed events to its declared dispatches.
|
|
102
|
+
*/
|
|
103
|
+
export interface WorkflowEntry {
|
|
104
|
+
name: string;
|
|
105
|
+
module: string;
|
|
106
|
+
app: string;
|
|
107
|
+
description?: string;
|
|
108
|
+
subscribesTo: string[];
|
|
109
|
+
dispatches: string[];
|
|
110
|
+
/** True when the module's manifest marked this workflow `.public()`. */
|
|
111
|
+
public?: boolean;
|
|
112
|
+
source?: SourceLocationEntry;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface EventEntry {
|
|
116
|
+
name: string;
|
|
117
|
+
description?: string;
|
|
118
|
+
module: string;
|
|
119
|
+
app: string;
|
|
120
|
+
visibility: "public" | "internal";
|
|
121
|
+
/** True when the module's manifest marked this event `.public()`. */
|
|
122
|
+
public?: boolean;
|
|
123
|
+
schema: object;
|
|
124
|
+
outcome?: "success" | "failure" | "milestone" | "warning";
|
|
125
|
+
businessWeight?: number;
|
|
126
|
+
audience?: string[];
|
|
127
|
+
source?: SourceLocationEntry;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface ActorEntry {
|
|
131
|
+
name: string;
|
|
132
|
+
module: string;
|
|
133
|
+
app: string;
|
|
134
|
+
key: string;
|
|
135
|
+
initial: string;
|
|
136
|
+
states: Array<{
|
|
137
|
+
name: string;
|
|
138
|
+
final?: boolean;
|
|
139
|
+
on: Array<{ eventName: string; target?: string }>;
|
|
140
|
+
after: Array<{ timerName: string; action: string; delay: string }>;
|
|
141
|
+
}>;
|
|
142
|
+
methods: string[];
|
|
143
|
+
schema: object;
|
|
144
|
+
stuckThresholds?: Record<string, number>;
|
|
145
|
+
slas?: Record<string, { maxDurationMs: number; escalateTo?: string }>;
|
|
146
|
+
source?: SourceLocationEntry;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface ProjectionEntry {
|
|
150
|
+
name: string;
|
|
151
|
+
module: string;
|
|
152
|
+
app: string;
|
|
153
|
+
listens: string[];
|
|
154
|
+
description?: string;
|
|
155
|
+
freshness?: { p95MsBehindStream?: number };
|
|
156
|
+
source?: SourceLocationEntry;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface QueryEntry {
|
|
160
|
+
name: string;
|
|
161
|
+
description?: string;
|
|
162
|
+
module: string;
|
|
163
|
+
app: string;
|
|
164
|
+
projection: string;
|
|
165
|
+
schema: object;
|
|
166
|
+
/** True when the module's manifest marked this query `.public()`. */
|
|
167
|
+
public?: boolean;
|
|
168
|
+
slo?: { p95LatencyMs?: number };
|
|
169
|
+
cacheable?: boolean;
|
|
170
|
+
source?: SourceLocationEntry;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface ModuleEntry {
|
|
174
|
+
name: string;
|
|
175
|
+
app: string;
|
|
176
|
+
provides: { events: string[]; actions: string[] };
|
|
177
|
+
needs: { events: string[]; externalEvents: string[]; actions: string[] };
|
|
178
|
+
counts: {
|
|
179
|
+
actions: number;
|
|
180
|
+
actors: number;
|
|
181
|
+
projections: number;
|
|
182
|
+
queries: number;
|
|
183
|
+
workflows: number;
|
|
184
|
+
events: number;
|
|
185
|
+
routes: number;
|
|
186
|
+
};
|
|
187
|
+
description?: string;
|
|
188
|
+
owners?: string[];
|
|
189
|
+
journey?: { id: string; label: string; description?: string }[];
|
|
190
|
+
source?: SourceLocationEntry;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export interface AppEntry {
|
|
194
|
+
name: string;
|
|
195
|
+
description?: string;
|
|
196
|
+
modules: string[];
|
|
197
|
+
tenantModel?: "single" | "per-org" | "per-account" | "per-workspace";
|
|
198
|
+
tenantKey?: string;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export interface RouteEntry {
|
|
202
|
+
method: string;
|
|
203
|
+
path: string;
|
|
204
|
+
target: string;
|
|
205
|
+
targetKind: "action" | "query" | "resolver";
|
|
206
|
+
module: string;
|
|
207
|
+
app: string;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export interface ResolverEntry {
|
|
211
|
+
operation: string;
|
|
212
|
+
version: number;
|
|
213
|
+
status: "draft" | "active" | "deprecated" | "sunset";
|
|
214
|
+
summary?: string;
|
|
215
|
+
description?: string;
|
|
216
|
+
tags?: string[];
|
|
217
|
+
app: string;
|
|
218
|
+
bindings: Array<{
|
|
219
|
+
transport: "rest" | "graphql" | "cli";
|
|
220
|
+
method?: string;
|
|
221
|
+
path?: string;
|
|
222
|
+
}>;
|
|
223
|
+
params?: object;
|
|
224
|
+
query?: object;
|
|
225
|
+
body?: object;
|
|
226
|
+
returns: Array<{ status: number; kind: "single" | "list" | "empty" }>;
|
|
227
|
+
errors: Array<{ code: string; status: number }>;
|
|
228
|
+
successor?: { operation: string; version: number };
|
|
229
|
+
sunsetDate?: string;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export interface EventGraphEdge {
|
|
233
|
+
event: string;
|
|
234
|
+
producer: { app: string; module: string };
|
|
235
|
+
consumers: Array<{
|
|
236
|
+
app: string;
|
|
237
|
+
module: string;
|
|
238
|
+
via: "workflow" | "projection" | "actor" | "external";
|
|
239
|
+
}>;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export interface HookEntry {
|
|
243
|
+
id: string;
|
|
244
|
+
name: string;
|
|
245
|
+
chain: number;
|
|
246
|
+
listeners: number;
|
|
247
|
+
source?: SourceLocationEntry;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export interface PluginEntry {
|
|
251
|
+
name: string;
|
|
252
|
+
kind: "plugin" | "module";
|
|
253
|
+
app: string;
|
|
254
|
+
source?: SourceLocationEntry;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* One DI registration on an app's container — surfaced by
|
|
259
|
+
* `container.list()` and emitted by `@nwire/scan` to `.nwire/di.json`.
|
|
260
|
+
* Studio's DI page renders these as a "what's bound + who registered it"
|
|
261
|
+
* table.
|
|
262
|
+
*/
|
|
263
|
+
export interface DIBindingEntry {
|
|
264
|
+
name: string;
|
|
265
|
+
kind: "singleton" | "transient";
|
|
266
|
+
app: string;
|
|
267
|
+
source?: SourceLocationEntry;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export interface Cache {
|
|
271
|
+
generatedAt: string;
|
|
272
|
+
apps: AppEntry[];
|
|
273
|
+
modules: ModuleEntry[];
|
|
274
|
+
actions: ActionEntry[];
|
|
275
|
+
events: EventEntry[];
|
|
276
|
+
actors: ActorEntry[];
|
|
277
|
+
projections: ProjectionEntry[];
|
|
278
|
+
queries: QueryEntry[];
|
|
279
|
+
resolvers: ResolverEntry[];
|
|
280
|
+
routes: RouteEntry[];
|
|
281
|
+
workflows: WorkflowEntry[];
|
|
282
|
+
externalCalls: ExternalCallEntry[];
|
|
283
|
+
inboundWebhooks: InboundWebhookEntry[];
|
|
284
|
+
outboxes: OutboxEntry[];
|
|
285
|
+
inboxes: InboxEntry[];
|
|
286
|
+
crons: CronEntry[];
|
|
287
|
+
hooks: HookEntry[];
|
|
288
|
+
plugins: PluginEntry[];
|
|
289
|
+
bindings: DIBindingEntry[];
|
|
290
|
+
graph: { events: EventGraphEdge[] };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
import { normalizeCache } from "./normalize-cache";
|
|
294
|
+
|
|
295
|
+
const cache: Ref<Cache | null> = shallowRef(null);
|
|
296
|
+
const loading = ref(false);
|
|
297
|
+
const error: Ref<string | null> = ref(null);
|
|
298
|
+
/**
|
|
299
|
+
* Fields the cache was missing — non-fatal. Studio renders the affected
|
|
300
|
+
* pages with empty states + shows a "rebuild cache" hint to the operator.
|
|
301
|
+
*/
|
|
302
|
+
const missingFields: Ref<readonly string[]> = ref([]);
|
|
303
|
+
|
|
304
|
+
export function useCache() {
|
|
305
|
+
if (!cache.value && !loading.value) {
|
|
306
|
+
void load();
|
|
307
|
+
}
|
|
308
|
+
return { cache, loading, error, missingFields, reload: load };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function load(): Promise<void> {
|
|
312
|
+
loading.value = true;
|
|
313
|
+
error.value = null;
|
|
314
|
+
missingFields.value = [];
|
|
315
|
+
try {
|
|
316
|
+
const res = await fetch("/__nwire/manifest.json");
|
|
317
|
+
if (!res.ok) {
|
|
318
|
+
error.value = `Cache fetch failed (${res.status}). Run \`nwire cache\` to build it.`;
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const raw = await res.json();
|
|
322
|
+
const result = normalizeCache(raw);
|
|
323
|
+
if (result.fatalError) {
|
|
324
|
+
error.value = result.fatalError;
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
cache.value = result.cache;
|
|
328
|
+
missingFields.value = result.missingFields;
|
|
329
|
+
} catch (e) {
|
|
330
|
+
error.value = (e as Error).message;
|
|
331
|
+
} finally {
|
|
332
|
+
loading.value = false;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cache normalizer — defends Studio against stale or partial
|
|
3
|
+
* `.nwire/manifest.json` payloads.
|
|
4
|
+
*
|
|
5
|
+
* Reality check: the manifest is generated by `@nwire/scan` and its schema
|
|
6
|
+
* evolves. A Studio served against a manifest built before a given field
|
|
7
|
+
* exists must not crash — pages that depend on that field should render
|
|
8
|
+
* an empty state, not a TypeError.
|
|
9
|
+
*
|
|
10
|
+
* The normalizer:
|
|
11
|
+
* 1. Returns `null` if the value isn't an object (Studio renders an error
|
|
12
|
+
* banner instead of throwing).
|
|
13
|
+
* 2. Fills every expected array field with `[]`.
|
|
14
|
+
* 3. Fills nested shapes (graph.events) with safe defaults.
|
|
15
|
+
* 4. Reports which fields were missing — surfaced as a warning so the
|
|
16
|
+
* operator knows the cache is stale and can rebuild.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { Cache } from "./cache";
|
|
20
|
+
|
|
21
|
+
export interface NormalizeResult {
|
|
22
|
+
/** The normalized cache, ready to render. `null` if the input is unusable. */
|
|
23
|
+
readonly cache: Cache | null;
|
|
24
|
+
/** Field paths that were missing in the input and filled with defaults. */
|
|
25
|
+
readonly missingFields: readonly string[];
|
|
26
|
+
/** Human-readable description of what made the input unusable. */
|
|
27
|
+
readonly fatalError?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const ARRAY_FIELDS = [
|
|
31
|
+
"apps",
|
|
32
|
+
"modules",
|
|
33
|
+
"actions",
|
|
34
|
+
"events",
|
|
35
|
+
"actors",
|
|
36
|
+
"projections",
|
|
37
|
+
"queries",
|
|
38
|
+
"resolvers",
|
|
39
|
+
"routes",
|
|
40
|
+
"workflows",
|
|
41
|
+
"externalCalls",
|
|
42
|
+
"inboundWebhooks",
|
|
43
|
+
"outboxes",
|
|
44
|
+
"inboxes",
|
|
45
|
+
"crons",
|
|
46
|
+
"hooks",
|
|
47
|
+
"plugins",
|
|
48
|
+
"bindings",
|
|
49
|
+
] as const;
|
|
50
|
+
|
|
51
|
+
export function normalizeCache(raw: unknown): NormalizeResult {
|
|
52
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
53
|
+
return {
|
|
54
|
+
cache: null,
|
|
55
|
+
missingFields: [],
|
|
56
|
+
fatalError: "Manifest is not a JSON object.",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const input = raw as Record<string, unknown>;
|
|
61
|
+
const missing: string[] = [];
|
|
62
|
+
|
|
63
|
+
const out: Record<string, unknown> = {
|
|
64
|
+
generatedAt:
|
|
65
|
+
typeof input.generatedAt === "string" ? input.generatedAt : new Date(0).toISOString(),
|
|
66
|
+
};
|
|
67
|
+
if (typeof input.generatedAt !== "string") missing.push("generatedAt");
|
|
68
|
+
|
|
69
|
+
for (const field of ARRAY_FIELDS) {
|
|
70
|
+
if (Array.isArray(input[field])) {
|
|
71
|
+
out[field] = input[field];
|
|
72
|
+
} else {
|
|
73
|
+
out[field] = [];
|
|
74
|
+
missing.push(field);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// graph.events — nested arrays
|
|
79
|
+
const rawGraph = input.graph as { events?: unknown } | undefined;
|
|
80
|
+
if (rawGraph && typeof rawGraph === "object" && Array.isArray(rawGraph.events)) {
|
|
81
|
+
out.graph = { events: rawGraph.events };
|
|
82
|
+
} else {
|
|
83
|
+
out.graph = { events: [] };
|
|
84
|
+
if (!rawGraph) missing.push("graph");
|
|
85
|
+
else missing.push("graph.events");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
cache: out as unknown as Cache,
|
|
90
|
+
missingFields: missing,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project catalog — a localStorage-backed directory of every Nwire project
|
|
3
|
+
* the user has opened in Studio. Each visit writes a snapshot so the
|
|
4
|
+
* `/projects` page can list "your projects" with stats even when the
|
|
5
|
+
* Studio instance for that project isn't currently running.
|
|
6
|
+
*
|
|
7
|
+
* Storage key: `nwire.projects` → Record<cwd, ProjectSnapshot>
|
|
8
|
+
*
|
|
9
|
+
* Snapshots are per-cwd so multiple worktrees of the same repo show up
|
|
10
|
+
* as siblings, which is what a polyrepo dev wants.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const STORAGE_KEY = "nwire.projects";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* What we remember about a project across visits. Everything but `cwd` +
|
|
17
|
+
* `name` is "best-known so far" — the snapshot the project's own Studio
|
|
18
|
+
* wrote on its last visit. The catalog page reconciles this against live
|
|
19
|
+
* status (running processes) fetched server-side from each cwd's
|
|
20
|
+
* `.nwire/processes/` registry.
|
|
21
|
+
*/
|
|
22
|
+
export interface ProjectSnapshot {
|
|
23
|
+
readonly cwd: string;
|
|
24
|
+
readonly name: string;
|
|
25
|
+
/** ISO timestamp of the last time this project's Studio was open. */
|
|
26
|
+
readonly lastVisited: string;
|
|
27
|
+
/** Composition counts from the last manifest seen. Optional — early
|
|
28
|
+
* visits before scan finishes may not have them. */
|
|
29
|
+
readonly composition?: {
|
|
30
|
+
readonly apps: number;
|
|
31
|
+
readonly modules: number;
|
|
32
|
+
readonly actions: number;
|
|
33
|
+
readonly events: number;
|
|
34
|
+
readonly resolvers?: number;
|
|
35
|
+
readonly workflows?: number;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Read every known project. Returns an empty record on first run. */
|
|
40
|
+
export function loadCatalog(): Record<string, ProjectSnapshot> {
|
|
41
|
+
if (typeof localStorage === "undefined") return {};
|
|
42
|
+
try {
|
|
43
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
44
|
+
if (!raw) return {};
|
|
45
|
+
const parsed = JSON.parse(raw) as Record<string, ProjectSnapshot>;
|
|
46
|
+
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
47
|
+
} catch {
|
|
48
|
+
return {};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Upsert this Studio's current project. Called on App mount whenever the
|
|
54
|
+
* `/__nwire/project` + cache fetches resolve. Existing snapshots are
|
|
55
|
+
* merged so we never lose the old composition during a fresh load.
|
|
56
|
+
*/
|
|
57
|
+
export function upsertCurrent(snapshot: ProjectSnapshot): void {
|
|
58
|
+
if (typeof localStorage === "undefined") return;
|
|
59
|
+
try {
|
|
60
|
+
const catalog = loadCatalog();
|
|
61
|
+
const prior = catalog[snapshot.cwd];
|
|
62
|
+
catalog[snapshot.cwd] = {
|
|
63
|
+
...prior,
|
|
64
|
+
...snapshot,
|
|
65
|
+
// Preserve the prior composition until the new visit produces one.
|
|
66
|
+
composition: snapshot.composition ?? prior?.composition,
|
|
67
|
+
};
|
|
68
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(catalog));
|
|
69
|
+
} catch {
|
|
70
|
+
// localStorage may be full or disabled; the catalog page just sees
|
|
71
|
+
// an empty entry next time.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Active-project state — which catalog entry every fetch is scoped to. Reads
|
|
77
|
+
* + writes through localStorage so a Studio refresh keeps the same view.
|
|
78
|
+
* Returns null when no choice has been recorded (caller should default to
|
|
79
|
+
* the launch-time project from `/__nwire/project`).
|
|
80
|
+
*/
|
|
81
|
+
const ACTIVE_KEY = "nwire.activeProject";
|
|
82
|
+
export function getActiveProjectCwd(): string | null {
|
|
83
|
+
if (typeof localStorage === "undefined") return null;
|
|
84
|
+
try {
|
|
85
|
+
return localStorage.getItem(ACTIVE_KEY);
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
export function setActiveProjectCwd(cwd: string | null): void {
|
|
91
|
+
if (typeof localStorage === "undefined") return;
|
|
92
|
+
try {
|
|
93
|
+
if (cwd) localStorage.setItem(ACTIVE_KEY, cwd);
|
|
94
|
+
else localStorage.removeItem(ACTIVE_KEY);
|
|
95
|
+
} catch {
|
|
96
|
+
// ignore
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* `fetch` wrapper that appends `?project=<cwd>` to every Studio-internal
|
|
102
|
+
* URL. The server's `targetCwd(req)` reads this and serves the right
|
|
103
|
+
* project's manifest, scripts, processes, etc. — single-project behavior
|
|
104
|
+
* is preserved when the active project happens to equal the launch one.
|
|
105
|
+
*
|
|
106
|
+
* Pass through `RequestInit` unchanged; we only mutate the URL.
|
|
107
|
+
*/
|
|
108
|
+
export function projectFetch(input: string, init?: RequestInit): Promise<Response> {
|
|
109
|
+
const cwd = getActiveProjectCwd();
|
|
110
|
+
if (!cwd) return fetch(input, init);
|
|
111
|
+
const sep = input.includes("?") ? "&" : "?";
|
|
112
|
+
return fetch(`${input}${sep}project=${encodeURIComponent(cwd)}`, init);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Remove a project from the catalog (Catalog page "Forget" action). */
|
|
116
|
+
export function forgetProject(cwd: string): void {
|
|
117
|
+
if (typeof localStorage === "undefined") return;
|
|
118
|
+
try {
|
|
119
|
+
const catalog = loadCatalog();
|
|
120
|
+
delete catalog[cwd];
|
|
121
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(catalog));
|
|
122
|
+
} catch {
|
|
123
|
+
// ignore
|
|
124
|
+
}
|
|
125
|
+
}
|
package/src/lib/utils.ts
ADDED
package/src/main.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { createApp } from "vue";
|
|
2
|
+
import { createRouter, createWebHistory } from "vue-router";
|
|
3
|
+
import App from "./App.vue";
|
|
4
|
+
import "./style.css";
|
|
5
|
+
import { getActiveProjectCwd, loadCatalog } from "./lib/project-catalog";
|
|
6
|
+
|
|
7
|
+
// Multi-project Studio (Shape A) — every Studio-internal URL needs to
|
|
8
|
+
// carry `?project=<cwd>` so the server routes it to the right project's
|
|
9
|
+
// manifest / processes / wire. Wrap `fetch` once at boot rather than
|
|
10
|
+
// touching every call site. Only the `/__nwire/*` and `/_nwire/*` paths
|
|
11
|
+
// are decorated; everything else (Vite HMR, third-party APIs) passes
|
|
12
|
+
// through unchanged.
|
|
13
|
+
const originalFetch = window.fetch.bind(window);
|
|
14
|
+
window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
|
|
15
|
+
const cwd = getActiveProjectCwd();
|
|
16
|
+
if (!cwd) return originalFetch(input as RequestInfo, init);
|
|
17
|
+
const url =
|
|
18
|
+
typeof input === "string"
|
|
19
|
+
? input
|
|
20
|
+
: input instanceof URL
|
|
21
|
+
? input.toString()
|
|
22
|
+
: (input as Request).url;
|
|
23
|
+
if (!url.startsWith("/__nwire/") && !url.startsWith("/_nwire/")) {
|
|
24
|
+
return originalFetch(input as RequestInfo, init);
|
|
25
|
+
}
|
|
26
|
+
const sep = url.includes("?") ? "&" : "?";
|
|
27
|
+
const next = `${url}${sep}project=${encodeURIComponent(cwd)}`;
|
|
28
|
+
return originalFetch(next, init);
|
|
29
|
+
}) as typeof window.fetch;
|
|
30
|
+
|
|
31
|
+
// EventSource is used for SSE (Run logs, live event stream, trace
|
|
32
|
+
// telemetry). It bypasses fetch, so we patch its constructor to inject
|
|
33
|
+
// the same query param. Same allowlist as the fetch wrapper.
|
|
34
|
+
const OriginalEventSource = window.EventSource;
|
|
35
|
+
class ScopedEventSource extends OriginalEventSource {
|
|
36
|
+
constructor(url: string | URL, init?: EventSourceInit) {
|
|
37
|
+
const cwd = getActiveProjectCwd();
|
|
38
|
+
const str = typeof url === "string" ? url : url.toString();
|
|
39
|
+
if (cwd && (str.startsWith("/__nwire/") || str.startsWith("/_nwire/"))) {
|
|
40
|
+
const sep = str.includes("?") ? "&" : "?";
|
|
41
|
+
super(`${str}${sep}project=${encodeURIComponent(cwd)}`, init);
|
|
42
|
+
} else {
|
|
43
|
+
super(url, init);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
(window as unknown as { EventSource: typeof OriginalEventSource }).EventSource = ScopedEventSource;
|
|
48
|
+
|
|
49
|
+
import Home from "./pages/Home.vue";
|
|
50
|
+
import Overview from "./pages/Overview.vue";
|
|
51
|
+
import Topology from "./pages/Topology.vue";
|
|
52
|
+
import Trace from "./pages/Trace.vue";
|
|
53
|
+
import Actions from "./pages/Actions.vue";
|
|
54
|
+
import Events from "./pages/Events.vue";
|
|
55
|
+
import Modules from "./pages/Modules.vue";
|
|
56
|
+
import Workflows from "./pages/Workflows.vue";
|
|
57
|
+
import Hooks from "./pages/Hooks.vue";
|
|
58
|
+
import Plugins from "./pages/Plugins.vue";
|
|
59
|
+
import Live from "./pages/Live.vue";
|
|
60
|
+
import Dispatch from "./pages/Dispatch.vue";
|
|
61
|
+
import Run from "./pages/Run.vue";
|
|
62
|
+
import Commands from "./pages/Commands.vue";
|
|
63
|
+
import Projects from "./pages/Projects.vue";
|
|
64
|
+
|
|
65
|
+
const router = createRouter({
|
|
66
|
+
history: createWebHistory(),
|
|
67
|
+
routes: [
|
|
68
|
+
{ path: "/", component: Home, name: "home" },
|
|
69
|
+
{ path: "/overview", component: Overview, name: "overview" },
|
|
70
|
+
{ path: "/topology", component: Topology, name: "topology" },
|
|
71
|
+
{ path: "/trace", component: Trace, name: "trace" },
|
|
72
|
+
// Back-compat redirect — the page formerly known as EventStorm.
|
|
73
|
+
{ path: "/eventstorm", redirect: "/trace" },
|
|
74
|
+
{ path: "/modules", component: Modules, name: "modules" },
|
|
75
|
+
{ path: "/actions", component: Actions, name: "actions" },
|
|
76
|
+
{ path: "/events", component: Events, name: "events" },
|
|
77
|
+
{ path: "/workflows", component: Workflows, name: "workflows" },
|
|
78
|
+
{ path: "/hooks", component: Hooks, name: "hooks" },
|
|
79
|
+
{ path: "/plugins", component: Plugins, name: "plugins" },
|
|
80
|
+
{ path: "/dispatch", component: Dispatch, name: "dispatch" },
|
|
81
|
+
{ path: "/live", component: Live, name: "live" },
|
|
82
|
+
{ path: "/run", component: Run, name: "run" },
|
|
83
|
+
{ path: "/commands", component: Commands, name: "commands" },
|
|
84
|
+
{ path: "/projects", component: Projects, name: "projects" },
|
|
85
|
+
],
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Register every catalog cwd with the running Studio process BEFORE mounting
|
|
90
|
+
* Vue. The fetch shim above appends `?project=<cwd>` to every nwire request,
|
|
91
|
+
* and the server's allowlist only includes `NWIRE_CWD` by default — so the
|
|
92
|
+
* first cache fetch from useCache() would 400 without this hand-shake.
|
|
93
|
+
*/
|
|
94
|
+
async function bootstrap() {
|
|
95
|
+
const catalog = loadCatalog();
|
|
96
|
+
const cwds = Object.keys(catalog);
|
|
97
|
+
const active = getActiveProjectCwd();
|
|
98
|
+
if (active && !cwds.includes(active)) cwds.push(active);
|
|
99
|
+
await Promise.all(
|
|
100
|
+
cwds.map((cwd) =>
|
|
101
|
+
originalFetch("/__nwire/projects/register", {
|
|
102
|
+
method: "POST",
|
|
103
|
+
headers: { "Content-Type": "application/json" },
|
|
104
|
+
body: JSON.stringify({ cwd }),
|
|
105
|
+
}).catch(() => {
|
|
106
|
+
/* stale localStorage entry — server rejects; catalog page lets user forget */
|
|
107
|
+
}),
|
|
108
|
+
),
|
|
109
|
+
);
|
|
110
|
+
createApp(App).use(router).mount("#app");
|
|
111
|
+
}
|
|
112
|
+
void bootstrap();
|