@nwire/studio 0.12.1 → 0.13.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/package.json +5 -3
- package/src/App.vue +62 -56
- package/src/components/BcCard.stories.ts +47 -0
- package/src/components/BcCard.vue +152 -0
- package/src/components/DurationBar.stories.ts +55 -0
- package/src/components/DurationBar.vue +72 -0
- package/src/components/ErrorCard.stories.ts +133 -0
- package/src/components/ErrorCard.vue +153 -0
- package/src/components/GraphCanvas.stories.ts +48 -0
- package/src/components/GraphCanvas.vue +88 -0
- package/src/components/KpiTile.stories.ts +32 -0
- package/src/components/KpiTile.vue +39 -0
- package/src/components/LiveTable.stories.ts +78 -0
- package/src/components/LiveTable.vue +186 -0
- package/src/components/MetadataInspector.stories.ts +53 -0
- package/src/components/MetadataInspector.vue +105 -0
- package/src/components/NodeCard.stories.ts +44 -0
- package/src/components/NodeCard.vue +150 -0
- package/src/components/RcaPanel.stories.ts +95 -0
- package/src/components/RcaPanel.vue +223 -0
- package/src/components/ServiceNode.vue +134 -0
- package/src/components/SourceDrawer.vue +6 -4
- package/src/components/SourcePill.vue +10 -3
- package/src/components/StatusBadge.stories.ts +33 -0
- package/src/components/StatusBadge.vue +54 -0
- package/src/components/Waterfall.stories.ts +85 -0
- package/src/components/Waterfall.vue +53 -0
- package/src/components/WaterfallRow.vue +74 -0
- package/src/components/__tests__/BcCard.test.ts +53 -0
- package/src/components/__tests__/DurationBar.test.ts +31 -0
- package/src/components/__tests__/ErrorCard.test.ts +71 -0
- package/src/components/__tests__/KpiTile.test.ts +23 -0
- package/src/components/__tests__/LiveTable.test.ts +100 -0
- package/src/components/__tests__/MetadataInspector.test.ts +38 -0
- package/src/components/__tests__/NodeCard.test.ts +116 -0
- package/src/components/__tests__/RcaPanel.test.ts +81 -0
- package/src/components/__tests__/StatusBadge.test.ts +23 -0
- package/src/components/__tests__/Waterfall.test.ts +54 -0
- package/src/components/index.ts +13 -0
- package/src/composables/__tests__/composables-context.test.ts +107 -0
- package/src/composables/__tests__/useTelemetry.test.ts +104 -0
- package/src/composables/__tests__/useTelemetryRuns.test.ts +282 -0
- package/src/composables/useDiscovery.ts +73 -0
- package/src/composables/useEndpoints.ts +94 -0
- package/src/composables/useLogTail.ts +51 -0
- package/src/composables/useManifest.ts +43 -0
- package/src/composables/useProcesses.ts +114 -0
- package/src/composables/useProject.ts +34 -0
- package/src/composables/useTelemetry.ts +270 -0
- package/src/lib/__tests__/bc-graph.test.ts +218 -0
- package/src/lib/__tests__/dispatch-form.test.ts +113 -0
- package/src/lib/__tests__/error-friendly.test.ts +198 -0
- package/src/lib/__tests__/home.test.ts +231 -0
- package/src/lib/__tests__/inspect.test.ts +160 -0
- package/src/lib/__tests__/kind-colors.test.ts +59 -0
- package/src/lib/__tests__/live-table.test.ts +194 -0
- package/src/lib/__tests__/manifest-health.test.ts +120 -0
- package/src/lib/__tests__/manifest.test.ts +87 -0
- package/src/lib/__tests__/metadata.test.ts +47 -0
- package/src/lib/__tests__/node-metrics.test.ts +144 -0
- package/src/lib/__tests__/operate.test.ts +97 -0
- package/src/lib/__tests__/pipeline-flow.test.ts +79 -0
- package/src/lib/__tests__/rca.test.ts +124 -0
- package/src/lib/__tests__/telemetry.test.ts +91 -0
- package/src/lib/__tests__/topology-graph.test.ts +331 -0
- package/src/lib/__tests__/topology-view.test.ts +154 -0
- package/src/lib/__tests__/waterfall.test.ts +165 -0
- package/src/lib/bc-graph.ts +298 -0
- package/src/lib/dispatch-form.ts +160 -0
- package/src/lib/error-friendly.ts +288 -0
- package/src/lib/home.ts +191 -0
- package/src/lib/inspect.ts +226 -0
- package/src/lib/kind-colors.ts +132 -0
- package/src/lib/live-table.ts +204 -0
- package/src/lib/manifest-health.ts +71 -0
- package/src/lib/manifest.ts +139 -0
- package/src/lib/metadata.ts +52 -0
- package/src/lib/node-metrics.ts +242 -0
- package/src/lib/operate.ts +114 -0
- package/src/lib/pipeline-flow.ts +120 -0
- package/src/lib/rca.ts +193 -0
- package/src/lib/telemetry.ts +155 -0
- package/src/lib/topology-graph.ts +551 -0
- package/src/lib/topology-view.ts +185 -0
- package/src/lib/waterfall.ts +148 -0
- package/src/main.ts +63 -29
- package/src/pages/Errors.vue +272 -0
- package/src/pages/Home.stories.ts +7 -8
- package/src/pages/Home.vue +255 -540
- package/src/pages/Hooks.stories.ts +44 -0
- package/src/pages/Hooks.vue +165 -164
- package/src/pages/Inspect.vue +240 -0
- package/src/pages/Map.vue +187 -0
- package/src/pages/Operate.vue +74 -0
- package/src/pages/Plugins.stories.ts +1 -1
- package/src/pages/Plugins.vue +174 -238
- package/src/pages/Projects.vue +62 -60
- package/src/pages/Streams.vue +344 -0
- package/src/pages/Topology.vue +318 -136
- package/src/pages/Trace.vue +174 -412
- package/src/pages/__tests__/Home.test.ts +109 -54
- package/src/pages/__tests__/Hooks.test.ts +5 -5
- package/src/pages/__tests__/Inspect.test.ts +111 -0
- package/src/pages/__tests__/Plugins.test.ts +85 -35
- package/src/pages/__tests__/Trace.test.ts +117 -0
- package/src/pages/operate/CommandsPanel.vue +186 -0
- package/src/pages/{Dispatch.vue → operate/DispatchPanel.vue} +100 -203
- package/src/pages/operate/EndpointPicker.vue +56 -0
- package/src/pages/operate/RunPanel.vue +316 -0
- package/src/server/__tests__/nwire-read.test.ts +80 -0
- package/src/server/nwire-read.ts +63 -0
- package/vite.config.ts +220 -2
- package/src/lib/__tests__/normalize-cache.test.ts +0 -105
- package/src/lib/cache.ts +0 -312
- package/src/lib/normalize-cache.ts +0 -92
- package/src/pages/Actions.vue +0 -171
- package/src/pages/Apps.vue +0 -177
- package/src/pages/Commands.vue +0 -262
- package/src/pages/Events.vue +0 -210
- package/src/pages/Live.vue +0 -249
- package/src/pages/Overview.vue +0 -161
- package/src/pages/Projections.vue +0 -148
- package/src/pages/Queries.vue +0 -148
- package/src/pages/Run.vue +0 -618
- package/src/pages/Sinks.vue +0 -124
- package/src/pages/TraceNode.vue +0 -164
- package/src/pages/Workflows.vue +0 -184
- package/src/pages/__tests__/Actions.test.ts +0 -98
- package/src/pages/__tests__/Projections.test.ts +0 -90
- package/src/pages/__tests__/Queries.test.ts +0 -86
package/src/pages/Sinks.vue
DELETED
|
@@ -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>
|
package/src/pages/TraceNode.vue
DELETED
|
@@ -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>
|
package/src/pages/Workflows.vue
DELETED
|
@@ -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
|
-
});
|