@shortlink-org/portolan 0.3.0 → 0.4.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.
- package/README.md +151 -6
- package/catalog/model.go +147 -0
- package/catalog/roundtrip_test.go +7 -0
- package/cli/portolan.mjs +6 -1
- package/go.mod +7 -0
- package/go.sum +20 -6
- package/package.json +10 -9
- package/plugin/describe.go +38 -0
- package/plugins/README.md +296 -5
- package/plugins/extract-argocd/options.schema.json +44 -0
- package/plugins/extract-celery/main.py +1 -0
- package/plugins/extract-csharp-ddd/README.md +213 -0
- package/plugins/extract-csharp-ddd/options.schema.json +55 -0
- package/plugins/extract-django/README.md +13 -2
- package/plugins/extract-django/choices.py +47 -0
- package/plugins/extract-django/domain.py +2 -1
- package/plugins/extract-django/extract_test.py +13 -0
- package/plugins/extract-django/lifecycle.py +2 -28
- package/plugins/extract-django/main.py +1 -0
- package/plugins/extract-django/rules.py +154 -0
- package/plugins/extract-django/rules_test.py +158 -0
- package/plugins/extract-go-sqs/options.schema.json +21 -0
- package/plugins/extract-http-clients/describe.go +5 -4
- package/plugins/extract-java/build/org/portolan/extract/Main.class +0 -0
- package/plugins/extract-k8s/options.schema.json +32 -0
- package/plugins/extract-laravel/Cargo.lock +962 -0
- package/plugins/extract-laravel/Cargo.toml +20 -0
- package/plugins/extract-laravel/README.md +200 -0
- package/plugins/extract-laravel/options.schema.json +68 -0
- package/plugins/extract-laravel/rustfmt.toml +1 -0
- package/plugins/extract-php-ddd/Cargo.lock +972 -0
- package/plugins/extract-php-ddd/Cargo.toml +22 -0
- package/plugins/extract-php-ddd/README.md +141 -0
- package/plugins/extract-php-ddd/options.schema.json +50 -0
- package/plugins/extract-php-ddd/rustfmt.toml +1 -0
- package/plugins/extract-python-kafka/main.py +1 -0
- package/plugins/extract-terraform/options.schema.json +26 -0
- package/plugins/extract-ts/main.ts +1 -0
- package/plugins/phpscan/Cargo.lock +953 -0
- package/plugins/phpscan/Cargo.toml +19 -0
- package/plugins/phpscan/rustfmt.toml +1 -0
- package/plugins/portolan-go.wasm +0 -0
- package/plugins/pyplugin/catalog.py +24 -1
- package/plugins/verify-otel/options.schema.json +12 -0
- package/portolan.json +130 -3
- package/rules/builtin.json +218 -0
- package/schema/portolan.schema.json +880 -13
- package/scripts/README.md +3 -1
- package/scripts/delivery-presets.mjs +36 -3
- package/scripts/gen-likec4.mjs +149 -16
- package/scripts/gen-likec4.test.mjs +96 -0
- package/scripts/gen.mjs +30 -3
- package/scripts/gitops-example.test.mjs +108 -0
- package/scripts/host-plugins/fetch-argocd.mjs +338 -0
- package/scripts/host-plugins/fetch-argocd.options.json +49 -0
- package/scripts/host-plugins/fetch-argocd.test.mjs +274 -0
- package/scripts/host-plugins/fetch-bsr.mjs +1 -0
- package/scripts/host-plugins/fetch-csr.mjs +1 -0
- package/scripts/host-plugins/fetch-git.mjs +1 -0
- package/scripts/host-plugins/fetch-k8s.mjs +263 -0
- package/scripts/host-plugins/fetch-k8s.options.json +50 -0
- package/scripts/host-plugins/fetch-k8s.test.mjs +259 -0
- package/scripts/host-plugins/k8s-topology.mjs +183 -0
- package/scripts/json-format.mjs +192 -0
- package/scripts/json-format.test.mjs +97 -0
- package/scripts/local-api.mjs +216 -8
- package/scripts/local-api.test.mjs +88 -2
- package/scripts/local-discovery.mjs +12 -0
- package/scripts/manifest.mjs +10 -1
- package/scripts/plugin-host.mjs +23 -1
- package/scripts/plugins-fresh.mjs +97 -0
- package/scripts/plugins-fresh.test.mjs +64 -0
- package/scripts/run-builtin.mjs +26 -5
- package/scripts/schema.mjs +138 -19
- package/scripts/trace-trials.mjs +176 -0
- package/scripts/trace-trials.test.mjs +142 -0
- package/scripts/warning-policy.mjs +12 -6
- package/scripts/warning-policy.test.mjs +40 -3
- package/src/app/Breadcrumbs.test.ts +3 -0
- package/src/app/Breadcrumbs.tsx +3 -0
- package/src/app/CatalogApp.tsx +2 -0
- package/src/app/SidebarFooter.tsx +20 -4
- package/src/catalog-index.ts +25 -0
- package/src/catalog-model.ts +246 -1
- package/src/catalog-validation.ts +104 -2
- package/src/catalog.test.ts +40 -1
- package/src/components/ChannelRows.test.tsx +76 -1
- package/src/components/ChannelRows.tsx +223 -60
- package/src/components/DeploymentRows.tsx +131 -0
- package/src/components/DocsLinks.test.tsx +27 -0
- package/src/components/DocsLinks.tsx +56 -0
- package/src/components/FieldTree.tsx +5 -2
- package/src/components/HTTPDestinationEvidence.tsx +1 -1
- package/src/components/PluginIcon.tsx +77 -0
- package/src/components/ProblemRow.tsx +173 -153
- package/src/components/RuleMarks.tsx +22 -0
- package/src/components/ShapeRows.tsx +24 -20
- package/src/data.ts +21 -0
- package/src/enrich.test.ts +127 -3
- package/src/enrich.ts +104 -6
- package/src/er/ErCanvas.tsx +218 -12
- package/src/er/GroupNode.tsx +57 -0
- package/src/er/StoreHeader.tsx +1 -0
- package/src/er/layout.test.ts +85 -2
- package/src/er/layout.ts +140 -5
- package/src/er/spec.test.ts +17 -0
- package/src/er/spec.ts +23 -10
- package/src/flow/Recordings.test.tsx +52 -0
- package/src/flow/Recordings.tsx +236 -0
- package/src/flow/StepDetail.tsx +53 -0
- package/src/flow/TraceTrial.tsx +419 -0
- package/src/flow/examples.test.ts +33 -0
- package/src/flow/examples.ts +37 -0
- package/src/flow/outline.test.ts +28 -0
- package/src/flow/outline.ts +7 -2
- package/src/flow/trace-trial-resume.test.ts +42 -0
- package/src/flow/trace-trial-resume.ts +74 -0
- package/src/graph/elk.ts +78 -0
- package/src/index.css +24 -0
- package/src/landing/LandingPage.tsx +15 -11
- package/src/landing/ProductTour.tsx +6 -6
- package/src/lib/all-problems.ts +27 -17
- package/src/lib/centrality.test.ts +251 -0
- package/src/lib/centrality.ts +232 -0
- package/src/lib/confluence.test.ts +37 -0
- package/src/lib/confluence.ts +41 -0
- package/src/lib/context-color.ts +1 -1
- package/src/lib/deployment-drift.ts +22 -0
- package/src/lib/derive.ts +43 -69
- package/src/lib/environments.test.ts +83 -0
- package/src/lib/environments.ts +57 -0
- package/src/lib/integration-url.test.ts +30 -0
- package/src/lib/integration-url.ts +63 -0
- package/src/lib/kafka-ui.ts +3 -45
- package/src/lib/local-api.ts +96 -2
- package/src/lib/notion.ts +13 -0
- package/src/lib/plugin-index.json +2765 -0
- package/src/lib/plugins.test.ts +68 -0
- package/src/lib/plugins.ts +264 -0
- package/src/lib/problem-flows.test.ts +61 -0
- package/src/lib/problem-flows.ts +78 -0
- package/src/lib/problem-rules-cel.d.mts +43 -0
- package/src/lib/problem-rules-cel.mjs +407 -0
- package/src/lib/problem-rules.test.ts +287 -0
- package/src/lib/problem-rules.ts +271 -0
- package/src/lib/problem-subjects.ts +737 -0
- package/src/lib/rule-entries.ts +39 -0
- package/src/lib/{data-problems.test.ts → rules-data.test.ts} +18 -16
- package/src/lib/rules-deploy.test.ts +133 -0
- package/src/lib/{problems.test.ts → rules-edges.test.ts} +8 -3
- package/src/lib/{proto-problems.test.ts → rules-proto.test.ts} +9 -25
- package/src/lib/{wire-problems.test.ts → rules-wire.test.ts} +13 -11
- package/src/lib/rules.test.ts +51 -0
- package/src/lib/rules.ts +86 -0
- package/src/lib/shape.test.ts +32 -0
- package/src/lib/shape.ts +30 -6
- package/src/lib/tech.ts +16 -0
- package/src/lib/trace-project.test.ts +34 -0
- package/src/lib/trace-project.ts +50 -0
- package/src/lib/use-problems.ts +23 -0
- package/src/lib/warnings.test.ts +9 -0
- package/src/lib/warnings.ts +10 -10
- package/src/likec4/ids.test.ts +6 -2
- package/src/likec4/ids.ts +43 -0
- package/src/main.tsx +23 -0
- package/src/merge-deployments.test.ts +127 -0
- package/src/merge.test.ts +82 -0
- package/src/merge.ts +173 -9
- package/src/pages/AggregatePage.tsx +57 -7
- package/src/pages/ContextMap.tsx +45 -3
- package/src/pages/ContextPage.tsx +2 -0
- package/src/pages/EventPage.tsx +15 -5
- package/src/pages/FlowDetail.tsx +23 -2
- package/src/pages/GraphPage.tsx +40 -3
- package/src/pages/Overview.tsx +152 -12
- package/src/pages/PluginIndex.tsx +190 -0
- package/src/pages/Problems.tsx +396 -128
- package/src/pages/ServicePage.tsx +58 -2
- package/src/pages/Settings.tsx +28 -2
- package/src/pages/settings/AboutSettings.tsx +8 -1
- package/src/pages/settings/IntegrationsSettings.tsx +63 -17
- package/src/pages/settings/RecordingSettings.tsx +138 -0
- package/src/pages/settings/RulesSettings.tsx +825 -0
- package/src/routes.test.ts +9 -0
- package/src/routes.ts +22 -1
- package/src/lib/data-problems.ts +0 -314
- package/src/lib/proto-problems.ts +0 -237
- package/src/lib/wire-problems.ts +0 -342
package/src/er/layout.test.ts
CHANGED
|
@@ -8,7 +8,8 @@ import { describe, expect, it } from "vitest";
|
|
|
8
8
|
import { buildIndex, validateCatalog } from "../catalog";
|
|
9
9
|
import { pathologicalCatalog, wideCatalog } from "../lib/scenarios";
|
|
10
10
|
import { erSpec } from "./spec";
|
|
11
|
-
import {
|
|
11
|
+
import type { ErNode, ErSpec } from "./spec";
|
|
12
|
+
import { canGroup, groupsOf, hideGroups, layoutEr, layoutInput } from "./layout";
|
|
12
13
|
|
|
13
14
|
describe("layoutInput", () => {
|
|
14
15
|
const bad = pathologicalCatalog();
|
|
@@ -82,7 +83,89 @@ describe("layoutEr", () => {
|
|
|
82
83
|
|
|
83
84
|
it("answers for an empty store without asking elk anything", async () => {
|
|
84
85
|
const result = await layoutEr({ nodes: [], edges: [] });
|
|
85
|
-
expect(result).toEqual({ positions: {}, width: 0, height: 0 });
|
|
86
|
+
expect(result).toEqual({ positions: {}, width: 0, height: 0, groups: [] });
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("grouping a big schema", () => {
|
|
91
|
+
/** A card with only what grouping and packing read off it. */
|
|
92
|
+
const card = (id: string, aggregate: string | null): ErNode =>
|
|
93
|
+
({ id, aggregate, width: 208, height: 120, name: id.split(".").at(-1) }) as unknown as ErNode;
|
|
94
|
+
/** `count` tables over `aggregates` groups, plus `loose` that persist nothing. */
|
|
95
|
+
const schema = (count: number, aggregates: string[], loose = 0): ErSpec => {
|
|
96
|
+
const nodes: ErNode[] = [];
|
|
97
|
+
for (let i = 0; i < count; i += 1) {
|
|
98
|
+
nodes.push(card(`s.t${i}`, aggregates[i % aggregates.length] ?? null));
|
|
99
|
+
}
|
|
100
|
+
for (let i = 0; i < loose; i += 1) nodes.push(card(`s.l${i}`, null));
|
|
101
|
+
return { nodes, edges: [] };
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
it("groups the cards by the model they persist, biggest first, the loose ones last", () => {
|
|
105
|
+
const spec = schema(7, ["a.big", "a.small", "a.big"], 2);
|
|
106
|
+
const groups = groupsOf(spec.nodes, (a) => a.split(".").at(-1)?.toUpperCase() ?? a);
|
|
107
|
+
expect(groups.map((g) => `${g.name}:${g.nodes.length}`)).toEqual(["BIG:5", "SMALL:2", "other:2"]);
|
|
108
|
+
expect(groups[2]?.aggregate).toBeNull();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("offers grouping only past the size where one flow becomes a column", () => {
|
|
112
|
+
expect(canGroup(schema(39, ["a", "b", "c"]))).toBe(false);
|
|
113
|
+
expect(canGroup(schema(60, ["a", "b"]))).toBe(false);
|
|
114
|
+
expect(canGroup(schema(60, ["a", "b", "c"]))).toBe(true);
|
|
115
|
+
// Loose tables count as size but not as a group.
|
|
116
|
+
expect(canGroup(schema(10, ["a", "b", "c"], 40))).toBe(true);
|
|
117
|
+
expect(canGroup(schema(10, ["a", "b"], 40))).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("packs the groups and keeps every card inside its own frame", async () => {
|
|
121
|
+
const spec = schema(48, ["a.x", "a.y", "a.z"], 6);
|
|
122
|
+
// A key inside a group and one across, which the packing ignores.
|
|
123
|
+
spec.edges.push(
|
|
124
|
+
{ id: "in", kind: "fk", from: "s.t3", to: "s.t0", fromColumn: "x_id", toColumn: "id", onDelete: null } as unknown as ErSpec["edges"][number],
|
|
125
|
+
{ id: "across", kind: "fk", from: "s.t1", to: "s.t0", fromColumn: "x_id", toColumn: "id", onDelete: null } as unknown as ErSpec["edges"][number],
|
|
126
|
+
);
|
|
127
|
+
const laid = await layoutEr(spec, { grouped: true, aspectRatio: 2 });
|
|
128
|
+
expect(laid.groups.map((g) => g.id)).toEqual(["a.x", "a.y", "a.z", "other"]);
|
|
129
|
+
for (const node of spec.nodes) {
|
|
130
|
+
const at = laid.positions[node.id];
|
|
131
|
+
expect(at).toBeDefined();
|
|
132
|
+
const frame = laid.groups.find((g) => g.id === (node.aggregate ?? "other"));
|
|
133
|
+
expect(frame).toBeDefined();
|
|
134
|
+
if (!at || !frame) continue;
|
|
135
|
+
expect(at.x).toBeGreaterThanOrEqual(frame.x);
|
|
136
|
+
expect(at.y).toBeGreaterThanOrEqual(frame.y);
|
|
137
|
+
expect(at.x + node.width).toBeLessThanOrEqual(frame.x + frame.width + 0.5);
|
|
138
|
+
expect(at.y + node.height).toBeLessThanOrEqual(frame.y + frame.height + 0.5);
|
|
139
|
+
}
|
|
140
|
+
// Frames do not overlap.
|
|
141
|
+
for (const a of laid.groups) {
|
|
142
|
+
for (const b of laid.groups) {
|
|
143
|
+
if (a.id === b.id) continue;
|
|
144
|
+
const apart = a.x + a.width <= b.x || b.x + b.width <= a.x || a.y + a.height <= b.y || b.y + b.height <= a.y;
|
|
145
|
+
expect(apart).toBe(true);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// Packed near the box's shape rather than as a column.
|
|
149
|
+
expect(laid.width / laid.height).toBeGreaterThan(1);
|
|
150
|
+
// The key inside a group put its parent left of its child.
|
|
151
|
+
expect(laid.positions["s.t0"]?.x ?? 0).toBeLessThan(laid.positions["s.t3"]?.x ?? 0);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("hides a group with every edge that touched it, and leaves the rest as it was", () => {
|
|
155
|
+
const spec = schema(6, ["a.x", "a.y"], 2);
|
|
156
|
+
spec.edges.push(
|
|
157
|
+
{ id: "in-x", kind: "fk", from: "s.t2", to: "s.t0", fromColumn: "x_id", toColumn: "id", onDelete: null } as unknown as ErSpec["edges"][number],
|
|
158
|
+
{ id: "x-y", kind: "fk", from: "s.t1", to: "s.t0", fromColumn: "x_id", toColumn: "id", onDelete: null } as unknown as ErSpec["edges"][number],
|
|
159
|
+
);
|
|
160
|
+
expect(hideGroups(spec, new Set())).toBe(spec);
|
|
161
|
+
const without = hideGroups(spec, new Set(["a.y", "other"]));
|
|
162
|
+
expect(without.nodes.map((n) => n.id)).toEqual(["s.t0", "s.t2", "s.t4"]);
|
|
163
|
+
expect(without.edges.map((e) => e.id)).toEqual(["in-x"]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("stays one flow unless asked", async () => {
|
|
167
|
+
const laid = await layoutEr(schema(45, ["a", "b", "c"]));
|
|
168
|
+
expect(laid.groups).toEqual([]);
|
|
86
169
|
});
|
|
87
170
|
});
|
|
88
171
|
|
package/src/er/layout.ts
CHANGED
|
@@ -11,14 +11,38 @@
|
|
|
11
11
|
// reads: source on the left, the view computed from it on the right. Reversing
|
|
12
12
|
// it too would put every view before the tables it reads, which is the one
|
|
13
13
|
// arrangement that makes a lineage picture unreadable.
|
|
14
|
+
//
|
|
15
|
+
// A big schema is laid out in two steps rather than one. A single layered
|
|
16
|
+
// pass over a hundred-odd tables comes out as a column: the graph is shallow
|
|
17
|
+
// and wide, one layer holds fifty cards, and no direction fixes that -
|
|
18
|
+
// measured on a 138-table schema, left-to-right, top-down and every wrapping
|
|
19
|
+
// option elk has all fit the canvas at a tenth of scale. What the tables do
|
|
20
|
+
// have is the model group each one persists, so the cards of one group are
|
|
21
|
+
// laid out among themselves and the groups are packed as rectangles to the
|
|
22
|
+
// canvas's own proportions, which fits the same schema at nearly three times
|
|
23
|
+
// the scale and reads by module rather than as one sheet.
|
|
24
|
+
|
|
25
|
+
import { layoutGroupsWithElk, layoutWithElk } from "../graph/elk";
|
|
26
|
+
import type { ErNode, ErSpec } from "./spec";
|
|
14
27
|
|
|
15
|
-
|
|
16
|
-
|
|
28
|
+
export interface ErGroupFrame {
|
|
29
|
+
id: string;
|
|
30
|
+
name: string;
|
|
31
|
+
/** The aggregate the group is, or null for the tables that persist none. */
|
|
32
|
+
aggregate: string | null;
|
|
33
|
+
count: number;
|
|
34
|
+
x: number;
|
|
35
|
+
y: number;
|
|
36
|
+
width: number;
|
|
37
|
+
height: number;
|
|
38
|
+
}
|
|
17
39
|
|
|
18
40
|
export interface ErLayout {
|
|
19
41
|
positions: Record<string, { x: number; y: number }>;
|
|
20
42
|
width: number;
|
|
21
43
|
height: number;
|
|
44
|
+
/** Empty when the layout is one flow. */
|
|
45
|
+
groups: ErGroupFrame[];
|
|
22
46
|
}
|
|
23
47
|
|
|
24
48
|
export interface ErLayoutInput {
|
|
@@ -57,11 +81,122 @@ export function layoutInput(spec: ErSpec): ErLayoutInput {
|
|
|
57
81
|
};
|
|
58
82
|
}
|
|
59
83
|
|
|
84
|
+
/** Fewer tables than this is a schema one layered pass draws fine. */
|
|
85
|
+
export const GROUP_MIN_TABLES = 40;
|
|
86
|
+
/** Fewer model groups than this and grouping would draw one big frame. */
|
|
87
|
+
export const GROUP_MIN_GROUPS = 3;
|
|
88
|
+
|
|
89
|
+
/** What every table that persists no model is grouped under. */
|
|
90
|
+
export const UNGROUPED = "other";
|
|
91
|
+
|
|
92
|
+
export interface ErGrouping {
|
|
93
|
+
id: string;
|
|
94
|
+
name: string;
|
|
95
|
+
aggregate: string | null;
|
|
96
|
+
nodes: string[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The cards by the model group they persist, biggest group first and the
|
|
101
|
+
* ungrouped rest last - which is the order the packing takes them in, and
|
|
102
|
+
* big-first is what packs tightest.
|
|
103
|
+
*/
|
|
104
|
+
export function groupsOf(
|
|
105
|
+
nodes: readonly Pick<ErNode, "id" | "aggregate">[],
|
|
106
|
+
nameOf: (aggregate: string) => string,
|
|
107
|
+
): ErGrouping[] {
|
|
108
|
+
const byAggregate = new Map<string | null, string[]>();
|
|
109
|
+
for (const node of nodes) {
|
|
110
|
+
const key = node.aggregate;
|
|
111
|
+
byAggregate.set(key, [...(byAggregate.get(key) ?? []), node.id]);
|
|
112
|
+
}
|
|
113
|
+
const groups: ErGrouping[] = [];
|
|
114
|
+
for (const [aggregate, ids] of byAggregate) {
|
|
115
|
+
groups.push({
|
|
116
|
+
id: aggregate ?? UNGROUPED,
|
|
117
|
+
name: aggregate ? nameOf(aggregate) : UNGROUPED,
|
|
118
|
+
aggregate,
|
|
119
|
+
nodes: ids,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
groups.sort((a, b) => {
|
|
123
|
+
if (a.aggregate === null) return 1;
|
|
124
|
+
if (b.aggregate === null) return -1;
|
|
125
|
+
return b.nodes.length - a.nodes.length || a.id.localeCompare(b.id);
|
|
126
|
+
});
|
|
127
|
+
return groups;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The group a card belongs to: its aggregate, or the ungrouped rest. */
|
|
131
|
+
export function groupKey(node: Pick<ErNode, "aggregate">): string {
|
|
132
|
+
return node.aggregate ?? UNGROUPED;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The spec without the groups a reader hid: their cards gone, and with them
|
|
137
|
+
* every edge that touched one. What is left lays out and searches like a
|
|
138
|
+
* smaller schema, which is the point of hiding.
|
|
139
|
+
*/
|
|
140
|
+
export function hideGroups(spec: ErSpec, hidden: ReadonlySet<string>): ErSpec {
|
|
141
|
+
if (hidden.size === 0) return spec;
|
|
142
|
+
const nodes = spec.nodes.filter((n) => !hidden.has(groupKey(n)));
|
|
143
|
+
const kept = new Set(nodes.map((n) => n.id));
|
|
144
|
+
return {
|
|
145
|
+
nodes,
|
|
146
|
+
edges: spec.edges.filter((e) => kept.has(e.from) && kept.has(e.to)),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Whether a schema is big enough, and grouped enough, for the two-step layout to help. */
|
|
151
|
+
export function canGroup(spec: ErSpec): boolean {
|
|
152
|
+
if (spec.nodes.length < GROUP_MIN_TABLES) return false;
|
|
153
|
+
const aggregates = new Set(spec.nodes.map((n) => n.aggregate).filter((a): a is string => a !== null));
|
|
154
|
+
return aggregates.size >= GROUP_MIN_GROUPS;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface ErLayoutOptions {
|
|
158
|
+
/** Lay the cards out by model group and pack the groups. */
|
|
159
|
+
grouped?: boolean;
|
|
160
|
+
/** Width over height of the box the picture is for; the packing aims at it. */
|
|
161
|
+
aspectRatio?: number;
|
|
162
|
+
/** What a group is called on its frame, by the aggregate it is. */
|
|
163
|
+
nameOf?: (aggregate: string) => string;
|
|
164
|
+
}
|
|
165
|
+
|
|
60
166
|
/** Lays a spec out left to right, roots first. Falls back to a column on failure. */
|
|
61
|
-
export async function layoutEr(spec: ErSpec): Promise<ErLayout> {
|
|
167
|
+
export async function layoutEr(spec: ErSpec, options: ErLayoutOptions = {}): Promise<ErLayout> {
|
|
62
168
|
const input = layoutInput(spec);
|
|
63
169
|
if (input.nodes.length === 0) {
|
|
64
|
-
return { positions: {}, width: 0, height: 0 };
|
|
170
|
+
return { positions: {}, width: 0, height: 0, groups: [] };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (options.grouped) {
|
|
174
|
+
const nameOf = options.nameOf ?? ((aggregate: string) => aggregate.split(".").at(-1) ?? aggregate);
|
|
175
|
+
const groups = groupsOf(spec.nodes, nameOf);
|
|
176
|
+
const groupOf = new Map<string, string>();
|
|
177
|
+
for (const group of groups) {
|
|
178
|
+
for (const id of group.nodes) groupOf.set(id, group.id);
|
|
179
|
+
}
|
|
180
|
+
const sizes = new Map(input.nodes.map((n) => [n.id, n]));
|
|
181
|
+
const result = await layoutGroupsWithElk({
|
|
182
|
+
groups: groups.map((group) => ({
|
|
183
|
+
id: group.id,
|
|
184
|
+
nodes: group.nodes.map((id) => sizes.get(id)).filter((n): n is NonNullable<typeof n> => n !== undefined),
|
|
185
|
+
edges: input.edges.filter((e) => groupOf.get(e.source) === group.id && groupOf.get(e.target) === group.id),
|
|
186
|
+
})),
|
|
187
|
+
aspectRatio: options.aspectRatio ?? 2,
|
|
188
|
+
layerSpacing: 120,
|
|
189
|
+
nodeSpacing: 24,
|
|
190
|
+
});
|
|
191
|
+
return {
|
|
192
|
+
positions: result.positions,
|
|
193
|
+
width: result.width,
|
|
194
|
+
height: result.height,
|
|
195
|
+
groups: groups.map((group) => {
|
|
196
|
+
const frame = result.frames[group.id] ?? { x: 0, y: 0, width: 0, height: 0 };
|
|
197
|
+
return { id: group.id, name: group.name, aggregate: group.aggregate, count: group.nodes.length, ...frame };
|
|
198
|
+
}),
|
|
199
|
+
};
|
|
65
200
|
}
|
|
66
201
|
|
|
67
202
|
const { positions, width, height } = await layoutWithElk({
|
|
@@ -73,5 +208,5 @@ export async function layoutEr(spec: ErSpec): Promise<ErLayout> {
|
|
|
73
208
|
layerSpacing: 170,
|
|
74
209
|
nodeSpacing: 28,
|
|
75
210
|
});
|
|
76
|
-
return { positions, width, height };
|
|
211
|
+
return { positions, width, height, groups: [] };
|
|
77
212
|
}
|
package/src/er/spec.test.ts
CHANGED
|
@@ -294,6 +294,23 @@ describe("matchingNodes", () => {
|
|
|
294
294
|
it("matches nothing on an empty term", () => {
|
|
295
295
|
expect(matchingNodes(spec, " ").size).toBe(0);
|
|
296
296
|
});
|
|
297
|
+
|
|
298
|
+
it("puts the best hit first: the table named so, then names, then columns and SQL", () => {
|
|
299
|
+
// `orders` is the table's own name; the two views contain it in theirs
|
|
300
|
+
// and come after, in the order they are drawn.
|
|
301
|
+
expect([...matchingNodes(spec, "orders")]).toEqual([
|
|
302
|
+
"shop.oms.pg.orders",
|
|
303
|
+
"shop.oms.pg.v_open_orders",
|
|
304
|
+
"shop.oms.pg.mv_orders_daily",
|
|
305
|
+
]);
|
|
306
|
+
// A hit by name outranks a hit by column: `order_items` is named for
|
|
307
|
+
// orders, `outbox` only keys on one.
|
|
308
|
+
const byOrder = [...matchingNodes(spec, "order")];
|
|
309
|
+
expect(byOrder.indexOf("shop.oms.pg.order_items")).toBeLessThan(
|
|
310
|
+
byOrder.indexOf("shop.oms.pg.outbox") < 0 ? Infinity : byOrder.indexOf("shop.oms.pg.outbox"),
|
|
311
|
+
);
|
|
312
|
+
expect(byOrder[0]).toBe("shop.oms.pg.orders");
|
|
313
|
+
});
|
|
297
314
|
});
|
|
298
315
|
|
|
299
316
|
describe("the pathological store", () => {
|
package/src/er/spec.ts
CHANGED
|
@@ -407,20 +407,33 @@ export function outboundLineage(
|
|
|
407
407
|
/**
|
|
408
408
|
* Cards matching a search term, by name, column name, or the aggregate they
|
|
409
409
|
* hold. Returns ids so the canvas can light them without re-deriving anything.
|
|
410
|
+
*
|
|
411
|
+
* The set is ordered, best match first, because the canvas jumps to the first
|
|
412
|
+
* one and cycles from there: a card whose name IS the term, then cards whose
|
|
413
|
+
* name contains it, then the aggregate, then a column, the doc or the SQL.
|
|
414
|
+
* Within a rank, the order the spec lists them in - which is the order they
|
|
415
|
+
* are drawn in.
|
|
410
416
|
*/
|
|
411
417
|
export function matchingNodes(spec: ErSpec, term: string): Set<string> {
|
|
412
418
|
const needle = term.trim().toLowerCase();
|
|
413
419
|
if (!needle) return new Set();
|
|
414
|
-
const
|
|
420
|
+
const ranked: { id: string; rank: number }[] = [];
|
|
415
421
|
for (const node of spec.nodes) {
|
|
416
|
-
const
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
422
|
+
const name = node.name.toLowerCase();
|
|
423
|
+
const rank =
|
|
424
|
+
name === needle
|
|
425
|
+
? 0
|
|
426
|
+
: name.includes(needle)
|
|
427
|
+
? 1
|
|
428
|
+
: (node.aggregate ?? "").toLowerCase().includes(needle)
|
|
429
|
+
? 2
|
|
430
|
+
: [node.doc ?? "", node.view?.definition ?? "", ...node.columns.map((c) => c.name)].some(
|
|
431
|
+
(h) => h.toLowerCase().includes(needle),
|
|
432
|
+
)
|
|
433
|
+
? 3
|
|
434
|
+
: -1;
|
|
435
|
+
if (rank >= 0) ranked.push({ id: node.id, rank });
|
|
424
436
|
}
|
|
425
|
-
|
|
437
|
+
ranked.sort((a, b) => a.rank - b.rank);
|
|
438
|
+
return new Set(ranked.map((r) => r.id));
|
|
426
439
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import { MemoryRouter } from "react-router";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { rawCatalog as raw } from "../test-catalog";
|
|
7
|
+
import type { Catalog, Flow } from "../catalog";
|
|
8
|
+
import { RecordingsChip } from "./Recordings";
|
|
9
|
+
|
|
10
|
+
const catalog = raw as unknown as Catalog;
|
|
11
|
+
const checkout = catalog.flows.find((f) => f.slug === "checkout") as Flow;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Rendered once, statically: no local server answers here, so the chip is
|
|
15
|
+
* what a published site shows - the recordings the catalog carries, or
|
|
16
|
+
* nothing.
|
|
17
|
+
*/
|
|
18
|
+
function render(flow: Flow, exampleId: string | null = null) {
|
|
19
|
+
const client = new QueryClient({ defaultOptions: { queries: { retry: false, enabled: false } } });
|
|
20
|
+
return renderToStaticMarkup(
|
|
21
|
+
<QueryClientProvider client={client}>
|
|
22
|
+
<MemoryRouter>
|
|
23
|
+
<RecordingsChip flow={flow} exampleId={exampleId} onExample={() => {}} />
|
|
24
|
+
</MemoryRouter>
|
|
25
|
+
</QueryClientProvider>,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const example = (traceId: string) => ({
|
|
30
|
+
id: `examples/shop/telemetry/traces.jsonl#${traceId}`,
|
|
31
|
+
recording: "examples/shop/telemetry/traces.jsonl",
|
|
32
|
+
traceId,
|
|
33
|
+
durationMs: 12.5,
|
|
34
|
+
steps: [{ step: "s1", durationMs: 12.5 }],
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("the recordings chip", () => {
|
|
38
|
+
it("counts the flow's recordings and says which one is lit", () => {
|
|
39
|
+
const flow = { ...checkout, examples: [example("aaaaaaaa1"), example("bbbbbbbb2")] };
|
|
40
|
+
const markup = render(flow);
|
|
41
|
+
expect(markup).toContain("recordings · 2");
|
|
42
|
+
expect(markup).toContain("Recordings — 2 recordings");
|
|
43
|
+
|
|
44
|
+
const lit = render(flow, flow.examples[1]!.id);
|
|
45
|
+
expect(lit).toContain("lighting #bbbbbbbb");
|
|
46
|
+
expect(lit).toContain("border-accent");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("is not on the page at all when there is nothing to show and nowhere to add", () => {
|
|
50
|
+
expect(render({ ...checkout, examples: undefined })).toBe("");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// The recordings a flow has been seen running in, and the way to add one.
|
|
2
|
+
//
|
|
3
|
+
// A recording is an example of the flow: which steps ran, how long each took,
|
|
4
|
+
// what the spans were called. It is evidence, so it sits in the evidence row
|
|
5
|
+
// as a chip - "recordings · 2" - and opens behind it, the way the catalog
|
|
6
|
+
// stamp does: the rail is for the steps, and a list that is read once and
|
|
7
|
+
// then left alone should not take a row of it for good. Choosing a recording
|
|
8
|
+
// lights the steps it showed on the picture; the step's own panel says what
|
|
9
|
+
// the span carried. In local mode the same panel takes a new recording, runs
|
|
10
|
+
// the generator over a copy with it in place, and shows what changed before
|
|
11
|
+
// anything is written.
|
|
12
|
+
|
|
13
|
+
import { useRef, useState } from "react";
|
|
14
|
+
import { useQuery } from "@tanstack/react-query";
|
|
15
|
+
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
|
|
16
|
+
import { Disc3, LoaderCircle, Upload } from "lucide-react";
|
|
17
|
+
import { Link } from "react-router";
|
|
18
|
+
import type { Flow, FlowExample } from "../catalog";
|
|
19
|
+
import { Modal } from "../components/Overlay";
|
|
20
|
+
import { useToastStore } from "../app/toast";
|
|
21
|
+
import { plural, relativeTime } from "../lib/format";
|
|
22
|
+
import { startTraceTrial } from "../lib/local-api";
|
|
23
|
+
import { localStatusQuery } from "../lib/queries";
|
|
24
|
+
import { projectForFlow, projectsForFlow } from "../lib/trace-project";
|
|
25
|
+
import { paths } from "../routes";
|
|
26
|
+
import { formatMs, stepsShownBy } from "./examples";
|
|
27
|
+
import { TraceTrialPanel } from "./TraceTrial";
|
|
28
|
+
import { forgetTraceTrial, recallTraceTrial, rememberTraceTrial, sessionStore } from "./trace-trial-resume";
|
|
29
|
+
|
|
30
|
+
function shortRecording(recording: string): string {
|
|
31
|
+
const parts = recording.split("/");
|
|
32
|
+
return parts.length > 2 ? `…/${parts.slice(-2).join("/")}` : recording;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function RecordingsChip({
|
|
36
|
+
flow,
|
|
37
|
+
exampleId,
|
|
38
|
+
onExample,
|
|
39
|
+
}: {
|
|
40
|
+
flow: Flow;
|
|
41
|
+
/** The example lit on the picture, or null. */
|
|
42
|
+
exampleId: string | null;
|
|
43
|
+
onExample: (id: string | null) => void;
|
|
44
|
+
}) {
|
|
45
|
+
const status = useQuery(localStatusQuery());
|
|
46
|
+
const local = status.isSuccess;
|
|
47
|
+
const projects = status.data?.setup.projects ?? [];
|
|
48
|
+
/**
|
|
49
|
+
* A run the page did not start, or lost: keeping a recording writes
|
|
50
|
+
* portolan.json, which restarts the dev server and reloads the page
|
|
51
|
+
* under the dialog. The run goes on; the page says so.
|
|
52
|
+
*/
|
|
53
|
+
const activeRun = status.data?.activeRun ?? null;
|
|
54
|
+
const say = useToastStore((s) => s.say);
|
|
55
|
+
const input = useRef<HTMLInputElement | null>(null);
|
|
56
|
+
// Picked back up from before a reload, when this page was watching one:
|
|
57
|
+
// keeping a recording reloads the page twice, and the dialog outlives it.
|
|
58
|
+
const [resumed] = useState(() => recallTraceTrial(sessionStore(), flow.slug));
|
|
59
|
+
const [runId, setRunId] = useState<string | null>(resumed?.runId ?? null);
|
|
60
|
+
const [busy, setBusy] = useState(false);
|
|
61
|
+
const watch = (id: string) => {
|
|
62
|
+
rememberTraceTrial(sessionStore(), flow.slug, { runId: id, writeRunId: null });
|
|
63
|
+
setRunId(id);
|
|
64
|
+
};
|
|
65
|
+
const done = () => {
|
|
66
|
+
forgetTraceTrial(sessionStore());
|
|
67
|
+
setRunId(null);
|
|
68
|
+
};
|
|
69
|
+
const examples = flow.examples ?? [];
|
|
70
|
+
const project = local ? projectForFlow(projects, flow) : null;
|
|
71
|
+
const candidates = local ? projectsForFlow(projects, flow) : [];
|
|
72
|
+
const [chosenProject, setChosenProject] = useState<string>("");
|
|
73
|
+
const target = project?.id ?? chosenProject;
|
|
74
|
+
const lit = exampleId ? examples.find((e) => e.id === exampleId) : undefined;
|
|
75
|
+
|
|
76
|
+
async function upload(file: File) {
|
|
77
|
+
if (!target) return;
|
|
78
|
+
setBusy(true);
|
|
79
|
+
try {
|
|
80
|
+
const started = await startTraceTrial(file, target);
|
|
81
|
+
watch(started.runId);
|
|
82
|
+
} catch (cause) {
|
|
83
|
+
say(cause instanceof Error ? cause.message : String(cause));
|
|
84
|
+
} finally {
|
|
85
|
+
setBusy(false);
|
|
86
|
+
if (input.current) input.current.value = "";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (examples.length === 0 && !local) return null;
|
|
91
|
+
|
|
92
|
+
const summary = examples.length
|
|
93
|
+
? `${examples.length} ${plural(examples.length, "recording")}${lit ? ` · lighting #${lit.traceId.slice(0, 8)}` : ""}`
|
|
94
|
+
: "no recordings yet";
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<>
|
|
98
|
+
<Popover className="relative">
|
|
99
|
+
<PopoverButton
|
|
100
|
+
title={`Recordings this flow was seen running in — ${summary}`}
|
|
101
|
+
aria-label={`Recordings — ${summary}`}
|
|
102
|
+
className={({ open }) =>
|
|
103
|
+
`chip transition-colors focus:outline-none ${open || lit ? "border-accent text-accent" : "border-line text-muted hover:text-ink"}`
|
|
104
|
+
}
|
|
105
|
+
>
|
|
106
|
+
<Disc3 size={11} aria-hidden />
|
|
107
|
+
{examples.length ? `recordings · ${examples.length}` : "add recording"}
|
|
108
|
+
{activeRun && runId === null ? <LoaderCircle size={11} className="animate-spin text-accent" aria-hidden /> : null}
|
|
109
|
+
</PopoverButton>
|
|
110
|
+
<PopoverPanel
|
|
111
|
+
anchor={{ to: "bottom start", gap: 4, padding: 8 }}
|
|
112
|
+
className="palette-in z-50 w-[30rem] max-w-[92vw] rounded-control border bg-canvas p-2 border-line-strong shadow-md focus:outline-none"
|
|
113
|
+
>
|
|
114
|
+
<div className="flex items-center gap-2 px-1">
|
|
115
|
+
<span className="label text-faint">recordings</span>
|
|
116
|
+
<span className="mono text-muted">{examples.length}</span>
|
|
117
|
+
{activeRun && runId === null ? (
|
|
118
|
+
<Link to={paths.settings()} className="mono ml-auto flex items-center gap-1 text-muted hover:text-ink" title="A generator run is in progress; the page reloads when it has written">
|
|
119
|
+
<LoaderCircle size={12} className="animate-spin text-accent" aria-hidden /> {activeRun.mode === "write" ? "regenerating…" : "run in progress…"}
|
|
120
|
+
</Link>
|
|
121
|
+
) : local ? (
|
|
122
|
+
<span className="ml-auto flex items-center gap-1.5">
|
|
123
|
+
{candidates.length > 1 && !project ? (
|
|
124
|
+
<select
|
|
125
|
+
className="mono rounded-control border border-line bg-canvas px-1 py-0.5 text-ink"
|
|
126
|
+
value={chosenProject}
|
|
127
|
+
onChange={(e) => setChosenProject(e.target.value)}
|
|
128
|
+
aria-label="Project to keep the recording under"
|
|
129
|
+
>
|
|
130
|
+
<option value="">project…</option>
|
|
131
|
+
{candidates.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
|
132
|
+
</select>
|
|
133
|
+
) : null}
|
|
134
|
+
<button
|
|
135
|
+
type="button"
|
|
136
|
+
className="tbtn"
|
|
137
|
+
disabled={busy || !target}
|
|
138
|
+
title={target ? `Upload an OTLP JSON recording; it is kept under ${target} after you review what it shows` : candidates.length ? "Choose the project the recording belongs to" : "No project in this flow's context to keep a recording under"}
|
|
139
|
+
onClick={() => input.current?.click()}
|
|
140
|
+
>
|
|
141
|
+
<Upload size={13} aria-hidden /> add
|
|
142
|
+
</button>
|
|
143
|
+
</span>
|
|
144
|
+
) : null}
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
{examples.length ? (
|
|
148
|
+
<ul className="mt-1.5 flex max-h-72 flex-col gap-0.5 overflow-y-auto">
|
|
149
|
+
{examples.map((example) => (
|
|
150
|
+
<ExampleRow
|
|
151
|
+
key={example.id}
|
|
152
|
+
example={example}
|
|
153
|
+
on={exampleId === example.id}
|
|
154
|
+
onToggle={() => onExample(exampleId === example.id ? null : example.id)}
|
|
155
|
+
/>
|
|
156
|
+
))}
|
|
157
|
+
</ul>
|
|
158
|
+
) : local ? (
|
|
159
|
+
<p className="mt-1.5 px-1 text-muted">
|
|
160
|
+
No recording shows this flow yet. Add an OTLP JSON export of a trace that runs through it, or see <Link to={paths.settingsRecordings()} className="text-accent hover:underline">recordings</Link> in settings.
|
|
161
|
+
</p>
|
|
162
|
+
) : null}
|
|
163
|
+
{examples.length ? (
|
|
164
|
+
<p className="mt-1.5 px-1 text-faint">Choose one to light the steps it showed on the picture; a step's panel says what its span carried.</p>
|
|
165
|
+
) : null}
|
|
166
|
+
</PopoverPanel>
|
|
167
|
+
</Popover>
|
|
168
|
+
|
|
169
|
+
{/* Outside the panel, which is gone the moment it closes: the file
|
|
170
|
+
input the button clicks and the dialog the upload opens must outlive
|
|
171
|
+
the popover. */}
|
|
172
|
+
{local ? (
|
|
173
|
+
<input
|
|
174
|
+
ref={input}
|
|
175
|
+
type="file"
|
|
176
|
+
accept=".jsonl,.json,.ndjson,application/json"
|
|
177
|
+
className="hidden"
|
|
178
|
+
onChange={(e) => { const file = e.target.files?.[0]; if (file) void upload(file); }}
|
|
179
|
+
/>
|
|
180
|
+
) : null}
|
|
181
|
+
|
|
182
|
+
<Modal open={runId !== null} onClose={() => {}} label="Recording" width="min(760px,94vw)">
|
|
183
|
+
<div className="flex items-center gap-3 border-b border-line px-5 py-4">
|
|
184
|
+
<div className="flex-1">
|
|
185
|
+
<div className="font-semibold text-ink">Recording of {flow.name}</div>
|
|
186
|
+
<div className="mono mt-0.5 text-muted">read against the catalog, nothing written yet</div>
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
<div className="min-h-0 flex-1 overflow-y-auto p-5">
|
|
190
|
+
{runId ? (
|
|
191
|
+
/* Keyed by the run: a dialog closing on one run and opening on
|
|
192
|
+
the next within the exit animation must not hand the next run
|
|
193
|
+
the state of the one before. */
|
|
194
|
+
<TraceTrialPanel
|
|
195
|
+
key={runId}
|
|
196
|
+
runId={runId}
|
|
197
|
+
initialWriteRunId={resumed?.runId === runId ? resumed.writeRunId : null}
|
|
198
|
+
onWriteStarted={(writeRunId) => rememberTraceTrial(sessionStore(), flow.slug, { runId, writeRunId })}
|
|
199
|
+
onDone={done}
|
|
200
|
+
/>
|
|
201
|
+
) : null}
|
|
202
|
+
</div>
|
|
203
|
+
</Modal>
|
|
204
|
+
</>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function ExampleRow({
|
|
209
|
+
example,
|
|
210
|
+
on,
|
|
211
|
+
onToggle,
|
|
212
|
+
}: {
|
|
213
|
+
example: FlowExample;
|
|
214
|
+
on: boolean;
|
|
215
|
+
onToggle: () => void;
|
|
216
|
+
}) {
|
|
217
|
+
return (
|
|
218
|
+
<li>
|
|
219
|
+
<button
|
|
220
|
+
type="button"
|
|
221
|
+
aria-pressed={on}
|
|
222
|
+
onClick={onToggle}
|
|
223
|
+
className={`flex w-full flex-wrap items-baseline gap-x-2 rounded-control px-1.5 py-1 text-left hover:bg-surface ${on ? "bg-surface" : ""}`}
|
|
224
|
+
title={`${example.recording} · trace ${example.traceId}`}
|
|
225
|
+
>
|
|
226
|
+
<span className="mono truncate text-ink">{shortRecording(example.recording)}</span>
|
|
227
|
+
<span className="mono text-faint">#{example.traceId.slice(0, 8)}</span>
|
|
228
|
+
<span className="mono ml-auto text-muted">
|
|
229
|
+
{stepsShownBy(example).length} {plural(stepsShownBy(example).length, "step")}
|
|
230
|
+
{example.durationMs ? ` · ${formatMs(example.durationMs)}` : ""}
|
|
231
|
+
{example.recordedAt ? ` · ${relativeTime(example.recordedAt)}` : ""}
|
|
232
|
+
</span>
|
|
233
|
+
</button>
|
|
234
|
+
</li>
|
|
235
|
+
);
|
|
236
|
+
}
|