@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,74 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* `WaterfallRow` — one row of the trace waterfall: a depth-indented gutter
|
|
4
|
+
* (kind dot + label + kind), and a time-lane bar positioned by `offsetPct` /
|
|
5
|
+
* `widthPct`. Critical-path rows get a brighter bar; failures go red; the
|
|
6
|
+
* selected row is highlighted. Purely presentational — `Waterfall` owns layout.
|
|
7
|
+
*/
|
|
8
|
+
import { computed } from "vue";
|
|
9
|
+
import { kindColor, recordColorKey } from "@/lib/kind-colors";
|
|
10
|
+
import type { WaterfallRow } from "@/lib/waterfall";
|
|
11
|
+
|
|
12
|
+
const props = withDefaults(
|
|
13
|
+
defineProps<{
|
|
14
|
+
row: WaterfallRow;
|
|
15
|
+
selected?: boolean;
|
|
16
|
+
/** Rows beyond the replay cursor render dimmed. */
|
|
17
|
+
dimmed?: boolean;
|
|
18
|
+
}>(),
|
|
19
|
+
{ selected: false, dimmed: false },
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
const emit = defineEmits<{ (e: "select", row: WaterfallRow): void }>();
|
|
23
|
+
|
|
24
|
+
const color = computed(() => kindColor(recordColorKey(props.row.record.kind)));
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<template>
|
|
28
|
+
<button
|
|
29
|
+
class="group flex w-full items-center gap-2 px-2 py-1 text-left transition-colors"
|
|
30
|
+
:class="[selected ? 'bg-zinc-800/80' : 'hover:bg-zinc-900/60', dimmed ? 'opacity-30' : '']"
|
|
31
|
+
data-testid="waterfall-row"
|
|
32
|
+
:data-critical="row.critical ? 'true' : undefined"
|
|
33
|
+
:data-failed="row.failed ? 'true' : undefined"
|
|
34
|
+
@click="emit('select', row)"
|
|
35
|
+
>
|
|
36
|
+
<!-- Gutter: indent + dot + label -->
|
|
37
|
+
<div
|
|
38
|
+
class="flex items-center gap-1.5 w-1/2 min-w-0"
|
|
39
|
+
:style="{ paddingLeft: row.depth * 14 + 'px' }"
|
|
40
|
+
>
|
|
41
|
+
<span class="h-2 w-2 rounded-full shrink-0" :style="{ backgroundColor: color }" />
|
|
42
|
+
<span
|
|
43
|
+
class="font-mono text-xs truncate"
|
|
44
|
+
:class="row.failed ? 'text-rose-300' : 'text-zinc-200'"
|
|
45
|
+
data-testid="waterfall-label"
|
|
46
|
+
>
|
|
47
|
+
{{ row.label }}
|
|
48
|
+
</span>
|
|
49
|
+
<span class="text-[10px] text-zinc-600 truncate shrink-0">{{ row.record.kind }}</span>
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<!-- Time lane -->
|
|
53
|
+
<div class="relative h-3 flex-1">
|
|
54
|
+
<span
|
|
55
|
+
class="absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full"
|
|
56
|
+
:class="[
|
|
57
|
+
row.failed ? 'bg-rose-500/80' : '',
|
|
58
|
+
row.critical && !row.failed ? 'ring-1 ring-white/40' : '',
|
|
59
|
+
]"
|
|
60
|
+
:style="{
|
|
61
|
+
left: row.offsetPct + '%',
|
|
62
|
+
width: row.widthPct + '%',
|
|
63
|
+
backgroundColor: row.failed ? undefined : color,
|
|
64
|
+
opacity: row.critical || row.failed ? 1 : 0.55,
|
|
65
|
+
}"
|
|
66
|
+
data-testid="waterfall-bar"
|
|
67
|
+
/>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<span class="text-[10px] tabular-nums text-zinc-500 w-14 text-right shrink-0">
|
|
71
|
+
{{ row.durationMs.toFixed(1) }}ms
|
|
72
|
+
</span>
|
|
73
|
+
</button>
|
|
74
|
+
</template>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { mount } from "@vue/test-utils";
|
|
3
|
+
import BcCard from "../BcCard.vue";
|
|
4
|
+
import type { BcNode } from "@/lib/bc-graph";
|
|
5
|
+
|
|
6
|
+
const bc: BcNode = {
|
|
7
|
+
name: "orders",
|
|
8
|
+
rows: [
|
|
9
|
+
{ id: "action:orders.place", kind: "action", name: "orders.place", public: true },
|
|
10
|
+
{ id: "event:orders.placed", kind: "event", name: "orders.placed" },
|
|
11
|
+
{ id: "query:orders.list", kind: "query", name: "orders.list" },
|
|
12
|
+
],
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
describe("BcCard", () => {
|
|
16
|
+
it("shows the app name and member count", () => {
|
|
17
|
+
const w = mount(BcCard, { props: { bc } });
|
|
18
|
+
expect(w.get('[data-testid="bc-card-name"]').text()).toBe("orders");
|
|
19
|
+
expect(w.text()).toContain("3");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("renders per-kind count chips", () => {
|
|
23
|
+
const w = mount(BcCard, { props: { bc } });
|
|
24
|
+
expect(w.text()).toContain("1 action");
|
|
25
|
+
expect(w.text()).toContain("1 event");
|
|
26
|
+
expect(w.text()).toContain("1 query");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("strips the app prefix from row names", () => {
|
|
30
|
+
const w = mount(BcCard, { props: { bc } });
|
|
31
|
+
const row = w.get('[data-testid="bc-row-action:orders.place"]');
|
|
32
|
+
expect(row.text()).toContain("place");
|
|
33
|
+
expect(row.text()).not.toContain("orders.place");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("marks public rows and emits select with the row on click", async () => {
|
|
37
|
+
const w = mount(BcCard, { props: { bc } });
|
|
38
|
+
const row = w.get('[data-testid="bc-row-action:orders.place"]');
|
|
39
|
+
expect(row.find('[data-testid="bc-row-public"]').exists()).toBe(true);
|
|
40
|
+
await row.trigger("click");
|
|
41
|
+
expect(w.emitted("select")?.[0]?.[0]).toMatchObject({ id: "action:orders.place" });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("highlights the selected row", () => {
|
|
45
|
+
const w = mount(BcCard, { props: { bc, selectedId: "event:orders.placed" } });
|
|
46
|
+
expect(w.get('[data-testid="bc-row-event:orders.placed"]').classes()).toContain("bg-zinc-900");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("shows an empty hint when the BC has no primitives", () => {
|
|
50
|
+
const w = mount(BcCard, { props: { bc: { name: "empty", rows: [] } } });
|
|
51
|
+
expect(w.text()).toContain("No primitives yet");
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { mount } from "@vue/test-utils";
|
|
3
|
+
import DurationBar from "../DurationBar.vue";
|
|
4
|
+
|
|
5
|
+
describe("DurationBar", () => {
|
|
6
|
+
it("renders the duration label with one decimal", () => {
|
|
7
|
+
const w = mount(DurationBar, { props: { ms: 12.34 } });
|
|
8
|
+
expect(w.get('[data-testid="duration-value"]').text()).toBe("12.3ms");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("is green under the warn threshold", () => {
|
|
12
|
+
const w = mount(DurationBar, { props: { ms: 10, warnMs: 50, dangerMs: 100 } });
|
|
13
|
+
expect(w.get('[data-testid="duration-bar"]').attributes("data-tone")).toBe("ok");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("turns amber at the warn threshold", () => {
|
|
17
|
+
const w = mount(DurationBar, { props: { ms: 60, warnMs: 50, dangerMs: 100 } });
|
|
18
|
+
expect(w.get('[data-testid="duration-bar"]').attributes("data-tone")).toBe("warn");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("turns red at the danger threshold", () => {
|
|
22
|
+
const w = mount(DurationBar, { props: { ms: 120, warnMs: 50, dangerMs: 100 } });
|
|
23
|
+
expect(w.get('[data-testid="duration-bar"]').attributes("data-tone")).toBe("danger");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("scales the bar width against max", () => {
|
|
27
|
+
const w = mount(DurationBar, { props: { ms: 25, max: 100 } });
|
|
28
|
+
const bar = w.get('[data-testid="duration-bar"] > div > div');
|
|
29
|
+
expect(bar.attributes("style")).toContain("width: 25%");
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { mount } from "@vue/test-utils";
|
|
3
|
+
import ErrorCard from "../ErrorCard.vue";
|
|
4
|
+
import type { TelemetryRecord } from "@/lib/telemetry";
|
|
5
|
+
|
|
6
|
+
const stubs = { RouterLink: { template: "<a data-stub-link><slot /></a>" } };
|
|
7
|
+
|
|
8
|
+
const mountCard = (record: TelemetryRecord, props: Record<string, unknown> = {}) =>
|
|
9
|
+
mount(ErrorCard, { props: { record, ...props }, global: { stubs } });
|
|
10
|
+
|
|
11
|
+
describe("ErrorCard", () => {
|
|
12
|
+
it("renders the friendly title + summary and tags the severity", () => {
|
|
13
|
+
const w = mountCard({
|
|
14
|
+
kind: "dlq.recorded",
|
|
15
|
+
action: "orders.ship",
|
|
16
|
+
attempts: 5,
|
|
17
|
+
error: { name: "TypeError", message: "boom" },
|
|
18
|
+
ts: "2026-01-01T00:00:00.000Z",
|
|
19
|
+
envelope: { messageId: "m", causationId: "m", correlationId: "c-123" },
|
|
20
|
+
});
|
|
21
|
+
expect(w.get('[data-testid="error-title"]').text()).toBe("Dead-lettered");
|
|
22
|
+
expect(w.get('[data-testid="error-summary"]').text()).toContain("orders.ship");
|
|
23
|
+
expect(w.get('[data-testid="error-card"]').attributes("data-severity")).toBe("critical");
|
|
24
|
+
expect(w.get('[data-testid="error-severity"]').text()).toBe("Critical");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("shows the retry narrative when the record carries attempts", () => {
|
|
28
|
+
const w = mountCard({
|
|
29
|
+
kind: "action.failed",
|
|
30
|
+
action: "orders.charge",
|
|
31
|
+
attempt: 2,
|
|
32
|
+
maxAttempts: 3,
|
|
33
|
+
willRetry: true,
|
|
34
|
+
error: { name: "E", message: "x" },
|
|
35
|
+
});
|
|
36
|
+
expect(w.get('[data-testid="error-retry"]').text()).toContain("attempt 2 of 3");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("renders a correlation chip linking to the trace", () => {
|
|
40
|
+
const w = mountCard({
|
|
41
|
+
kind: "action.failed",
|
|
42
|
+
action: "a",
|
|
43
|
+
error: { name: "E", message: "x" },
|
|
44
|
+
envelope: { messageId: "m", causationId: "m", correlationId: "c-abcdef-1" },
|
|
45
|
+
});
|
|
46
|
+
expect(w.get('[data-testid="error-correlation"]').text()).toContain("c");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("emits select on click and reflects the selected state", async () => {
|
|
50
|
+
const w = mountCard(
|
|
51
|
+
{ kind: "action.failed", action: "a", error: { name: "E", message: "x" } },
|
|
52
|
+
{ selected: true },
|
|
53
|
+
);
|
|
54
|
+
await w.get('[data-testid="error-card"]').trigger("click");
|
|
55
|
+
expect(w.emitted("select")).toBeTruthy();
|
|
56
|
+
expect(w.get('[data-testid="error-card"]').classes()).toContain("border-zinc-600");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("hides the correlation chip when asked", () => {
|
|
60
|
+
const w = mountCard(
|
|
61
|
+
{
|
|
62
|
+
kind: "action.failed",
|
|
63
|
+
action: "a",
|
|
64
|
+
error: { name: "E", message: "x" },
|
|
65
|
+
envelope: { messageId: "m", causationId: "m", correlationId: "c-1" },
|
|
66
|
+
},
|
|
67
|
+
{ showCorrelation: false },
|
|
68
|
+
);
|
|
69
|
+
expect(w.find('[data-testid="error-correlation"]').exists()).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { mount } from "@vue/test-utils";
|
|
3
|
+
import KpiTile from "../KpiTile.vue";
|
|
4
|
+
|
|
5
|
+
describe("KpiTile", () => {
|
|
6
|
+
it("renders label, value and sub", () => {
|
|
7
|
+
const w = mount(KpiTile, { props: { label: "Actions", value: 12, sub: "3 public" } });
|
|
8
|
+
expect(w.text()).toContain("Actions");
|
|
9
|
+
expect(w.get('[data-testid="kpi-value"]').text()).toBe("12");
|
|
10
|
+
expect(w.text()).toContain("3 public");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("applies the accent colour to the value", () => {
|
|
14
|
+
const w = mount(KpiTile, { props: { label: "Events", value: 4, accent: "#c08bff" } });
|
|
15
|
+
expect(w.get('[data-testid="kpi-value"]').attributes("style")).toContain("#c08bff");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("omits the sub line when not given", () => {
|
|
19
|
+
const w = mount(KpiTile, { props: { label: "Queries", value: 0 } });
|
|
20
|
+
expect(w.text()).toContain("Queries");
|
|
21
|
+
expect(w.text()).toContain("0");
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { mount } from "@vue/test-utils";
|
|
3
|
+
import LiveTable from "../LiveTable.vue";
|
|
4
|
+
import type { TelemetryRecord } from "@/lib/telemetry";
|
|
5
|
+
|
|
6
|
+
function rec(over: Partial<TelemetryRecord> & { kind: string }): TelemetryRecord {
|
|
7
|
+
return over as TelemetryRecord;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const rows: TelemetryRecord[] = [
|
|
11
|
+
rec({
|
|
12
|
+
kind: "action.dispatched",
|
|
13
|
+
action: "shop.place-order",
|
|
14
|
+
ts: "2026-06-15T10:00:00.000Z",
|
|
15
|
+
envelope: { messageId: "m1", correlationId: "c-abc-1", causationId: "m1" },
|
|
16
|
+
}),
|
|
17
|
+
rec({
|
|
18
|
+
kind: "action.completed",
|
|
19
|
+
action: "shop.place-order",
|
|
20
|
+
durationMs: 37,
|
|
21
|
+
ts: "2026-06-15T10:00:01.000Z",
|
|
22
|
+
envelope: { messageId: "m2", correlationId: "c-abc-1", causationId: "m1" },
|
|
23
|
+
}),
|
|
24
|
+
rec({
|
|
25
|
+
kind: "query.executed",
|
|
26
|
+
query: "shop.order-by-id",
|
|
27
|
+
durationMs: 4,
|
|
28
|
+
ts: "2026-06-15T10:00:02.000Z",
|
|
29
|
+
envelope: { messageId: "m3", correlationId: "c-def-2", causationId: "m1" },
|
|
30
|
+
}),
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
describe("LiveTable", () => {
|
|
34
|
+
it("renders a row per record, newest first by default", () => {
|
|
35
|
+
const w = mount(LiveTable, { props: { rows } });
|
|
36
|
+
const cells = w.findAll('[data-testid="live-row"]');
|
|
37
|
+
expect(cells).toHaveLength(3);
|
|
38
|
+
// default sort is time desc → query.executed (10:00:02) leads
|
|
39
|
+
expect(cells[0]!.text()).toContain("query.executed");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("shows subject, duration and short correlation", () => {
|
|
43
|
+
const w = mount(LiveTable, { props: { rows } });
|
|
44
|
+
const text = w.text();
|
|
45
|
+
expect(text).toContain("shop.place-order");
|
|
46
|
+
expect(text).toContain("37ms");
|
|
47
|
+
expect(text).toContain("c"); // shortId("c-abc-1") → "c"
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("toggles sort direction when a header is clicked", async () => {
|
|
51
|
+
const w = mount(LiveTable, { props: { rows } });
|
|
52
|
+
await w.find('[data-testid="col-time"]').trigger("click"); // time → asc
|
|
53
|
+
const cells = w.findAll('[data-testid="live-row"]');
|
|
54
|
+
expect(cells[0]!.text()).toContain("action.dispatched"); // oldest first now
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("re-sorts by a different column on header click", async () => {
|
|
58
|
+
const w = mount(LiveTable, { props: { rows } });
|
|
59
|
+
await w.find('[data-testid="col-subject"]').trigger("click"); // subject asc
|
|
60
|
+
const cells = w.findAll('[data-testid="live-row"]');
|
|
61
|
+
// "shop.order-by-id" < "shop.place-order"
|
|
62
|
+
expect(cells[0]!.text()).toContain("shop.order-by-id");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("emits select + update:selectedIndex on row click", async () => {
|
|
66
|
+
const w = mount(LiveTable, { props: { rows } });
|
|
67
|
+
await w.findAll('[data-testid="live-row"]')[0]!.trigger("click");
|
|
68
|
+
expect(w.emitted("select")).toBeTruthy();
|
|
69
|
+
const idx = w.emitted("update:selectedIndex");
|
|
70
|
+
expect(idx).toBeTruthy();
|
|
71
|
+
expect(idx![0]![0]).toBe(0);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("walks selection with ArrowDown / ArrowUp", async () => {
|
|
75
|
+
const w = mount(LiveTable, { props: { rows, selectedIndex: -1 } });
|
|
76
|
+
const grid = w.find('[data-testid="live-table"]');
|
|
77
|
+
await grid.trigger("keydown", { key: "ArrowDown" });
|
|
78
|
+
expect(w.emitted("update:selectedIndex")!.at(-1)![0]).toBe(0);
|
|
79
|
+
|
|
80
|
+
await w.setProps({ selectedIndex: 0 });
|
|
81
|
+
await grid.trigger("keydown", { key: "ArrowDown" });
|
|
82
|
+
expect(w.emitted("update:selectedIndex")!.at(-1)![0]).toBe(1);
|
|
83
|
+
|
|
84
|
+
await w.setProps({ selectedIndex: 1 });
|
|
85
|
+
await grid.trigger("keydown", { key: "ArrowUp" });
|
|
86
|
+
expect(w.emitted("update:selectedIndex")!.at(-1)![0]).toBe(0);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("opens the selected row on Enter", async () => {
|
|
90
|
+
const w = mount(LiveTable, { props: { rows, selectedIndex: 0 } });
|
|
91
|
+
await w.find('[data-testid="live-table"]').trigger("keydown", { key: "Enter" });
|
|
92
|
+
expect(w.emitted("select")).toBeTruthy();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("marks the selected row with aria-selected", () => {
|
|
96
|
+
const w = mount(LiveTable, { props: { rows, selectedIndex: 1 } });
|
|
97
|
+
const selected = w.findAll('[data-testid="live-row"]')[1]!;
|
|
98
|
+
expect(selected.attributes("aria-selected")).toBe("true");
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { mount } from "@vue/test-utils";
|
|
3
|
+
import MetadataInspector from "../MetadataInspector.vue";
|
|
4
|
+
|
|
5
|
+
describe("MetadataInspector", () => {
|
|
6
|
+
it("renders one row per flattened leaf", () => {
|
|
7
|
+
const w = mount(MetadataInspector, {
|
|
8
|
+
props: { data: { kind: "action.completed", envelope: { messageId: "m1" } } },
|
|
9
|
+
});
|
|
10
|
+
const rows = w.findAll('[data-testid="meta-row"]');
|
|
11
|
+
expect(rows).toHaveLength(2);
|
|
12
|
+
expect(w.text()).toContain("envelope.messageId");
|
|
13
|
+
expect(w.text()).toContain("m1");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("shows an empty hint for an empty object", () => {
|
|
17
|
+
const w = mount(MetadataInspector, { props: { data: {} } });
|
|
18
|
+
expect(w.find('[data-testid="meta-empty"]').exists()).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("copies a value to the clipboard on row click", async () => {
|
|
22
|
+
const writeText = vi.fn().mockResolvedValue(undefined);
|
|
23
|
+
vi.stubGlobal("navigator", { clipboard: { writeText } });
|
|
24
|
+
const w = mount(MetadataInspector, { props: { data: { id: "abc" } } });
|
|
25
|
+
await w.get('[data-testid="meta-row"]').trigger("click");
|
|
26
|
+
expect(writeText).toHaveBeenCalledWith("abc");
|
|
27
|
+
vi.unstubAllGlobals();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("copies the whole payload as JSON from the header", async () => {
|
|
31
|
+
const writeText = vi.fn().mockResolvedValue(undefined);
|
|
32
|
+
vi.stubGlobal("navigator", { clipboard: { writeText } });
|
|
33
|
+
const w = mount(MetadataInspector, { props: { data: { a: 1 }, label: "Span" } });
|
|
34
|
+
await w.get('[data-testid="meta-copy-all"]').trigger("click");
|
|
35
|
+
expect(writeText).toHaveBeenCalledWith(JSON.stringify({ a: 1 }, null, 2));
|
|
36
|
+
vi.unstubAllGlobals();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { mount } from "@vue/test-utils";
|
|
3
|
+
import NodeCard from "../NodeCard.vue";
|
|
4
|
+
import type { BcNodeDetail } from "@/lib/bc-graph";
|
|
5
|
+
|
|
6
|
+
const detail: BcNodeDetail = {
|
|
7
|
+
id: "action:orders.place",
|
|
8
|
+
kind: "action",
|
|
9
|
+
name: "orders.place",
|
|
10
|
+
app: "orders",
|
|
11
|
+
description: "Place an order.",
|
|
12
|
+
public: true,
|
|
13
|
+
source: { file: "/app/actions.ts", line: 12, column: 4 },
|
|
14
|
+
schema: { type: "object", properties: { id: { type: "string" } } },
|
|
15
|
+
emits: ["orders.placed"],
|
|
16
|
+
extra: { retry: 3 },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
describe("NodeCard", () => {
|
|
20
|
+
it("renders the empty state with no detail", () => {
|
|
21
|
+
const w = mount(NodeCard, { props: { detail: null } });
|
|
22
|
+
expect(w.find('[data-testid="node-card-empty"]').exists()).toBe(true);
|
|
23
|
+
expect(w.find('[data-testid="node-card"]').exists()).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("renders kind, name, app and description", () => {
|
|
27
|
+
const w = mount(NodeCard, { props: { detail } });
|
|
28
|
+
expect(w.text()).toContain("action");
|
|
29
|
+
expect(w.text()).toContain("orders.place");
|
|
30
|
+
expect(w.text()).toContain("orders");
|
|
31
|
+
expect(w.text()).toContain("Place an order.");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("shows the public marker for public nodes", () => {
|
|
35
|
+
const w = mount(NodeCard, { props: { detail } });
|
|
36
|
+
expect(w.find('[data-testid="node-public"]').exists()).toBe(true);
|
|
37
|
+
const priv = mount(NodeCard, { props: { detail: { ...detail, public: false } } });
|
|
38
|
+
expect(priv.find('[data-testid="node-private"]').exists()).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("emits select-event when an emitted event chip is clicked", async () => {
|
|
42
|
+
const w = mount(NodeCard, { props: { detail } });
|
|
43
|
+
await w.get('[data-testid="emits-orders.placed"]').trigger("click");
|
|
44
|
+
expect(w.emitted("select-event")?.[0]).toEqual(["orders.placed"]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("emits open-source when the source pill is clicked", async () => {
|
|
48
|
+
const w = mount(NodeCard, { props: { detail } });
|
|
49
|
+
await w.get('[data-testid="source-pill"]').trigger("click");
|
|
50
|
+
expect(w.emitted("open-source")?.[0]?.[0]).toMatchObject({ line: 12 });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("renders a Business rules section listing each invariant message + rule", () => {
|
|
54
|
+
const withRules: BcNodeDetail = {
|
|
55
|
+
...detail,
|
|
56
|
+
invariants: [
|
|
57
|
+
{
|
|
58
|
+
rule: '(i) => i.by.length > 0 || "Must name the moderator"',
|
|
59
|
+
message: "Must name the moderator",
|
|
60
|
+
},
|
|
61
|
+
{ rule: "(i) => i.ok" },
|
|
62
|
+
],
|
|
63
|
+
};
|
|
64
|
+
const w = mount(NodeCard, { props: { detail: withRules } });
|
|
65
|
+
const panel = w.find('[data-testid="business-rules"]');
|
|
66
|
+
expect(panel.exists()).toBe(true);
|
|
67
|
+
expect(panel.text()).toContain("Business rules");
|
|
68
|
+
expect(panel.text()).toContain("Must name the moderator");
|
|
69
|
+
expect(panel.text()).toContain('(i) => i.by.length > 0 || "Must name the moderator"');
|
|
70
|
+
// A rule with no message still shows its source plus the placeholder.
|
|
71
|
+
expect(panel.text()).toContain("(i) => i.ok");
|
|
72
|
+
expect(panel.text()).toContain("no stated message");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("omits the Business rules section when there are no invariants", () => {
|
|
76
|
+
const w = mount(NodeCard, { props: { detail } });
|
|
77
|
+
expect(w.find('[data-testid="business-rules"]').exists()).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const appDetail: BcNodeDetail = {
|
|
81
|
+
id: "app:moderation",
|
|
82
|
+
kind: "app",
|
|
83
|
+
name: "moderation",
|
|
84
|
+
description: "Moderation BC.",
|
|
85
|
+
env: ["MODERATION_AUTO_THRESHOLD", "PORT"],
|
|
86
|
+
config: [{ file: "/abs/app/config/moderation.ts", keys: ["moderation", "spamMarker"] }],
|
|
87
|
+
extra: { plugins: ["forge.actions"] },
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
it("renders an Environment section listing each env var the app reads", () => {
|
|
91
|
+
const w = mount(NodeCard, { props: { detail: appDetail } });
|
|
92
|
+
const panel = w.find('[data-testid="environment"]');
|
|
93
|
+
expect(panel.exists()).toBe(true);
|
|
94
|
+
expect(panel.text()).toContain("Environment");
|
|
95
|
+
expect(w.find('[data-testid="env-PORT"]').exists()).toBe(true);
|
|
96
|
+
expect(w.find('[data-testid="env-MODERATION_AUTO_THRESHOLD"]').exists()).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("renders a Config section with each module's file (config-relative) + keys", () => {
|
|
100
|
+
const w = mount(NodeCard, { props: { detail: appDetail } });
|
|
101
|
+
const panel = w.find('[data-testid="config"]');
|
|
102
|
+
expect(panel.exists()).toBe(true);
|
|
103
|
+
expect(panel.text()).toContain("Config");
|
|
104
|
+
// Path is shown from the config/ segment, not the absolute prefix.
|
|
105
|
+
expect(panel.text()).toContain("config/moderation.ts");
|
|
106
|
+
expect(panel.text()).not.toContain("/abs/app");
|
|
107
|
+
expect(panel.text()).toContain("moderation");
|
|
108
|
+
expect(panel.text()).toContain("spamMarker");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("omits Environment + Config sections when absent", () => {
|
|
112
|
+
const w = mount(NodeCard, { props: { detail } });
|
|
113
|
+
expect(w.find('[data-testid="environment"]').exists()).toBe(false);
|
|
114
|
+
expect(w.find('[data-testid="config"]').exists()).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { mount } from "@vue/test-utils";
|
|
3
|
+
import RcaPanel from "../RcaPanel.vue";
|
|
4
|
+
import { groupIncidents } from "@/lib/rca";
|
|
5
|
+
import type { TelemetryRecord } from "@/lib/telemetry";
|
|
6
|
+
|
|
7
|
+
const env = (m: string, c: string, corr: string) => ({
|
|
8
|
+
messageId: m,
|
|
9
|
+
causationId: c,
|
|
10
|
+
correlationId: corr,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const RECORDS: TelemetryRecord[] = [
|
|
14
|
+
{
|
|
15
|
+
kind: "action.dispatched",
|
|
16
|
+
action: "orders.charge",
|
|
17
|
+
appName: "billing",
|
|
18
|
+
ts: "2026-01-01T00:00:00.000Z",
|
|
19
|
+
envelope: env("m1", "m1", "c1"),
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
kind: "action.failed",
|
|
23
|
+
action: "orders.charge",
|
|
24
|
+
appName: "billing",
|
|
25
|
+
attempt: 1,
|
|
26
|
+
maxAttempts: 3,
|
|
27
|
+
willRetry: true,
|
|
28
|
+
ts: "2026-01-01T00:00:00.050Z",
|
|
29
|
+
error: { name: "TimeoutError", message: "timed out", stack: "at chargeCard (billing.ts:42)" },
|
|
30
|
+
envelope: env("m2", "m1", "c1"),
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
kind: "dlq.recorded",
|
|
34
|
+
action: "orders.charge",
|
|
35
|
+
appName: "billing",
|
|
36
|
+
attempts: 3,
|
|
37
|
+
ts: "2026-01-01T00:00:00.300Z",
|
|
38
|
+
error: { name: "TimeoutError", message: "timed out" },
|
|
39
|
+
envelope: env("m3", "m1", "c1"),
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
const incident = groupIncidents(RECORDS)[0]!;
|
|
44
|
+
|
|
45
|
+
describe("RcaPanel", () => {
|
|
46
|
+
it("opens on the 'What happened' tab with the suggested fix", () => {
|
|
47
|
+
const w = mount(RcaPanel, { props: { incident } });
|
|
48
|
+
expect(w.get('[data-testid="rca-what"]').exists()).toBe(true);
|
|
49
|
+
expect(w.get('[data-testid="rca-suggestion"]').text().length).toBeGreaterThan(0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("switches to the timeline and lists the whole chain with failures flagged", async () => {
|
|
53
|
+
const w = mount(RcaPanel, { props: { incident } });
|
|
54
|
+
await w.get('[data-testid="rca-tab-timeline"]').trigger("click");
|
|
55
|
+
const rows = w.findAll('[data-testid="rca-timeline-row"]');
|
|
56
|
+
expect(rows).toHaveLength(3);
|
|
57
|
+
expect(rows.filter((r) => r.attributes("data-failure") === "true")).toHaveLength(2);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("shows the blast radius on the impact tab", async () => {
|
|
61
|
+
const w = mount(RcaPanel, { props: { incident } });
|
|
62
|
+
await w.get('[data-testid="rca-tab-impact"]').trigger("click");
|
|
63
|
+
const impact = w.get('[data-testid="rca-impact"]');
|
|
64
|
+
expect(impact.text()).toContain("billing");
|
|
65
|
+
expect(impact.text()).toContain("Yes"); // dead-lettered
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("shows the root error + stack on the evidence tab", async () => {
|
|
69
|
+
const w = mount(RcaPanel, { props: { incident } });
|
|
70
|
+
await w.get('[data-testid="rca-tab-evidence"]').trigger("click");
|
|
71
|
+
expect(w.get('[data-testid="rca-evidence"]').text()).toContain("TimeoutError");
|
|
72
|
+
expect(w.get('[data-testid="rca-stack"]').text()).toContain("chargeCard");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("emits open-source when the source pill is clicked", async () => {
|
|
76
|
+
const source = { file: "apps/billing/orders.charge.ts", line: 42 };
|
|
77
|
+
const w = mount(RcaPanel, { props: { incident, source } });
|
|
78
|
+
await w.get('[data-testid="source-pill"]').trigger("click");
|
|
79
|
+
expect(w.emitted("open-source")?.[0]?.[0]).toEqual(source);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { mount } from "@vue/test-utils";
|
|
3
|
+
import StatusBadge from "../StatusBadge.vue";
|
|
4
|
+
|
|
5
|
+
describe("StatusBadge", () => {
|
|
6
|
+
it("renders the label", () => {
|
|
7
|
+
const w = mount(StatusBadge, { props: { status: "ok", label: "Healthy" } });
|
|
8
|
+
expect(w.text()).toContain("Healthy");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("colours the dot by status", () => {
|
|
12
|
+
expect(mount(StatusBadge, { props: { status: "error" } }).html()).toContain("bg-rose-400");
|
|
13
|
+
expect(mount(StatusBadge, { props: { status: "warn" } }).html()).toContain("bg-amber-400");
|
|
14
|
+
expect(mount(StatusBadge, { props: { status: "idle" } }).html()).toContain("bg-zinc-600");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("shows the pulse ring only when pulse is set", () => {
|
|
18
|
+
const off = mount(StatusBadge, { props: { status: "live" } });
|
|
19
|
+
expect(off.find('[data-testid="status-pulse"]').exists()).toBe(false);
|
|
20
|
+
const on = mount(StatusBadge, { props: { status: "live", pulse: true } });
|
|
21
|
+
expect(on.find('[data-testid="status-pulse"]').exists()).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { mount } from "@vue/test-utils";
|
|
3
|
+
import Waterfall from "../Waterfall.vue";
|
|
4
|
+
import type { TelemetryRecord, TraceNode } from "@/lib/telemetry";
|
|
5
|
+
|
|
6
|
+
const env = (messageId: string, causationId: string) => ({
|
|
7
|
+
messageId,
|
|
8
|
+
causationId,
|
|
9
|
+
correlationId: "c",
|
|
10
|
+
});
|
|
11
|
+
function node(record: TelemetryRecord, children: TraceNode[] = []): TraceNode {
|
|
12
|
+
return { record, children };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const forest: TraceNode[] = [
|
|
16
|
+
node({ kind: "action.dispatched", name: "place", durationMs: 10, envelope: env("m1", "m1") }, [
|
|
17
|
+
node({ kind: "query.executed", name: "lookup", durationMs: 90, envelope: env("m2", "m1") }),
|
|
18
|
+
node({
|
|
19
|
+
kind: "action.failed",
|
|
20
|
+
name: "charge",
|
|
21
|
+
error: { name: "E", message: "x" },
|
|
22
|
+
envelope: env("m3", "m1"),
|
|
23
|
+
}),
|
|
24
|
+
]),
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
describe("Waterfall", () => {
|
|
28
|
+
it("renders a row per node", () => {
|
|
29
|
+
const w = mount(Waterfall, { props: { forest } });
|
|
30
|
+
expect(w.findAll('[data-testid="waterfall-row"]')).toHaveLength(3);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("marks the critical path and failed rows", () => {
|
|
34
|
+
const w = mount(Waterfall, { props: { forest } });
|
|
35
|
+
expect(w.findAll('[data-critical="true"]').length).toBeGreaterThan(0);
|
|
36
|
+
expect(w.find('[data-failed="true"]').exists()).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("emits select with the clicked row's record", async () => {
|
|
40
|
+
const w = mount(Waterfall, { props: { forest } });
|
|
41
|
+
await w.findAll('[data-testid="waterfall-row"]')[1]!.trigger("click");
|
|
42
|
+
const ev = w.emitted("select");
|
|
43
|
+
expect(ev).toBeTruthy();
|
|
44
|
+
expect((ev![0]![0] as { record: TelemetryRecord }).record.name).toBe("lookup");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("dims rows beyond the replay cursor", () => {
|
|
48
|
+
const w = mount(Waterfall, { props: { forest, reveal: 1 } });
|
|
49
|
+
const rows = w.findAll('[data-testid="waterfall-row"]');
|
|
50
|
+
expect(rows[0]!.classes()).not.toContain("opacity-30");
|
|
51
|
+
expect(rows[1]!.classes()).toContain("opacity-30");
|
|
52
|
+
expect(rows[2]!.classes()).toContain("opacity-30");
|
|
53
|
+
});
|
|
54
|
+
});
|