@nwire/studio 0.12.1 → 0.13.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.
Files changed (130) hide show
  1. package/package.json +5 -3
  2. package/src/App.vue +62 -56
  3. package/src/components/BcCard.stories.ts +47 -0
  4. package/src/components/BcCard.vue +152 -0
  5. package/src/components/DurationBar.stories.ts +55 -0
  6. package/src/components/DurationBar.vue +72 -0
  7. package/src/components/ErrorCard.stories.ts +133 -0
  8. package/src/components/ErrorCard.vue +153 -0
  9. package/src/components/GraphCanvas.stories.ts +48 -0
  10. package/src/components/GraphCanvas.vue +88 -0
  11. package/src/components/KpiTile.stories.ts +32 -0
  12. package/src/components/KpiTile.vue +39 -0
  13. package/src/components/LiveTable.stories.ts +78 -0
  14. package/src/components/LiveTable.vue +186 -0
  15. package/src/components/MetadataInspector.stories.ts +53 -0
  16. package/src/components/MetadataInspector.vue +105 -0
  17. package/src/components/NodeCard.stories.ts +44 -0
  18. package/src/components/NodeCard.vue +150 -0
  19. package/src/components/RcaPanel.stories.ts +95 -0
  20. package/src/components/RcaPanel.vue +223 -0
  21. package/src/components/ServiceNode.vue +134 -0
  22. package/src/components/SourceDrawer.vue +6 -4
  23. package/src/components/SourcePill.vue +10 -3
  24. package/src/components/StatusBadge.stories.ts +33 -0
  25. package/src/components/StatusBadge.vue +54 -0
  26. package/src/components/Waterfall.stories.ts +85 -0
  27. package/src/components/Waterfall.vue +53 -0
  28. package/src/components/WaterfallRow.vue +74 -0
  29. package/src/components/__tests__/BcCard.test.ts +53 -0
  30. package/src/components/__tests__/DurationBar.test.ts +31 -0
  31. package/src/components/__tests__/ErrorCard.test.ts +71 -0
  32. package/src/components/__tests__/KpiTile.test.ts +23 -0
  33. package/src/components/__tests__/LiveTable.test.ts +100 -0
  34. package/src/components/__tests__/MetadataInspector.test.ts +38 -0
  35. package/src/components/__tests__/NodeCard.test.ts +116 -0
  36. package/src/components/__tests__/RcaPanel.test.ts +81 -0
  37. package/src/components/__tests__/StatusBadge.test.ts +23 -0
  38. package/src/components/__tests__/Waterfall.test.ts +54 -0
  39. package/src/components/index.ts +13 -0
  40. package/src/composables/__tests__/composables-context.test.ts +107 -0
  41. package/src/composables/__tests__/useTelemetry.test.ts +104 -0
  42. package/src/composables/__tests__/useTelemetryRuns.test.ts +282 -0
  43. package/src/composables/useDiscovery.ts +73 -0
  44. package/src/composables/useEndpoints.ts +94 -0
  45. package/src/composables/useLogTail.ts +51 -0
  46. package/src/composables/useManifest.ts +43 -0
  47. package/src/composables/useProcesses.ts +114 -0
  48. package/src/composables/useProject.ts +34 -0
  49. package/src/composables/useTelemetry.ts +270 -0
  50. package/src/lib/__tests__/bc-graph.test.ts +218 -0
  51. package/src/lib/__tests__/dispatch-form.test.ts +113 -0
  52. package/src/lib/__tests__/error-friendly.test.ts +198 -0
  53. package/src/lib/__tests__/home.test.ts +231 -0
  54. package/src/lib/__tests__/inspect.test.ts +160 -0
  55. package/src/lib/__tests__/kind-colors.test.ts +59 -0
  56. package/src/lib/__tests__/live-table.test.ts +194 -0
  57. package/src/lib/__tests__/manifest-health.test.ts +120 -0
  58. package/src/lib/__tests__/manifest.test.ts +87 -0
  59. package/src/lib/__tests__/metadata.test.ts +47 -0
  60. package/src/lib/__tests__/node-metrics.test.ts +144 -0
  61. package/src/lib/__tests__/operate.test.ts +97 -0
  62. package/src/lib/__tests__/pipeline-flow.test.ts +79 -0
  63. package/src/lib/__tests__/rca.test.ts +124 -0
  64. package/src/lib/__tests__/telemetry.test.ts +91 -0
  65. package/src/lib/__tests__/topology-graph.test.ts +331 -0
  66. package/src/lib/__tests__/topology-view.test.ts +154 -0
  67. package/src/lib/__tests__/waterfall.test.ts +165 -0
  68. package/src/lib/bc-graph.ts +298 -0
  69. package/src/lib/dispatch-form.ts +160 -0
  70. package/src/lib/error-friendly.ts +288 -0
  71. package/src/lib/home.ts +191 -0
  72. package/src/lib/inspect.ts +226 -0
  73. package/src/lib/kind-colors.ts +132 -0
  74. package/src/lib/live-table.ts +204 -0
  75. package/src/lib/manifest-health.ts +71 -0
  76. package/src/lib/manifest.ts +139 -0
  77. package/src/lib/metadata.ts +52 -0
  78. package/src/lib/node-metrics.ts +242 -0
  79. package/src/lib/operate.ts +114 -0
  80. package/src/lib/pipeline-flow.ts +120 -0
  81. package/src/lib/rca.ts +193 -0
  82. package/src/lib/telemetry.ts +155 -0
  83. package/src/lib/topology-graph.ts +551 -0
  84. package/src/lib/topology-view.ts +185 -0
  85. package/src/lib/waterfall.ts +148 -0
  86. package/src/main.ts +63 -29
  87. package/src/pages/Errors.vue +272 -0
  88. package/src/pages/Home.stories.ts +7 -8
  89. package/src/pages/Home.vue +255 -540
  90. package/src/pages/Hooks.stories.ts +44 -0
  91. package/src/pages/Hooks.vue +165 -164
  92. package/src/pages/Inspect.vue +240 -0
  93. package/src/pages/Map.vue +187 -0
  94. package/src/pages/Operate.vue +74 -0
  95. package/src/pages/Plugins.stories.ts +1 -1
  96. package/src/pages/Plugins.vue +174 -238
  97. package/src/pages/Projects.vue +62 -60
  98. package/src/pages/Streams.vue +344 -0
  99. package/src/pages/Topology.vue +318 -136
  100. package/src/pages/Trace.vue +174 -412
  101. package/src/pages/__tests__/Home.test.ts +109 -54
  102. package/src/pages/__tests__/Hooks.test.ts +5 -5
  103. package/src/pages/__tests__/Inspect.test.ts +111 -0
  104. package/src/pages/__tests__/Plugins.test.ts +85 -35
  105. package/src/pages/__tests__/Trace.test.ts +117 -0
  106. package/src/pages/operate/CommandsPanel.vue +186 -0
  107. package/src/pages/{Dispatch.vue → operate/DispatchPanel.vue} +100 -203
  108. package/src/pages/operate/EndpointPicker.vue +56 -0
  109. package/src/pages/operate/RunPanel.vue +316 -0
  110. package/src/server/__tests__/nwire-read.test.ts +80 -0
  111. package/src/server/nwire-read.ts +63 -0
  112. package/vite.config.ts +220 -2
  113. package/src/lib/__tests__/normalize-cache.test.ts +0 -105
  114. package/src/lib/cache.ts +0 -312
  115. package/src/lib/normalize-cache.ts +0 -92
  116. package/src/pages/Actions.vue +0 -171
  117. package/src/pages/Apps.vue +0 -177
  118. package/src/pages/Commands.vue +0 -262
  119. package/src/pages/Events.vue +0 -210
  120. package/src/pages/Live.vue +0 -249
  121. package/src/pages/Overview.vue +0 -161
  122. package/src/pages/Projections.vue +0 -148
  123. package/src/pages/Queries.vue +0 -148
  124. package/src/pages/Run.vue +0 -618
  125. package/src/pages/Sinks.vue +0 -124
  126. package/src/pages/TraceNode.vue +0 -164
  127. package/src/pages/Workflows.vue +0 -184
  128. package/src/pages/__tests__/Actions.test.ts +0 -98
  129. package/src/pages/__tests__/Projections.test.ts +0 -90
  130. package/src/pages/__tests__/Queries.test.ts +0 -86
