@nwire/studio 0.10.1 → 0.11.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/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/src/lib/cache.ts
CHANGED
|
@@ -18,14 +18,16 @@ export interface SourceLocationEntry {
|
|
|
18
18
|
export interface ActionEntry {
|
|
19
19
|
name: string;
|
|
20
20
|
description?: string;
|
|
21
|
-
module: string;
|
|
22
21
|
app: string;
|
|
23
|
-
schema
|
|
22
|
+
/** Zod input schema serialized by the scanner. */
|
|
23
|
+
inputSchema?: unknown;
|
|
24
24
|
retry?: object;
|
|
25
25
|
policy?: string | readonly string[];
|
|
26
|
+
/** True when a handler was wired via defineAction({handler}). */
|
|
26
27
|
hasInlineHandler: boolean;
|
|
28
|
+
/** Event names this action emits, in declaration order. */
|
|
27
29
|
emits: string[];
|
|
28
|
-
/** True when the
|
|
30
|
+
/** True when the action def carries the `.public()` mark. */
|
|
29
31
|
public?: boolean;
|
|
30
32
|
persona?: string;
|
|
31
33
|
journeyStep?: string;
|
|
@@ -38,61 +40,60 @@ export interface ActionEntry {
|
|
|
38
40
|
export interface ExternalCallEntry {
|
|
39
41
|
name: string;
|
|
40
42
|
description?: string;
|
|
41
|
-
module: string;
|
|
42
43
|
app: string;
|
|
43
|
-
target
|
|
44
|
-
request
|
|
44
|
+
target?: { provider: string; endpoint: string; region?: string };
|
|
45
|
+
request?: object;
|
|
45
46
|
response?: object;
|
|
46
|
-
hasIdempotencyKey
|
|
47
|
+
hasIdempotencyKey?: boolean;
|
|
47
48
|
slo?: { p95LatencyMs?: number; successRate?: number };
|
|
48
49
|
retry?: { max: number; backoff?: string };
|
|
49
50
|
tags?: string[];
|
|
51
|
+
source?: SourceLocationEntry;
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
export interface InboundWebhookEntry {
|
|
53
55
|
name: string;
|
|
54
56
|
description?: string;
|
|
55
|
-
module: string;
|
|
56
57
|
app: string;
|
|
57
|
-
source
|
|
58
|
-
path
|
|
59
|
-
hasSignatureVerifier
|
|
58
|
+
source?: SourceLocationEntry;
|
|
59
|
+
path?: string;
|
|
60
|
+
hasSignatureVerifier?: boolean;
|
|
60
61
|
dedupe?: { window: string };
|
|
61
|
-
discriminator
|
|
62
|
-
routes
|
|
62
|
+
discriminator?: string;
|
|
63
|
+
routes?: Record<string, string>;
|
|
63
64
|
tags?: string[];
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
export interface OutboxEntry {
|
|
67
68
|
name: string;
|
|
68
69
|
description?: string;
|
|
69
|
-
module: string;
|
|
70
70
|
app: string;
|
|
71
|
-
publishes
|
|
72
|
-
flushIntervalMs
|
|
73
|
-
maxBatch
|
|
71
|
+
publishes?: string[];
|
|
72
|
+
flushIntervalMs?: number;
|
|
73
|
+
maxBatch?: number;
|
|
74
74
|
tags?: string[];
|
|
75
|
+
source?: SourceLocationEntry;
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
export interface InboxEntry {
|
|
78
79
|
name: string;
|
|
79
80
|
description?: string;
|
|
80
|
-
module: string;
|
|
81
81
|
app: string;
|
|
82
|
-
window
|
|
83
|
-
on
|
|
82
|
+
window?: string;
|
|
83
|
+
on?: string[];
|
|
84
84
|
tags?: string[];
|
|
85
|
+
source?: SourceLocationEntry;
|
|
85
86
|
}
|
|
86
87
|
|
|
87
88
|
export interface CronEntry {
|
|
88
89
|
name: string;
|
|
89
90
|
description?: string;
|
|
90
|
-
module: string;
|
|
91
91
|
app: string;
|
|
92
92
|
schedule: string;
|
|
93
|
-
dispatches
|
|
93
|
+
dispatches?: string;
|
|
94
94
|
timezone?: string;
|
|
95
95
|
tags?: string[];
|
|
96
|
+
source?: SourceLocationEntry;
|
|
96
97
|
}
|
|
97
98
|
|
|
98
99
|
/**
|
|
@@ -102,12 +103,13 @@ export interface CronEntry {
|
|
|
102
103
|
*/
|
|
103
104
|
export interface WorkflowEntry {
|
|
104
105
|
name: string;
|
|
105
|
-
module: string;
|
|
106
106
|
app: string;
|
|
107
107
|
description?: string;
|
|
108
|
+
/** Event names this workflow listens to. */
|
|
108
109
|
subscribesTo: string[];
|
|
110
|
+
/** Action names this workflow dispatches inside its body. */
|
|
109
111
|
dispatches: string[];
|
|
110
|
-
/** True when the
|
|
112
|
+
/** True when the workflow def carries the `.public()` mark. */
|
|
111
113
|
public?: boolean;
|
|
112
114
|
source?: SourceLocationEntry;
|
|
113
115
|
}
|
|
@@ -115,43 +117,28 @@ export interface WorkflowEntry {
|
|
|
115
117
|
export interface EventEntry {
|
|
116
118
|
name: string;
|
|
117
119
|
description?: string;
|
|
118
|
-
module: string;
|
|
119
120
|
app: string;
|
|
120
|
-
|
|
121
|
-
/** True when the module's manifest marked this event `.public()`. */
|
|
121
|
+
/** True when the event def carries the `.public()` mark — reaches outbound sinks. */
|
|
122
122
|
public?: boolean;
|
|
123
|
-
|
|
124
|
-
outcome?: "success" | "failure" | "milestone" | "warning";
|
|
125
|
-
businessWeight?: number;
|
|
123
|
+
version?: number;
|
|
126
124
|
audience?: string[];
|
|
127
125
|
source?: SourceLocationEntry;
|
|
128
126
|
}
|
|
129
127
|
|
|
130
128
|
export interface ActorEntry {
|
|
131
129
|
name: string;
|
|
132
|
-
module: string;
|
|
133
130
|
app: string;
|
|
134
|
-
|
|
135
|
-
|
|
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 }>;
|
|
131
|
+
/** State names declared in the actor's `states` map. */
|
|
132
|
+
states: string[];
|
|
146
133
|
source?: SourceLocationEntry;
|
|
147
134
|
}
|
|
148
135
|
|
|
149
136
|
export interface ProjectionEntry {
|
|
150
137
|
name: string;
|
|
151
|
-
module: string;
|
|
152
138
|
app: string;
|
|
153
|
-
listens: string[];
|
|
154
139
|
description?: string;
|
|
140
|
+
/** Event names this projection folds in. */
|
|
141
|
+
listens: string[];
|
|
155
142
|
freshness?: { p95MsBehindStream?: number };
|
|
156
143
|
source?: SourceLocationEntry;
|
|
157
144
|
}
|
|
@@ -159,52 +146,40 @@ export interface ProjectionEntry {
|
|
|
159
146
|
export interface QueryEntry {
|
|
160
147
|
name: string;
|
|
161
148
|
description?: string;
|
|
162
|
-
module: string;
|
|
163
149
|
app: string;
|
|
164
|
-
projection
|
|
165
|
-
|
|
166
|
-
/** True when the
|
|
150
|
+
/** Backing projection name (projection-form queries only). */
|
|
151
|
+
projection?: string;
|
|
152
|
+
/** True when the query def carries the `.public()` mark. */
|
|
167
153
|
public?: boolean;
|
|
168
154
|
slo?: { p95LatencyMs?: number };
|
|
169
155
|
cacheable?: boolean;
|
|
170
156
|
source?: SourceLocationEntry;
|
|
171
157
|
}
|
|
172
158
|
|
|
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
159
|
export interface AppEntry {
|
|
194
160
|
name: string;
|
|
195
161
|
description?: string;
|
|
196
|
-
|
|
162
|
+
/** Plugin names installed on this app, in install order. */
|
|
163
|
+
plugins: string[];
|
|
197
164
|
tenantModel?: "single" | "per-org" | "per-account" | "per-workspace";
|
|
198
165
|
tenantKey?: string;
|
|
199
166
|
}
|
|
200
167
|
|
|
168
|
+
export interface SinkEntry {
|
|
169
|
+
name: string;
|
|
170
|
+
app: string;
|
|
171
|
+
/** Adapter kind tag — "bullmq", "nats", "capture", etc. */
|
|
172
|
+
kind?: string;
|
|
173
|
+
position: "early" | "middle" | "terminal";
|
|
174
|
+
direction: "outbound";
|
|
175
|
+
}
|
|
176
|
+
|
|
201
177
|
export interface RouteEntry {
|
|
202
178
|
method: string;
|
|
203
179
|
path: string;
|
|
204
|
-
target
|
|
205
|
-
targetKind
|
|
206
|
-
|
|
207
|
-
app: string;
|
|
180
|
+
target?: string;
|
|
181
|
+
targetKind?: "action" | "query" | "resolver";
|
|
182
|
+
app?: string;
|
|
208
183
|
}
|
|
209
184
|
|
|
210
185
|
export interface ResolverEntry {
|
|
@@ -229,14 +204,17 @@ export interface ResolverEntry {
|
|
|
229
204
|
sunsetDate?: string;
|
|
230
205
|
}
|
|
231
206
|
|
|
207
|
+
/**
|
|
208
|
+
* One directed edge in the static event graph as emitted by `@nwire/scan`.
|
|
209
|
+
* `from` and `to` are name identifiers (action / event / workflow /
|
|
210
|
+
* projection) per the `via` discriminator. Multiple edges share the
|
|
211
|
+
* same source or target — e.g. one event consumed by N projections
|
|
212
|
+
* shows up as N "folds" edges.
|
|
213
|
+
*/
|
|
232
214
|
export interface EventGraphEdge {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
app: string;
|
|
237
|
-
module: string;
|
|
238
|
-
via: "workflow" | "projection" | "actor" | "external";
|
|
239
|
-
}>;
|
|
215
|
+
from: string;
|
|
216
|
+
to: string;
|
|
217
|
+
via: "emits" | "folds" | "subscribes" | "dispatches";
|
|
240
218
|
}
|
|
241
219
|
|
|
242
220
|
export interface HookEntry {
|
|
@@ -270,7 +248,6 @@ export interface DIBindingEntry {
|
|
|
270
248
|
export interface Cache {
|
|
271
249
|
generatedAt: string;
|
|
272
250
|
apps: AppEntry[];
|
|
273
|
-
modules: ModuleEntry[];
|
|
274
251
|
actions: ActionEntry[];
|
|
275
252
|
events: EventEntry[];
|
|
276
253
|
actors: ActorEntry[];
|
|
@@ -286,6 +263,7 @@ export interface Cache {
|
|
|
286
263
|
crons: CronEntry[];
|
|
287
264
|
hooks: HookEntry[];
|
|
288
265
|
plugins: PluginEntry[];
|
|
266
|
+
sinks: SinkEntry[];
|
|
289
267
|
bindings: DIBindingEntry[];
|
|
290
268
|
graph: { events: EventGraphEdge[] };
|
|
291
269
|
}
|
|
@@ -29,7 +29,6 @@ export interface NormalizeResult {
|
|
|
29
29
|
|
|
30
30
|
const ARRAY_FIELDS = [
|
|
31
31
|
"apps",
|
|
32
|
-
"modules",
|
|
33
32
|
"actions",
|
|
34
33
|
"events",
|
|
35
34
|
"actors",
|
|
@@ -45,6 +44,7 @@ const ARRAY_FIELDS = [
|
|
|
45
44
|
"crons",
|
|
46
45
|
"hooks",
|
|
47
46
|
"plugins",
|
|
47
|
+
"sinks",
|
|
48
48
|
"bindings",
|
|
49
49
|
] as const;
|
|
50
50
|
|
|
@@ -28,7 +28,7 @@ export interface ProjectSnapshot {
|
|
|
28
28
|
* visits before scan finishes may not have them. */
|
|
29
29
|
readonly composition?: {
|
|
30
30
|
readonly apps: number;
|
|
31
|
-
readonly
|
|
31
|
+
readonly plugins: number;
|
|
32
32
|
readonly actions: number;
|
|
33
33
|
readonly events: number;
|
|
34
34
|
readonly resolvers?: number;
|
|
@@ -79,6 +79,8 @@ export function upsertCurrent(snapshot: ProjectSnapshot): void {
|
|
|
79
79
|
* the launch-time project from `/__nwire/project`).
|
|
80
80
|
*/
|
|
81
81
|
const ACTIVE_KEY = "nwire.activeProject";
|
|
82
|
+
const ACTIVE_SLUG_KEY = "nwire.studio.activeSlug";
|
|
83
|
+
|
|
82
84
|
export function getActiveProjectCwd(): string | null {
|
|
83
85
|
if (typeof localStorage === "undefined") return null;
|
|
84
86
|
try {
|
|
@@ -97,6 +99,42 @@ export function setActiveProjectCwd(cwd: string | null): void {
|
|
|
97
99
|
}
|
|
98
100
|
}
|
|
99
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Derive a stable URL slug from a project's package name (preferred) or
|
|
104
|
+
* the basename of its cwd (fallback). Slugs are kebab-cased, alphanum
|
|
105
|
+
* only — anything else collapses to `-`.
|
|
106
|
+
*/
|
|
107
|
+
export function projectSlug(snapshot: { name?: string; cwd: string }): string {
|
|
108
|
+
const raw = (
|
|
109
|
+
snapshot.name && snapshot.name.length > 0
|
|
110
|
+
? snapshot.name
|
|
111
|
+
: (snapshot.cwd.split("/").filter(Boolean).pop() ?? "project")
|
|
112
|
+
)
|
|
113
|
+
.toLowerCase()
|
|
114
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
115
|
+
.replace(/^-|-$/g, "");
|
|
116
|
+
return raw || "project";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Reverse-map a URL slug back to its cwd via the catalog. */
|
|
120
|
+
export function cwdForSlug(slug: string): string | null {
|
|
121
|
+
const catalog = loadCatalog();
|
|
122
|
+
for (const snapshot of Object.values(catalog)) {
|
|
123
|
+
if (projectSlug(snapshot) === slug) return snapshot.cwd;
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function setActiveSlug(slug: string | null): void {
|
|
129
|
+
if (typeof localStorage === "undefined") return;
|
|
130
|
+
try {
|
|
131
|
+
if (slug) localStorage.setItem(ACTIVE_SLUG_KEY, slug);
|
|
132
|
+
else localStorage.removeItem(ACTIVE_SLUG_KEY);
|
|
133
|
+
} catch {
|
|
134
|
+
// ignore
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
100
138
|
/**
|
|
101
139
|
* `fetch` wrapper that appends `?project=<cwd>` to every Studio-internal
|
|
102
140
|
* URL. The server's `targetCwd(req)` reads this and serves the right
|
package/src/main.ts
CHANGED
|
@@ -52,7 +52,6 @@ import Topology from "./pages/Topology.vue";
|
|
|
52
52
|
import Trace from "./pages/Trace.vue";
|
|
53
53
|
import Actions from "./pages/Actions.vue";
|
|
54
54
|
import Events from "./pages/Events.vue";
|
|
55
|
-
import Modules from "./pages/Modules.vue";
|
|
56
55
|
import Workflows from "./pages/Workflows.vue";
|
|
57
56
|
import Hooks from "./pages/Hooks.vue";
|
|
58
57
|
import Plugins from "./pages/Plugins.vue";
|
|
@@ -61,27 +60,64 @@ import Dispatch from "./pages/Dispatch.vue";
|
|
|
61
60
|
import Run from "./pages/Run.vue";
|
|
62
61
|
import Commands from "./pages/Commands.vue";
|
|
63
62
|
import Projects from "./pages/Projects.vue";
|
|
63
|
+
import Projections from "./pages/Projections.vue";
|
|
64
|
+
import Queries from "./pages/Queries.vue";
|
|
65
|
+
import Apps from "./pages/Apps.vue";
|
|
66
|
+
import Sinks from "./pages/Sinks.vue";
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Routes are namespaced under `/projects/:slug` so every URL is
|
|
70
|
+
* project-pinned. Bookmarks and shared links carry the project
|
|
71
|
+
* identity; multiple Studio tabs can hold different projects without
|
|
72
|
+
* stomping each other.
|
|
73
|
+
*
|
|
74
|
+
* `/` and bare-page routes resolve the active project from
|
|
75
|
+
* localStorage and redirect into the slug-pinned route. The bare
|
|
76
|
+
* `/projects` catalog page is the one URL that's intentionally not
|
|
77
|
+
* project-pinned.
|
|
78
|
+
*/
|
|
79
|
+
const pageRoutes = [
|
|
80
|
+
{ path: "", component: Home, name: "home" },
|
|
81
|
+
{ path: "overview", component: Overview, name: "overview" },
|
|
82
|
+
{ path: "topology", component: Topology, name: "topology" },
|
|
83
|
+
{ path: "trace", component: Trace, name: "trace" },
|
|
84
|
+
{ path: "apps", component: Apps, name: "apps" },
|
|
85
|
+
{ path: "actions", component: Actions, name: "actions" },
|
|
86
|
+
{ path: "events", component: Events, name: "events" },
|
|
87
|
+
{ path: "workflows", component: Workflows, name: "workflows" },
|
|
88
|
+
{ path: "hooks", component: Hooks, name: "hooks" },
|
|
89
|
+
{ path: "plugins", component: Plugins, name: "plugins" },
|
|
90
|
+
{ path: "projections", component: Projections, name: "projections" },
|
|
91
|
+
{ path: "queries", component: Queries, name: "queries" },
|
|
92
|
+
{ path: "sinks", component: Sinks, name: "sinks" },
|
|
93
|
+
{ path: "dispatch", component: Dispatch, name: "dispatch" },
|
|
94
|
+
{ path: "live", component: Live, name: "live" },
|
|
95
|
+
{ path: "run", component: Run, name: "run" },
|
|
96
|
+
{ path: "commands", component: Commands, name: "commands" },
|
|
97
|
+
];
|
|
64
98
|
|
|
65
99
|
const router = createRouter({
|
|
66
100
|
history: createWebHistory(),
|
|
67
101
|
routes: [
|
|
102
|
+
// Project catalog — global, not project-pinned.
|
|
103
|
+
{ path: "/projects", component: Projects, name: "projects" },
|
|
104
|
+
// Project-pinned routes. Names get a `p-` prefix so they don't
|
|
105
|
+
// collide with the bare-path variants below.
|
|
106
|
+
{
|
|
107
|
+
path: "/projects/:slug",
|
|
108
|
+
children: pageRoutes.map((r) => ({ ...r, name: `p-${r.name}` })),
|
|
109
|
+
},
|
|
110
|
+
// Bare paths render the same pages. App.vue upgrades the URL to
|
|
111
|
+
// /projects/<slug>/... once the active project slug is known, so a
|
|
112
|
+
// user who lands on /actions ends up bookmark-friendly without
|
|
113
|
+
// breaking the load. Old bookmarks + CI test suites continue to
|
|
114
|
+
// work unchanged.
|
|
68
115
|
{ path: "/", component: Home, name: "home" },
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
// Back-compat redirect — the page formerly known as EventStorm.
|
|
116
|
+
...pageRoutes
|
|
117
|
+
.filter((r) => r.path !== "")
|
|
118
|
+
.map((r) => ({ path: `/${r.path}`, component: r.component, name: r.name })),
|
|
73
119
|
{ path: "/eventstorm", redirect: "/trace" },
|
|
74
|
-
{ path: "/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" },
|
|
120
|
+
{ path: "/modules", redirect: "/apps" },
|
|
85
121
|
],
|
|
86
122
|
});
|
|
87
123
|
|
package/src/pages/Actions.vue
CHANGED
|
@@ -30,7 +30,6 @@ const filtered = computed(() => {
|
|
|
30
30
|
!q ||
|
|
31
31
|
a.name.toLowerCase().includes(q) ||
|
|
32
32
|
(a.description ?? "").toLowerCase().includes(q) ||
|
|
33
|
-
a.module.toLowerCase().includes(q) ||
|
|
34
33
|
a.app.toLowerCase().includes(q),
|
|
35
34
|
);
|
|
36
35
|
});
|
|
@@ -47,7 +46,7 @@ const detail = computed(() => filtered.value.find((a) => a.name === selected.val
|
|
|
47
46
|
<Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-zinc-500" />
|
|
48
47
|
<input
|
|
49
48
|
v-model="filter"
|
|
50
|
-
placeholder="filter by name,
|
|
49
|
+
placeholder="filter by name, app, description…"
|
|
51
50
|
class="w-full bg-zinc-900 border border-zinc-800 rounded pl-7 pr-2 py-1.5 text-sm placeholder:text-zinc-600 focus:outline-none focus:border-zinc-600"
|
|
52
51
|
/>
|
|
53
52
|
</div>
|
|
@@ -74,9 +73,7 @@ const detail = computed(() => filtered.value.find((a) => a.name === selected.val
|
|
|
74
73
|
:is="a.public ? Globe : Lock"
|
|
75
74
|
class="w-3 h-3"
|
|
76
75
|
:class="a.public ? 'text-emerald-400' : 'text-zinc-500'"
|
|
77
|
-
:title="
|
|
78
|
-
a.public ? 'public — other modules may dispatch' : 'private — module-internal'
|
|
79
|
-
"
|
|
76
|
+
:title="a.public ? 'public — other apps may dispatch' : 'private — app-internal'"
|
|
80
77
|
/>
|
|
81
78
|
<span class="text-[10px] text-zinc-500">{{ a.app }}</span>
|
|
82
79
|
</div>
|
|
@@ -95,7 +92,7 @@ const detail = computed(() => filtered.value.find((a) => a.name === selected.val
|
|
|
95
92
|
<div v-else class="p-6 space-y-5">
|
|
96
93
|
<div>
|
|
97
94
|
<div class="text-[10px] uppercase tracking-wide text-zinc-500">
|
|
98
|
-
{{ detail.app }}
|
|
95
|
+
{{ detail.app }}
|
|
99
96
|
</div>
|
|
100
97
|
<h2 class="font-mono text-xl mt-1">{{ detail.name }}</h2>
|
|
101
98
|
<p v-if="detail.description" class="text-sm text-zinc-400 mt-2 max-w-2xl">
|
|
@@ -136,16 +133,10 @@ const detail = computed(() => filtered.value.find((a) => a.name === selected.val
|
|
|
136
133
|
</div>
|
|
137
134
|
|
|
138
135
|
<div v-if="detail.source" class="flex items-center gap-2">
|
|
139
|
-
<
|
|
140
|
-
type="button"
|
|
141
|
-
class="inline-flex items-center"
|
|
142
|
-
@click="sourcePreview = detail.source!"
|
|
143
|
-
>
|
|
144
|
-
<SourcePill :source="detail.source" />
|
|
145
|
-
</button>
|
|
136
|
+
<SourcePill :source="detail.source" @click="sourcePreview = detail.source!" />
|
|
146
137
|
</div>
|
|
147
138
|
|
|
148
|
-
<SchemaTree :schema="detail.
|
|
139
|
+
<SchemaTree :schema="detail.inputSchema" label="Input schema" />
|
|
149
140
|
|
|
150
141
|
<div class="flex flex-wrap gap-2">
|
|
151
142
|
<button
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Apps — every App registered in this workspace. An App is the
|
|
4
|
+
* bounded-context unit; multi-app systems compose via
|
|
5
|
+
* `appCompose(a, b)` and run side-by-side under one endpoint.
|
|
6
|
+
*/
|
|
7
|
+
import { computed, ref } from "vue";
|
|
8
|
+
import { useCache } from "@/lib/cache";
|
|
9
|
+
import { Network, Puzzle } 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 filtered = computed(() => {
|
|
24
|
+
if (!cache.value) return [];
|
|
25
|
+
const q = filter.value.toLowerCase();
|
|
26
|
+
return cache.value.apps.filter(
|
|
27
|
+
(a) =>
|
|
28
|
+
!q || a.name.toLowerCase().includes(q) || (a.description ?? "").toLowerCase().includes(q),
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const detail = computed(() => filtered.value.find((a) => a.name === selected.value) ?? null);
|
|
33
|
+
|
|
34
|
+
function countFor(
|
|
35
|
+
appName: string,
|
|
36
|
+
kind: "actions" | "events" | "projections" | "queries" | "workflows",
|
|
37
|
+
): number {
|
|
38
|
+
if (!cache.value) return 0;
|
|
39
|
+
return cache.value[kind].filter((x: { app: string }) => x.app === appName).length;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function pluginsFor(appName: string) {
|
|
43
|
+
if (!cache.value) return [];
|
|
44
|
+
return cache.value.plugins.filter((p) => p.app === appName);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function sinksFor(appName: string) {
|
|
48
|
+
if (!cache.value) return [];
|
|
49
|
+
return cache.value.sinks?.filter((s) => s.app === appName) ?? [];
|
|
50
|
+
}
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<template>
|
|
54
|
+
<div v-if="cache" class="h-full flex flex-col" data-testid="apps-page">
|
|
55
|
+
<div class="p-6 pb-3 border-b border-zinc-800">
|
|
56
|
+
<PageHeader
|
|
57
|
+
title="Apps"
|
|
58
|
+
subtitle="Every App registered in this workspace — its plugins, surface, and outbound sinks."
|
|
59
|
+
:icon="Network"
|
|
60
|
+
icon-color="text-emerald-400"
|
|
61
|
+
:count="filtered.length"
|
|
62
|
+
:total="cache.apps.length"
|
|
63
|
+
/>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<EmptyState
|
|
67
|
+
v-if="cache.apps.length === 0"
|
|
68
|
+
title="No apps in cache"
|
|
69
|
+
hint="Apps are declared via createApp({appName, plugins}). Run `nwire cache` after adding one."
|
|
70
|
+
:icon="Network"
|
|
71
|
+
/>
|
|
72
|
+
|
|
73
|
+
<MasterDetail v-else class="flex-1">
|
|
74
|
+
<template #listHeader>
|
|
75
|
+
<FilterInput v-model="filter" placeholder="filter by name…" />
|
|
76
|
+
</template>
|
|
77
|
+
|
|
78
|
+
<template #list>
|
|
79
|
+
<ListRow
|
|
80
|
+
v-for="app in filtered"
|
|
81
|
+
:key="app.name"
|
|
82
|
+
:selected="selected === app.name"
|
|
83
|
+
@click="selected = app.name"
|
|
84
|
+
>
|
|
85
|
+
<template #title>
|
|
86
|
+
<Network class="w-3 h-3 text-emerald-400 shrink-0" />
|
|
87
|
+
<span class="font-mono text-sm truncate">{{ app.name }}</span>
|
|
88
|
+
</template>
|
|
89
|
+
<template #meta>
|
|
90
|
+
<span class="text-[10px] text-zinc-500">
|
|
91
|
+
{{ app.plugins.length }} plugin{{ app.plugins.length === 1 ? "" : "s" }}
|
|
92
|
+
</span>
|
|
93
|
+
</template>
|
|
94
|
+
<template v-if="app.description" #description>
|
|
95
|
+
{{ app.description }}
|
|
96
|
+
</template>
|
|
97
|
+
</ListRow>
|
|
98
|
+
</template>
|
|
99
|
+
|
|
100
|
+
<template #empty
|
|
101
|
+
>Select an app to see its plugin stack, primitive counts, and sinks.</template
|
|
102
|
+
>
|
|
103
|
+
|
|
104
|
+
<template v-if="detail" #detail>
|
|
105
|
+
<div class="p-6 space-y-6" data-testid="app-detail">
|
|
106
|
+
<div>
|
|
107
|
+
<h2 class="font-mono text-xl">{{ detail.name }}</h2>
|
|
108
|
+
<p v-if="detail.description" class="text-sm text-zinc-400 mt-2 max-w-2xl">
|
|
109
|
+
{{ detail.description }}
|
|
110
|
+
</p>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
<div class="space-y-3">
|
|
114
|
+
<h3 class="text-xs uppercase tracking-wide text-zinc-500">Plugins</h3>
|
|
115
|
+
<div v-if="pluginsFor(detail.name).length === 0" class="text-xs text-zinc-600">
|
|
116
|
+
No plugins installed.
|
|
117
|
+
</div>
|
|
118
|
+
<div v-else class="flex flex-wrap gap-2">
|
|
119
|
+
<div
|
|
120
|
+
v-for="p in pluginsFor(detail.name)"
|
|
121
|
+
:key="p.name"
|
|
122
|
+
class="inline-flex items-center gap-1.5 rounded border border-zinc-800 bg-zinc-900/50 px-2.5 py-1 text-xs font-mono"
|
|
123
|
+
>
|
|
124
|
+
<Puzzle class="w-3 h-3 text-fuchsia-400" />
|
|
125
|
+
{{ p.name }}
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
<div class="space-y-3">
|
|
131
|
+
<h3 class="text-xs uppercase tracking-wide text-zinc-500">Outbound sinks</h3>
|
|
132
|
+
<div v-if="sinksFor(detail.name).length === 0" class="text-xs text-zinc-600">
|
|
133
|
+
No outbound sinks installed. Events stay in-process.
|
|
134
|
+
</div>
|
|
135
|
+
<div v-else class="space-y-1">
|
|
136
|
+
<div
|
|
137
|
+
v-for="s in sinksFor(detail.name)"
|
|
138
|
+
:key="s.name"
|
|
139
|
+
class="font-mono text-xs flex items-center gap-2"
|
|
140
|
+
>
|
|
141
|
+
<KindBadge variant="public">{{ s.position }}</KindBadge>
|
|
142
|
+
<span class="text-amber-200">{{ s.name }}</span>
|
|
143
|
+
<span v-if="s.kind" class="text-zinc-500">· {{ s.kind }}</span>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
<div class="space-y-3">
|
|
149
|
+
<h3 class="text-xs uppercase tracking-wide text-zinc-500">Surface</h3>
|
|
150
|
+
<div class="grid grid-cols-5 gap-3 text-sm">
|
|
151
|
+
<div>
|
|
152
|
+
<div class="text-zinc-500 text-[10px] uppercase">actions</div>
|
|
153
|
+
<div class="tabular-nums">{{ countFor(detail.name, "actions") }}</div>
|
|
154
|
+
</div>
|
|
155
|
+
<div>
|
|
156
|
+
<div class="text-zinc-500 text-[10px] uppercase">events</div>
|
|
157
|
+
<div class="tabular-nums">{{ countFor(detail.name, "events") }}</div>
|
|
158
|
+
</div>
|
|
159
|
+
<div>
|
|
160
|
+
<div class="text-zinc-500 text-[10px] uppercase">projections</div>
|
|
161
|
+
<div class="tabular-nums">{{ countFor(detail.name, "projections") }}</div>
|
|
162
|
+
</div>
|
|
163
|
+
<div>
|
|
164
|
+
<div class="text-zinc-500 text-[10px] uppercase">queries</div>
|
|
165
|
+
<div class="tabular-nums">{{ countFor(detail.name, "queries") }}</div>
|
|
166
|
+
</div>
|
|
167
|
+
<div>
|
|
168
|
+
<div class="text-zinc-500 text-[10px] uppercase">workflows</div>
|
|
169
|
+
<div class="tabular-nums">{{ countFor(detail.name, "workflows") }}</div>
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
</template>
|
|
175
|
+
</MasterDetail>
|
|
176
|
+
</div>
|
|
177
|
+
</template>
|