@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
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Errors / Debug — the friendly RCA surface.
|
|
4
|
+
*
|
|
5
|
+
* /errors?correlationId=…
|
|
6
|
+
*
|
|
7
|
+
* Source picker at the top: Live (stream the current run) or a past run
|
|
8
|
+
* (static snapshot). Subscribes via `useTelemetry`, groups every failure
|
|
9
|
+
* into incidents (`groupIncidents`), and reads each one back in plain language:
|
|
10
|
+
*
|
|
11
|
+
* - Left: a KPI strip (incidents · critical · dead-lettered) over a list of
|
|
12
|
+
* friendly ErrorCards, newest first, filterable by severity.
|
|
13
|
+
* - Right: the selected incident's RcaPanel (what happened · timeline ·
|
|
14
|
+
* impact · evidence), with a SourceDrawer for the failing unit.
|
|
15
|
+
*/
|
|
16
|
+
import { computed, ref, watch } from "vue";
|
|
17
|
+
import { useRoute, useRouter } from "vue-router";
|
|
18
|
+
import { ShieldCheck, Bug, Filter, Clock } from "lucide-vue-next";
|
|
19
|
+
import { useTelemetry, useRunList, type TelemetrySource } from "@/composables/useTelemetry";
|
|
20
|
+
import { useManifest } from "@/composables/useManifest";
|
|
21
|
+
import { groupIncidents, type Incident } from "@/lib/rca";
|
|
22
|
+
import type { Severity } from "@/lib/error-friendly";
|
|
23
|
+
import { ErrorCard, RcaPanel, StatusBadge, EmptyState, KpiTile, SourceDrawer } from "@/components";
|
|
24
|
+
|
|
25
|
+
const route = useRoute();
|
|
26
|
+
const router = useRouter();
|
|
27
|
+
|
|
28
|
+
const { records, status, source, connect } = useTelemetry();
|
|
29
|
+
const { runs } = useRunList();
|
|
30
|
+
const { view } = useManifest();
|
|
31
|
+
|
|
32
|
+
// ── Source picker ─────────────────────────────────────────────────────
|
|
33
|
+
const isLive = computed(() => source.value === "live");
|
|
34
|
+
|
|
35
|
+
function selectSource(s: TelemetrySource): void {
|
|
36
|
+
source.value = s;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isActiveRun(runId: string): boolean {
|
|
40
|
+
if (isLive.value) return false;
|
|
41
|
+
const s = source.value;
|
|
42
|
+
return typeof s === "object" && s !== null && s.id === runId;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function formatRunId(id: string): string {
|
|
46
|
+
const m = /^(\d{4}-\d{2}-\d{2})T(\d{2}-\d{2}-\d{2})/.exec(id);
|
|
47
|
+
if (m) return `${m[1]} ${m[2]!.replaceAll("-", ":")}`;
|
|
48
|
+
return id;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Connection state → StatusBadge ───────────────────────────────────
|
|
52
|
+
const conn = computed(() => {
|
|
53
|
+
if (!isLive.value) return { status: "idle" as const, label: "history", pulse: false };
|
|
54
|
+
switch (status.value) {
|
|
55
|
+
case "open":
|
|
56
|
+
return { status: "live" as const, label: "live", pulse: true };
|
|
57
|
+
case "connecting":
|
|
58
|
+
case "reconnecting":
|
|
59
|
+
return { status: "warn" as const, label: status.value, pulse: false };
|
|
60
|
+
case "closed":
|
|
61
|
+
return { status: "error" as const, label: "closed", pulse: false };
|
|
62
|
+
default:
|
|
63
|
+
return { status: "idle" as const, label: "idle", pulse: false };
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// ── Incidents (newest first) ─────────────────────────────────────────
|
|
68
|
+
const allIncidents = computed(() => groupIncidents(records.value));
|
|
69
|
+
|
|
70
|
+
const severityFilter = ref<Severity | "all">("all");
|
|
71
|
+
const SEVERITIES: { id: Severity | "all"; label: string }[] = [
|
|
72
|
+
{ id: "all", label: "All" },
|
|
73
|
+
{ id: "critical", label: "Critical" },
|
|
74
|
+
{ id: "high", label: "High" },
|
|
75
|
+
{ id: "warning", label: "Warning" },
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
const incidents = computed(() =>
|
|
79
|
+
severityFilter.value === "all"
|
|
80
|
+
? allIncidents.value
|
|
81
|
+
: allIncidents.value.filter((i) => i.severity === severityFilter.value),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const kpis = computed(() => {
|
|
85
|
+
const all = allIncidents.value;
|
|
86
|
+
return {
|
|
87
|
+
total: all.length,
|
|
88
|
+
critical: all.filter((i) => i.severity === "critical").length,
|
|
89
|
+
deadLettered: all.filter(
|
|
90
|
+
(i) => i.root.kind === "dlq.recorded" || i.root.kind === "reaction.exhausted",
|
|
91
|
+
).length,
|
|
92
|
+
};
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ── Selection (deep-linkable by correlationId) ───────────────────────
|
|
96
|
+
const selectedId = ref<string | undefined>(
|
|
97
|
+
route.query.correlationId ? String(route.query.correlationId) : undefined,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const selected = computed<Incident | undefined>(() => {
|
|
101
|
+
const list = incidents.value;
|
|
102
|
+
return list.find((i) => i.id === selectedId.value) ?? list[0];
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
function select(incident: Incident): void {
|
|
106
|
+
selectedId.value = incident.id;
|
|
107
|
+
if (incident.correlationId) {
|
|
108
|
+
void router.replace({ query: { ...route.query, correlationId: incident.correlationId } });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Keep the URL's correlationId in sync if the list re-keys to a new default.
|
|
113
|
+
watch(
|
|
114
|
+
() => route.query.correlationId,
|
|
115
|
+
(cid) => {
|
|
116
|
+
if (cid) selectedId.value = String(cid);
|
|
117
|
+
},
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
// ── Source of the failing unit, resolved from the manifest ───────────
|
|
121
|
+
const NODE_KIND: Record<string, string> = { "external call": "externalCall", operation: "handler" };
|
|
122
|
+
|
|
123
|
+
const selectedSource = computed(() => {
|
|
124
|
+
const s = selected.value?.friendly.subject;
|
|
125
|
+
if (!s || !view.value) return undefined;
|
|
126
|
+
const kind = NODE_KIND[s.kind] ?? s.kind;
|
|
127
|
+
return view.value.node(`${kind}:${s.name}`)?.source ?? undefined;
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const drawerSource = ref<{ file: string; line?: number; column?: number } | null>(null);
|
|
131
|
+
|
|
132
|
+
const loading = computed(() => status.value === "connecting" && records.value.length === 0);
|
|
133
|
+
</script>
|
|
134
|
+
|
|
135
|
+
<template>
|
|
136
|
+
<div class="h-full flex flex-col">
|
|
137
|
+
<!-- Source picker bar -->
|
|
138
|
+
<div
|
|
139
|
+
class="border-b border-zinc-800 px-4 py-2 flex items-center gap-2"
|
|
140
|
+
data-testid="errors-source-picker"
|
|
141
|
+
>
|
|
142
|
+
<button
|
|
143
|
+
class="h-7 px-2.5 rounded text-xs font-medium border transition-colors"
|
|
144
|
+
:class="
|
|
145
|
+
isLive
|
|
146
|
+
? 'bg-sky-950/60 border-sky-800 text-sky-300'
|
|
147
|
+
: 'bg-zinc-900 border-zinc-800 text-zinc-400 hover:text-zinc-200'
|
|
148
|
+
"
|
|
149
|
+
data-testid="errors-source-live"
|
|
150
|
+
@click="selectSource('live')"
|
|
151
|
+
>
|
|
152
|
+
Live
|
|
153
|
+
</button>
|
|
154
|
+
<template v-if="runs.length > 0">
|
|
155
|
+
<div class="h-4 w-px bg-zinc-800" />
|
|
156
|
+
<Clock class="w-3 h-3 text-zinc-600 shrink-0" />
|
|
157
|
+
<button
|
|
158
|
+
v-for="run in runs"
|
|
159
|
+
:key="run.id"
|
|
160
|
+
class="h-7 px-2.5 rounded text-xs border transition-colors font-mono"
|
|
161
|
+
:class="
|
|
162
|
+
isActiveRun(run.id)
|
|
163
|
+
? 'bg-zinc-700 border-zinc-600 text-zinc-100'
|
|
164
|
+
: 'bg-zinc-900 border-zinc-800 text-zinc-500 hover:text-zinc-200'
|
|
165
|
+
"
|
|
166
|
+
:data-testid="`errors-source-run-${run.id}`"
|
|
167
|
+
@click="selectSource(run)"
|
|
168
|
+
>
|
|
169
|
+
{{ formatRunId(run.id) }}
|
|
170
|
+
</button>
|
|
171
|
+
</template>
|
|
172
|
+
</div>
|
|
173
|
+
|
|
174
|
+
<!-- Main layout: left list + right panel -->
|
|
175
|
+
<div class="flex-1 flex min-h-0">
|
|
176
|
+
<!-- ── Left: KPI strip + incident list ──────────────────────────── -->
|
|
177
|
+
<aside class="w-[26rem] border-r border-zinc-800 flex flex-col shrink-0">
|
|
178
|
+
<header class="border-b border-zinc-800 px-4 py-3 flex items-center justify-between gap-2">
|
|
179
|
+
<div class="flex items-center gap-2">
|
|
180
|
+
<Bug class="w-4 h-4 text-rose-400" />
|
|
181
|
+
<h1 class="text-sm font-medium">Errors</h1>
|
|
182
|
+
</div>
|
|
183
|
+
<StatusBadge :status="conn.status" :label="conn.label" :pulse="conn.pulse" />
|
|
184
|
+
</header>
|
|
185
|
+
|
|
186
|
+
<div class="px-3 py-3 grid grid-cols-3 gap-2 border-b border-zinc-800">
|
|
187
|
+
<KpiTile label="Incidents" :value="kpis.total" />
|
|
188
|
+
<KpiTile
|
|
189
|
+
label="Critical"
|
|
190
|
+
:value="kpis.critical"
|
|
191
|
+
:accent="kpis.critical ? '#fb7185' : undefined"
|
|
192
|
+
/>
|
|
193
|
+
<KpiTile
|
|
194
|
+
label="Dead-letter"
|
|
195
|
+
:value="kpis.deadLettered"
|
|
196
|
+
:accent="kpis.deadLettered ? '#fb7185' : undefined"
|
|
197
|
+
/>
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
<div class="px-3 py-2 border-b border-zinc-800 flex items-center gap-1.5">
|
|
201
|
+
<Filter class="w-3 h-3 text-zinc-600" />
|
|
202
|
+
<button
|
|
203
|
+
v-for="s in SEVERITIES"
|
|
204
|
+
:key="s.id"
|
|
205
|
+
class="text-[11px] px-2 py-0.5 rounded transition-colors"
|
|
206
|
+
:class="
|
|
207
|
+
severityFilter === s.id
|
|
208
|
+
? 'bg-zinc-800 text-zinc-100'
|
|
209
|
+
: 'text-zinc-500 hover:text-zinc-300'
|
|
210
|
+
"
|
|
211
|
+
:data-testid="`sev-filter-${s.id}`"
|
|
212
|
+
@click="severityFilter = s.id"
|
|
213
|
+
>
|
|
214
|
+
{{ s.label }}
|
|
215
|
+
</button>
|
|
216
|
+
</div>
|
|
217
|
+
|
|
218
|
+
<div class="flex-1 overflow-auto p-3 space-y-2">
|
|
219
|
+
<EmptyState
|
|
220
|
+
v-if="allIncidents.length === 0"
|
|
221
|
+
:icon="ShieldCheck"
|
|
222
|
+
title="All clear"
|
|
223
|
+
:hint="
|
|
224
|
+
loading
|
|
225
|
+
? 'Connecting to the telemetry stream…'
|
|
226
|
+
: 'No failures on the stream. Trigger behavior and anything that breaks will show up here — explained, not just logged.'
|
|
227
|
+
"
|
|
228
|
+
/>
|
|
229
|
+
<div v-else-if="incidents.length === 0" class="text-xs text-zinc-500 p-4 text-center">
|
|
230
|
+
No {{ severityFilter }} incidents.
|
|
231
|
+
</div>
|
|
232
|
+
<ErrorCard
|
|
233
|
+
v-for="incident in incidents"
|
|
234
|
+
:key="incident.id"
|
|
235
|
+
:record="incident.root"
|
|
236
|
+
:selected="incident.id === selected?.id"
|
|
237
|
+
@select="select(incident)"
|
|
238
|
+
/>
|
|
239
|
+
</div>
|
|
240
|
+
</aside>
|
|
241
|
+
|
|
242
|
+
<!-- ── Right: RCA panel ─────────────────────────────────────────── -->
|
|
243
|
+
<main class="flex-1 flex flex-col min-w-0">
|
|
244
|
+
<RcaPanel
|
|
245
|
+
v-if="selected"
|
|
246
|
+
:incident="selected"
|
|
247
|
+
:source="selectedSource"
|
|
248
|
+
class="flex-1 min-h-0"
|
|
249
|
+
@open-source="drawerSource = $event"
|
|
250
|
+
/>
|
|
251
|
+
<EmptyState
|
|
252
|
+
v-else
|
|
253
|
+
:icon="ShieldCheck"
|
|
254
|
+
title="Nothing to debug"
|
|
255
|
+
hint="When a failure lands, pick it on the left to open its root-cause analysis."
|
|
256
|
+
class="m-auto"
|
|
257
|
+
/>
|
|
258
|
+
</main>
|
|
259
|
+
|
|
260
|
+
<SourceDrawer :source="drawerSource" @close="drawerSource = null" />
|
|
261
|
+
|
|
262
|
+
<!-- reconnect affordance when the stream drops (live mode only) -->
|
|
263
|
+
<button
|
|
264
|
+
v-if="isLive && status === 'closed'"
|
|
265
|
+
class="fixed bottom-4 right-4 text-xs px-3 py-1.5 rounded bg-zinc-800 text-orange-400 hover:bg-zinc-700"
|
|
266
|
+
@click="connect()"
|
|
267
|
+
>
|
|
268
|
+
Reconnect stream
|
|
269
|
+
</button>
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
</template>
|
|
@@ -3,18 +3,17 @@ import { createRouter, createMemoryHistory } from "vue-router";
|
|
|
3
3
|
import Home from "./Home.vue";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* Storybook for the Home dashboard.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* Storybook those fail and the component degrades to its "no live data"
|
|
10
|
-
* states, which is exactly what we want to document.
|
|
6
|
+
* Storybook for the Home discovery dashboard. The `/__nwire/*` fetches fail
|
|
7
|
+
* inside Storybook, so the discovered-projects grid degrades to its empty
|
|
8
|
+
* state — exactly the surface we want to document.
|
|
11
9
|
*/
|
|
12
10
|
const router = createRouter({
|
|
13
11
|
history: createMemoryHistory(),
|
|
14
12
|
routes: [
|
|
15
13
|
{ path: "/", name: "home", component: Home },
|
|
16
14
|
{ path: "/trace", name: "trace", component: { template: "<div/>" } },
|
|
17
|
-
{ path: "/
|
|
15
|
+
{ path: "/streams", name: "streams", component: { template: "<div/>" } },
|
|
16
|
+
{ path: "/projects/:slug/:page", name: "page", component: { template: "<div/>" } },
|
|
18
17
|
],
|
|
19
18
|
});
|
|
20
19
|
|
|
@@ -43,5 +42,5 @@ export default meta;
|
|
|
43
42
|
|
|
44
43
|
type Story = StoryObj<typeof Home>;
|
|
45
44
|
|
|
46
|
-
/** Default:
|
|
47
|
-
export const
|
|
45
|
+
/** Default: nothing reachable — the grid degrades to its empty state. */
|
|
46
|
+
export const NoProjects: Story = {};
|