@@ -1,124 +0,0 @@
1
- <script setup lang="ts">
2
- /**
3
- * Sinks — outbound delivery surface. Each sink is a stage on the
4
- * runtime's outbound chain (early → middle → terminal). Endpoint
5
- * adapters install them at boot via `ctx.installSinkStage`.
6
- */
7
- import { computed, ref } from "vue";
8
- import { useCache } from "@/lib/cache";
9
- import { Waves, ArrowRight } 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 sinks = computed(() => cache.value?.sinks ?? []);
24
-
25
- const filtered = computed(() => {
26
- if (!cache.value) return [];
27
- const q = filter.value.toLowerCase();
28
- return sinks.value.filter(
29
- (s) =>
30
- !q ||
31
- s.name.toLowerCase().includes(q) ||
32
- s.app.toLowerCase().includes(q) ||
33
- (s.kind ?? "").toLowerCase().includes(q),
34
- );
35
- });
36
-
37
- const key = (s: { app: string; name: string }) => `${s.app}::${s.name}`;
38
- const detail = computed(() => filtered.value.find((s) => key(s) === selected.value) ?? null);
39
-
40
- const positionVariant = (p: "early" | "middle" | "terminal") =>
41
- p === "terminal" ? "public" : p === "middle" ? "neutral" : "private";
42
- </script>
43
-
44
- <template>
45
- <div v-if="cache" class="h-full flex flex-col" data-testid="sinks-page">
46
- <div class="p-6 pb-3 border-b border-zinc-800">
47
- <PageHeader
48
- title="Sinks"
49
- subtitle="Outbound stages — every step in the runtime's outbound delivery chain."
50
- :icon="Waves"
51
- icon-color="text-amber-400"
52
- :count="filtered.length"
53
- :total="sinks.length"
54
- />
55
- </div>
56
-
57
- <EmptyState
58
- v-if="sinks.length === 0"
59
- title="No outbound sinks installed"
60
- hint="Outbound adapters (queue publisher, NATS, webhook, OTLP) install sinks at endpoint boot via ctx.installSinkStage. Apps without one keep events in-process."
61
- :icon="Waves"
62
- />
63
-
64
- <MasterDetail v-else class="flex-1">
65
- <template #listHeader>
66
- <FilterInput v-model="filter" placeholder="filter by name, app, kind…" />
67
- </template>
68
-
69
- <template #list>
70
- <ListRow
71
- v-for="s in filtered"
72
- :key="key(s)"
73
- :selected="selected === key(s)"
74
- @click="selected = key(s)"
75
- >
76
- <template #title>
77
- <Waves class="w-3 h-3 text-amber-400 shrink-0" />
78
- <span class="font-mono text-sm truncate">{{ s.name }}</span>
79
- </template>
80
- <template #meta>
81
- <KindBadge :variant="positionVariant(s.position)">{{ s.position }}</KindBadge>
82
- <span class="text-[10px] text-zinc-500">{{ s.app }}</span>
83
- </template>
84
- <template v-if="s.kind" #description>
85
- <span class="text-zinc-500">kind</span>
86
- <span class="ml-1 font-mono">{{ s.kind }}</span>
87
- </template>
88
- </ListRow>
89
- </template>
90
-
91
- <template #empty>Select a sink to see its position and adapter kind.</template>
92
-
93
- <template v-if="detail" #detail>
94
- <div class="p-6 space-y-5" data-testid="sink-detail">
95
- <div>
96
- <div class="text-[10px] uppercase tracking-wide text-zinc-500">{{ detail.app }}</div>
97
- <h2 class="font-mono text-xl mt-1">{{ detail.name }}</h2>
98
- </div>
99
-
100
- <div class="flex flex-wrap gap-2">
101
- <KindBadge :variant="positionVariant(detail.position)">
102
- {{ detail.position }}
103
- </KindBadge>
104
- <KindBadge variant="neutral">{{ detail.direction }}</KindBadge>
105
- <KindBadge v-if="detail.kind" variant="neutral">kind: {{ detail.kind }}</KindBadge>
106
- </div>
107
-
108
- <div class="text-xs text-zinc-400 max-w-xl space-y-2">
109
- <p>
110
- <ArrowRight class="w-3 h-3 inline mr-1 text-amber-400" />
111
- Every public event the App publishes runs through the outbound chain in
112
- <em>position</em> order: early → middle → terminal. Terminal stages do the transport
113
- delivery; early/middle do logging, metrics, routing.
114
- </p>
115
- <p v-if="detail.position === 'terminal'">
116
- Only one terminal stage per <em>kind</em> is allowed — the runtime rejects a second
117
- registration. Swap implementations by installing a different kind.
118
- </p>
119
- </div>
120
- </div>
121
- </template>
122
- </MasterDetail>
123
- </div>
124
- </template>
@@ -1,164 +0,0 @@
1
- <script setup lang="ts">
2
- /**
3
- * One node in the trace tree. Renders the event card and (when expanded)
4
- * recursively renders its causation children.
5
- *
6
- * Indentation comes from `depth`. The collapsed/expanded state lives in
7
- * the parent page (`expanded` ref keyed by messageId) so it survives
8
- * re-renders.
9
- */
10
- import { computed } from "vue";
11
- import { useRouter } from "vue-router";
12
- import { ChevronDown, ChevronRight, Globe, Network, Zap } from "lucide-vue-next";
13
- import { useCache } from "@/lib/cache";
14
- import SourcePill from "@/components/SourcePill.vue";
15
-
16
- interface BufferedEvent {
17
- seq: number;
18
- eventName?: string;
19
- payload: unknown;
20
- envelope: {
21
- messageId: string;
22
- correlationId: string;
23
- causationId: string;
24
- tenant?: string;
25
- userId?: string;
26
- timestamp: string;
27
- version: number;
28
- };
29
- source: "in-process" | "external";
30
- appName: string;
31
- capturedAt: string;
32
- }
33
-
34
- interface TraceNode {
35
- evt: BufferedEvent;
36
- children: TraceNode[];
37
- }
38
-
39
- interface SourceLoc {
40
- file: string;
41
- line: number;
42
- column?: number;
43
- }
44
-
45
- const props = defineProps<{
46
- node: TraceNode;
47
- depth: number;
48
- eventIndex: Map<string, { app: string; source?: SourceLoc }>;
49
- isExpanded: (id: string) => boolean;
50
- toggle: (id: string) => void;
51
- formatTime: (iso: string) => string;
52
- payloadPreview: (p: unknown) => string;
53
- }>();
54
-
55
- const emit = defineEmits<{ (e: "openSource", source: SourceLoc): void }>();
56
-
57
- const router = useRouter();
58
- const { cache } = useCache();
59
-
60
- const displayName = props.node.evt.eventName ?? "(framework lifecycle)";
61
- const meta = props.node.evt.eventName ? props.eventIndex.get(props.node.evt.eventName) : undefined;
62
- const source = meta?.source;
63
-
64
- /**
65
- * The action whose handler emitted this event, if any. We look it up by
66
- * scanning cache.actions for one whose `emits` list contains this event
67
- * name. First match wins. Used to render the "Action: <name>" deep link
68
- * back to /actions?name=<name>.
69
- */
70
- const dispatchingAction = computed<string | null>(() => {
71
- const evtName = props.node.evt.eventName;
72
- if (!evtName || !cache.value) return null;
73
- const found = cache.value.actions.find((a) => a.emits.includes(evtName));
74
- return found?.name ?? null;
75
- });
76
-
77
- function openAction(name: string): void {
78
- void router.push({ path: "/actions", query: { name } });
79
- }
80
-
81
- function open(s: SourceLoc | undefined) {
82
- if (s) emit("openSource", s);
83
- }
84
- </script>
85
-
86
- <template>
87
- <div :style="{ paddingLeft: `${depth * 16}px` }">
88
- <div class="flex items-start gap-2">
89
- <button
90
- type="button"
91
- class="mt-1.5 text-zinc-500 hover:text-zinc-300 shrink-0"
92
- :class="{ invisible: node.children.length === 0 }"
93
- @click="toggle(node.evt.envelope.messageId)"
94
- >
95
- <ChevronDown v-if="isExpanded(node.evt.envelope.messageId)" class="w-3.5 h-3.5" />
96
- <ChevronRight v-else class="w-3.5 h-3.5" />
97
- </button>
98
-
99
- <div class="flex-1 min-w-0">
100
- <div
101
- class="rounded border border-zinc-800 bg-zinc-900/30 hover:bg-zinc-900/60 transition-colors p-3"
102
- >
103
- <div class="flex items-center gap-2 flex-wrap">
104
- <component
105
- :is="node.evt.source === 'external' ? Network : Globe"
106
- class="w-3.5 h-3.5"
107
- :class="node.evt.source === 'external' ? 'text-violet-400' : 'text-emerald-400'"
108
- />
109
- <span
110
- class="font-mono text-sm truncate"
111
- :class="node.evt.eventName ? 'text-zinc-100' : 'text-zinc-500 italic'"
112
- >
113
- {{ displayName }}
114
- </span>
115
- <span class="text-[10px] text-zinc-500 tabular-nums">
116
- {{ formatTime(node.evt.capturedAt) }}
117
- </span>
118
- <span v-if="meta" class="text-[10px] text-zinc-500 font-mono"> · {{ meta.app }} </span>
119
- <button v-if="source" type="button" class="ml-auto" @click="open(source)">
120
- <SourcePill :source="source" compact />
121
- </button>
122
- </div>
123
- <div class="text-[10px] text-zinc-500 font-mono mt-1 truncate">
124
- msg {{ node.evt.envelope.messageId.split("-")[0] }} ← caused by
125
- {{ node.evt.envelope.causationId.split("-")[0] }}
126
- </div>
127
- <div v-if="dispatchingAction" class="text-[10px] mt-1">
128
- <button
129
- type="button"
130
- class="inline-flex items-center gap-1 font-mono text-amber-300 hover:underline"
131
- :data-testid="`action-link-${dispatchingAction}`"
132
- @click="openAction(dispatchingAction)"
133
- >
134
- <Zap class="w-3 h-3 text-amber-400" />
135
- Action: {{ dispatchingAction }}
136
- </button>
137
- </div>
138
- <div v-if="node.evt.payload" class="text-[11px] font-mono text-zinc-400 mt-1.5 truncate">
139
- <Zap class="inline w-3 h-3 text-amber-400 mr-1" />
140
- {{ payloadPreview(node.evt.payload) }}
141
- </div>
142
- </div>
143
-
144
- <div
145
- v-if="isExpanded(node.evt.envelope.messageId) && node.children.length"
146
- class="mt-2 space-y-2"
147
- >
148
- <TraceNode
149
- v-for="child in node.children"
150
- :key="child.evt.envelope.messageId"
151
- :node="child"
152
- :depth="depth + 1"
153
- :event-index="eventIndex"
154
- :is-expanded="isExpanded"
155
- :toggle="toggle"
156
- :format-time="formatTime"
157
- :payload-preview="payloadPreview"
158
- @open-source="emit('openSource', $event)"
159
- />
160
- </div>
161
- </div>
162
- </div>
163
- </div>
164
- </template>
@@ -1,184 +0,0 @@
1
- <script setup lang="ts">
2
- /**
3
- * Workflows — the unified event-driven side-effect primitive (reactions,
4
- * translators, sagas; one shape). Each row in `cache.workflows` shows
5
- * which events it subscribes to and which actions it dispatches.
6
- */
7
- import { computed, onMounted, ref, watch } from "vue";
8
- import { useRoute, useRouter } from "vue-router";
9
- import { useCache } from "@/lib/cache";
10
- import { GitBranch, Zap, ArrowRight, Globe, Lock } from "lucide-vue-next";
11
- import {
12
- PageHeader,
13
- FilterInput,
14
- KindBadge,
15
- EmptyState,
16
- MasterDetail,
17
- SourcePill,
18
- SourceDrawer,
19
- ListRow,
20
- } from "@/components";
21
-
22
- const route = useRoute();
23
- const router = useRouter();
24
- const { cache } = useCache();
25
- const filter = ref("");
26
- const selected = ref<string | null>(null);
27
- const sourcePreview = ref<{ file: string; line: number; column?: number } | null>(null);
28
-
29
- function applyQueryPreselect(): void {
30
- const name = route.query.name;
31
- if (typeof name !== "string" || name.length === 0) return;
32
- const found = cache.value?.workflows.find((w) => w.name === name);
33
- if (found) selected.value = `${found.app}::${found.name}`;
34
- }
35
-
36
- onMounted(applyQueryPreselect);
37
- watch(() => route.query.name, applyQueryPreselect);
38
- watch(() => cache.value, applyQueryPreselect);
39
-
40
- const filtered = computed(() => {
41
- if (!cache.value) return [];
42
- const q = filter.value.toLowerCase();
43
- return cache.value.workflows.filter(
44
- (w) =>
45
- !q ||
46
- w.name.toLowerCase().includes(q) ||
47
- w.app.toLowerCase().includes(q) ||
48
- (w.subscribesTo ?? []).some((e) => e.toLowerCase().includes(q)) ||
49
- (w.dispatches ?? []).some((a) => a.toLowerCase().includes(q)) ||
50
- (w.description ?? "").toLowerCase().includes(q),
51
- );
52
- });
53
-
54
- const key = (w: { app: string; name: string }) => `${w.app}::${w.name}`;
55
- const detail = computed(() => filtered.value.find((w) => key(w) === selected.value) ?? null);
56
- </script>
57
-
58
- <template>
59
- <div v-if="cache" class="h-full flex flex-col" data-testid="workflows-page">
60
- <div class="p-6 pb-3 border-b border-zinc-800">
61
- <PageHeader
62
- title="Workflows"
63
- subtitle="Event-driven side effects — reactions, translators, sagas. One primitive."
64
- :icon="GitBranch"
65
- icon-color="text-violet-400"
66
- :count="filtered.length"
67
- :total="cache.workflows.length"
68
- />
69
- </div>
70
-
71
- <EmptyState
72
- v-if="cache.workflows.length === 0"
73
- title="No workflows in cache"
74
- hint="Workflows are declared via defineWorkflow(name, ({ on, send }) => ...). Run `nwire cache` after adding one."
75
- :icon="GitBranch"
76
- />
77
-
78
- <MasterDetail v-else class="flex-1">
79
- <template #listHeader>
80
- <FilterInput v-model="filter" placeholder="filter by name, app, event, action…" />
81
- </template>
82
-
83
- <template #list>
84
- <ListRow
85
- v-for="w in filtered"
86
- :key="key(w)"
87
- :selected="selected === key(w)"
88
- @click="selected = key(w)"
89
- >
90
- <template #title>
91
- <GitBranch class="w-3 h-3 text-violet-400 shrink-0" />
92
- <span class="font-mono text-sm truncate">{{ w.name }}</span>
93
- </template>
94
- <template #meta>
95
- <component
96
- :is="w.public ? Globe : Lock"
97
- class="w-3 h-3"
98
- :class="w.public ? 'text-emerald-400' : 'text-zinc-500'"
99
- :title="w.public ? 'public — exposed across apps' : 'private — app-internal'"
100
- />
101
- <span class="text-[10px] text-zinc-500">{{ w.app }}</span>
102
- </template>
103
- <template v-if="w.description || (w.subscribesTo ?? []).length > 0" #description>
104
- <div v-if="w.description">{{ w.description }}</div>
105
- <div v-if="(w.subscribesTo ?? []).length > 0" class="text-zinc-600 mt-0.5">
106
- <span class="text-zinc-500">on</span>
107
- {{ (w.subscribesTo ?? []).slice(0, 2).join(", ") }}
108
- <span v-if="(w.subscribesTo ?? []).length > 2">
109
- +{{ (w.subscribesTo ?? []).length - 2 }}
110
- </span>
111
- </div>
112
- </template>
113
- </ListRow>
114
- </template>
115
-
116
- <template #empty>Select a workflow to view its event subscriptions and dispatches.</template>
117
-
118
- <template v-if="detail" #detail>
119
- <div class="p-6 space-y-5" data-testid="workflow-detail">
120
- <div>
121
- <div class="text-[10px] uppercase tracking-wide text-zinc-500">
122
- {{ detail.app }}
123
- </div>
124
- <h2 class="font-mono text-xl mt-1">{{ detail.name }}</h2>
125
- <p v-if="detail.description" class="text-sm text-zinc-400 mt-2 max-w-2xl">
126
- {{ detail.description }}
127
- </p>
128
- </div>
129
-
130
- <div class="flex flex-wrap gap-2">
131
- <KindBadge :variant="detail.public ? 'public' : 'private'">
132
- {{ detail.public ? "public" : "private" }}
133
- </KindBadge>
134
- </div>
135
-
136
- <div class="space-y-3">
137
- <h3 class="text-xs uppercase tracking-wide text-zinc-500">Listens to</h3>
138
- <div v-if="(detail.subscribesTo ?? []).length === 0" class="text-xs text-zinc-600">
139
- No event subscriptions declared.
140
- </div>
141
- <div v-else class="space-y-1">
142
- <button
143
- v-for="ev in detail.subscribesTo ?? []"
144
- :key="ev"
145
- type="button"
146
- class="flex items-center gap-2 font-mono text-sm text-left hover:text-cyan-300"
147
- :data-testid="`event-link-${ev}`"
148
- @click="router.push({ path: '/events', query: { name: ev } })"
149
- >
150
- <ArrowRight class="w-3.5 h-3.5 text-cyan-400" />
151
- <span class="underline-offset-2 hover:underline">{{ ev }}</span>
152
- </button>
153
- </div>
154
- </div>
155
-
156
- <div class="space-y-3">
157
- <h3 class="text-xs uppercase tracking-wide text-zinc-500">Dispatches</h3>
158
- <div v-if="(detail.dispatches ?? []).length === 0" class="text-xs text-zinc-600">
159
- Pure observer — no action dispatches.
160
- </div>
161
- <div v-else class="space-y-1">
162
- <button
163
- v-for="action in detail.dispatches ?? []"
164
- :key="action"
165
- type="button"
166
- class="flex items-center gap-2 font-mono text-sm text-left hover:text-amber-300"
167
- :data-testid="`action-link-${action}`"
168
- @click="router.push({ path: '/actions', query: { name: action } })"
169
- >
170
- <Zap class="w-3.5 h-3.5 text-amber-400" />
171
- <span class="underline-offset-2 hover:underline">{{ action }}</span>
172
- </button>
173
- </div>
174
- </div>
175
-
176
- <div v-if="detail.source" class="pt-2">
177
- <SourcePill :source="detail.source" @click="sourcePreview = detail.source!" />
178
- </div>
179
- </div>
180
- </template>
181
- </MasterDetail>
182
- <SourceDrawer :source="sourcePreview" @close="sourcePreview = null" />
183
- </div>
184
- </template>
@@ -1,98 +0,0 @@
1
- /**
2
- * Actions page deep-link test — verifies that `/actions?name=<actionName>`
3
- * preselects the matching action and that the cross-link buttons to
4
- * /hooks and /trace are rendered with the right router destinations.
5
- */
6
- import { describe, it, expect, beforeEach, vi } from "vitest";
7
- import { mount, flushPromises } from "@vue/test-utils";
8
- import { createRouter, createMemoryHistory } from "vue-router";
9
- import Actions from "../Actions.vue";
10
-
11
- const fakeAction = {
12
- name: "submitAssignment",
13
- description: "Student submits an assignment.",
14
- module: "submissions",
15
- app: "learnflow",
16
- schema: { type: "object" },
17
- hasInlineHandler: true,
18
- emits: ["AssignmentSubmitted"],
19
- public: true,
20
- };
21
-
22
- beforeEach(() => {
23
- globalThis.fetch = vi.fn((url: string | URL) => {
24
- const u = String(url);
25
- if (u.includes("/__nwire/manifest.json")) {
26
- return Promise.resolve(
27
- new Response(
28
- JSON.stringify({
29
- generatedAt: new Date().toISOString(),
30
- apps: [],
31
- modules: [],
32
- actions: [fakeAction],
33
- events: [],
34
- actors: [],
35
- projections: [],
36
- queries: [],
37
- resolvers: [],
38
- routes: [],
39
- workflows: [],
40
- externalCalls: [],
41
- inboundWebhooks: [],
42
- outboxes: [],
43
- inboxes: [],
44
- crons: [],
45
- hooks: [],
46
- plugins: [],
47
- graph: { events: [] },
48
- }),
49
- { status: 200 },
50
- ),
51
- );
52
- }
53
- return Promise.resolve(new Response("", { status: 404 }));
54
- }) as typeof fetch;
55
- });
56
-
57
- function makeRouter() {
58
- return createRouter({
59
- history: createMemoryHistory(),
60
- routes: [
61
- { path: "/actions", name: "actions", component: Actions },
62
- { path: "/hooks", name: "hooks", component: { template: "<div/>" } },
63
- { path: "/trace", name: "trace", component: { template: "<div/>" } },
64
- ],
65
- });
66
- }
67
-
68
- describe("Actions deep-links", () => {
69
- it("preselects the action when ?name=… is in the URL", async () => {
70
- const router = makeRouter();
71
- await router.push("/actions?name=submitAssignment");
72
- const wrapper = mount(Actions, { global: { plugins: [router] } });
73
- // Wait for cache fetch + onMounted preselect + computed re-render.
74
- await flushPromises();
75
- await flushPromises();
76
-
77
- expect(wrapper.text()).toContain("submitAssignment");
78
- // Schema heading is only shown when an action is selected.
79
- expect(wrapper.text()).toContain("Input schema");
80
- // Cross-link buttons rendered.
81
- expect(wrapper.find("[data-testid=hooks-link-submitAssignment]").exists()).toBe(true);
82
- expect(wrapper.find("[data-testid=trace-link-submitAssignment]").exists()).toBe(true);
83
- });
84
-
85
- it("clicking 'View hooks' navigates to /hooks?name=action.before:<name>", async () => {
86
- const router = makeRouter();
87
- await router.push("/actions?name=submitAssignment");
88
- const wrapper = mount(Actions, { global: { plugins: [router] } });
89
- await flushPromises();
90
- await flushPromises();
91
-
92
- await wrapper.find("[data-testid=hooks-link-submitAssignment]").trigger("click");
93
- await flushPromises();
94
-
95
- expect(router.currentRoute.value.path).toBe("/hooks");
96
- expect(router.currentRoute.value.query.name).toBe("action.before:submitAssignment");
97
- });
98
- });
@@ -1,90 +0,0 @@
1
- /**
2
- * Projections page — renders the list from cache and cross-links to
3
- * each query that reads the projection.
4
- */
5
- import { describe, it, expect, beforeEach, vi } from "vitest";
6
- import { mount, flushPromises } from "@vue/test-utils";
7
- import { createRouter, createMemoryHistory } from "vue-router";
8
- import Projections from "../Projections.vue";
9
-
10
- const counterProjection = {
11
- name: "counter-total",
12
- app: "shop",
13
- };
14
- const counterQuery = {
15
- name: "counter.get-count",
16
- app: "shop",
17
- public: false,
18
- projection: "counter-total",
19
- };
20
-
21
- beforeEach(() => {
22
- globalThis.fetch = vi.fn((url: string | URL) => {
23
- const u = String(url);
24
- if (u.includes("/__nwire/manifest.json")) {
25
- return Promise.resolve(
26
- new Response(
27
- JSON.stringify({
28
- generatedAt: new Date().toISOString(),
29
- apps: [],
30
- modules: [],
31
- actions: [],
32
- events: [],
33
- actors: [],
34
- projections: [counterProjection],
35
- queries: [counterQuery],
36
- resolvers: [],
37
- routes: [],
38
- workflows: [],
39
- externalCalls: [],
40
- inboundWebhooks: [],
41
- outboxes: [],
42
- inboxes: [],
43
- crons: [],
44
- hooks: [],
45
- plugins: [],
46
- bindings: [],
47
- graph: { events: [] },
48
- }),
49
- { status: 200 },
50
- ),
51
- );
52
- }
53
- return Promise.resolve(new Response("", { status: 404 }));
54
- }) as typeof fetch;
55
- });
56
-
57
- function makeRouter() {
58
- return createRouter({
59
- history: createMemoryHistory(),
60
- routes: [
61
- { path: "/projections", name: "projections", component: Projections },
62
- { path: "/queries", name: "queries", component: { template: "<div/>" } },
63
- ],
64
- });
65
- }
66
-
67
- describe("Projections", () => {
68
- it("renders projections from the cache", async () => {
69
- const router = makeRouter();
70
- await router.push("/projections");
71
- const wrapper = mount(Projections, { global: { plugins: [router] } });
72
- await flushPromises();
73
- await flushPromises();
74
-
75
- expect(wrapper.text()).toContain("counter-total");
76
- expect(wrapper.find("[data-testid=projections-page]").exists()).toBe(true);
77
- });
78
-
79
- it("deep-links via ?name=… and shows linked queries", async () => {
80
- const router = makeRouter();
81
- await router.push("/projections?name=counter-total");
82
- const wrapper = mount(Projections, { global: { plugins: [router] } });
83
- await flushPromises();
84
- await flushPromises();
85
-
86
- const detail = wrapper.find("[data-testid=projection-detail]");
87
- expect(detail.exists()).toBe(true);
88
- expect(detail.text()).toContain("counter.get-count");
89
- });
90
- });