@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/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: object;
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 module's manifest marked this action `.public()`. */
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: { provider: string; endpoint: string; region?: string };
44
- request: object;
44
+ target?: { provider: string; endpoint: string; region?: string };
45
+ request?: object;
45
46
  response?: object;
46
- hasIdempotencyKey: boolean;
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: string;
58
- path: string;
59
- hasSignatureVerifier: boolean;
58
+ source?: SourceLocationEntry;
59
+ path?: string;
60
+ hasSignatureVerifier?: boolean;
60
61
  dedupe?: { window: string };
61
- discriminator: string;
62
- routes: Record<string, string>;
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: string[];
72
- flushIntervalMs: number;
73
- maxBatch: number;
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: string;
83
- on: string[];
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: string;
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 module's manifest marked this workflow `.public()`. */
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
- visibility: "public" | "internal";
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
- schema: object;
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
- 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 }>;
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: string;
165
- schema: object;
166
- /** True when the module's manifest marked this query `.public()`. */
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
- modules: string[];
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: string;
205
- targetKind: "action" | "query" | "resolver";
206
- module: string;
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
- event: string;
234
- producer: { app: string; module: string };
235
- consumers: Array<{
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 modules: number;
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
- { 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.
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", 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" },
120
+ { path: "/modules", redirect: "/apps" },
85
121
  ],
86
122
  });
87
123
 
@@ -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, module, app, description…"
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 }} · {{ detail.module }}
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
- <button
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.schema" label="Input schema" />
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>