@shortlink-org/portolan 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/package.json +1 -1
  2. package/plugins/README.md +5 -0
  3. package/plugins/extract-python-kafka/README.md +6 -0
  4. package/plugins/extract-python-kafka/extract.py +2 -2
  5. package/plugins/extract-python-kafka/extract_test.py +17 -1
  6. package/plugins/portolan-go.wasm +0 -0
  7. package/plugins/pyplugin/catalog.py +12 -1
  8. package/plugins/pyplugin/kafka.py +74 -3
  9. package/scripts/gen-likec4.mjs +78 -20
  10. package/scripts/gen-likec4.test.mjs +25 -2
  11. package/src/app/Breadcrumbs.test.ts +4 -0
  12. package/src/app/Breadcrumbs.tsx +1 -0
  13. package/src/catalog-model.ts +22 -1
  14. package/src/catalog-stores.test.ts +17 -0
  15. package/src/catalog-validation.ts +43 -2
  16. package/src/catalog.test.ts +13 -2
  17. package/src/components/ChannelRows.messagepack.test.tsx +28 -0
  18. package/src/components/ChannelRows.test.tsx +54 -0
  19. package/src/components/ChannelRows.tsx +57 -10
  20. package/src/components/LifecycleDiagram.tsx +28 -12
  21. package/src/components/ProblemRow.tsx +4 -0
  22. package/src/components/WhatLinksHere.tsx +6 -4
  23. package/src/enrich.test.ts +4 -5
  24. package/src/flow/StepDetail.tsx +98 -54
  25. package/src/flow/answers.test.ts +18 -1
  26. package/src/flow/answers.ts +37 -8
  27. package/src/lib/backlinks.test.ts +16 -1
  28. package/src/lib/backlinks.ts +20 -0
  29. package/src/lib/catalog-diff.test.ts +18 -0
  30. package/src/lib/catalog-diff.ts +19 -1
  31. package/src/lib/derive.ts +1 -0
  32. package/src/lib/kafka-ui.test.ts +87 -0
  33. package/src/lib/kafka-ui.ts +105 -0
  34. package/src/lib/wire-problems.test.ts +21 -0
  35. package/src/lib/wire-problems.ts +62 -1
  36. package/src/likec4/FlowView.tsx +2 -6
  37. package/src/likec4/flow-edges.test.ts +64 -1
  38. package/src/likec4/flow-edges.ts +43 -7
  39. package/src/likec4/view-index.ts +8 -2
  40. package/src/merge.test.ts +23 -0
  41. package/src/merge.ts +17 -1
  42. package/src/pages/CatalogFailure.tsx +2 -2
  43. package/src/pages/Settings.tsx +11 -3
  44. package/src/pages/settings/IntegrationsSettings.tsx +117 -0
  45. package/src/routes.test.ts +2 -0
  46. package/src/routes.ts +2 -1
  47. package/src/selection/DetailPanel.tsx +46 -1
@@ -3,10 +3,9 @@
3
3
  // id across, so the two are paired by position.
4
4
  //
5
5
  // That is sound because it is the same walk twice: the generator emits steps in
6
- // the order walkSteps visits them a step, then each branch of a parallel,
7
- // then each branch of an alt, then the body of a loop and the view keeps that
8
- // order in its edge list. If the two lists ever differ in length the pairing is
9
- // abandoned rather than guessed at, and the diagram simply stops highlighting.
6
+ // catalog order and this module inserts the same contract-response edges in the
7
+ // same places. If the two lists ever differ in length the pairing is abandoned
8
+ // rather than guessed at, and the diagram simply stops highlighting.
10
9
 
11
10
  import type { Flow } from "../catalog";
12
11
  import { walkSteps } from "../catalog";
@@ -15,13 +14,16 @@ import { hiddenStepIds } from "../flow/cross-context";
15
14
  export interface EdgeStepPairing {
16
15
  /** LikeC4 edge id -> catalog step id */
17
16
  stepOf: Map<string, string>;
18
- /** catalog step id -> LikeC4 edge id */
17
+ /** catalog step id -> its primary LikeC4 edge id */
19
18
  edgeOf: Map<string, string>;
19
+ /** catalog step id -> every edge it draws, including a contract response */
20
+ edgesOf: Map<string, string[]>;
20
21
  }
21
22
 
22
23
  export const EMPTY_PAIRING: EdgeStepPairing = {
23
24
  stepOf: new Map(),
24
25
  edgeOf: new Map(),
26
+ edgesOf: new Map(),
25
27
  };
26
28
 
27
29
  export function pairEdgesToSteps(
@@ -31,13 +33,17 @@ export function pairEdgesToSteps(
31
33
  if (edgeIds.length !== stepIds.length) return EMPTY_PAIRING;
32
34
  const stepOf = new Map<string, string>();
33
35
  const edgeOf = new Map<string, string>();
36
+ const edgesOf = new Map<string, string[]>();
34
37
  edgeIds.forEach((edgeId, i) => {
35
38
  const stepId = stepIds[i];
36
39
  if (stepId === undefined) return;
37
40
  stepOf.set(edgeId, stepId);
38
- edgeOf.set(stepId, edgeId);
41
+ if (!edgeOf.has(stepId)) edgeOf.set(stepId, edgeId);
42
+ const edges = edgesOf.get(stepId) ?? [];
43
+ edges.push(edgeId);
44
+ edgesOf.set(stepId, edges);
39
45
  });
40
- return { stepOf, edgeOf };
46
+ return { stepOf, edgeOf, edgesOf };
41
47
  }
42
48
 
43
49
  /**
@@ -87,3 +93,33 @@ export function drawnStepIds(flow: Flow, crossOnly: boolean): string[] {
87
93
  const hidden = hiddenStepIds(flow);
88
94
  return steps.filter((s) => !hidden.has(s.id)).map((s) => s.id);
89
95
  }
96
+
97
+ /**
98
+ * One step id per generated edge, including contract responses synthesized by
99
+ * the LikeC4 generator. Nested RPCs return immediately; the actor request that
100
+ * opened the flow returns after its final step.
101
+ */
102
+ export function drawnEdgeStepIds(
103
+ flow: Flow,
104
+ crossOnly: boolean,
105
+ responseStepIds: ReadonlySet<string>,
106
+ ): string[] {
107
+ const steps = walkSteps(flow.steps);
108
+ const actorIds = new Set(
109
+ flow.participants.filter((p) => p.kind === "actor").map((p) => p.id),
110
+ );
111
+ const deferred = steps.find(
112
+ (step) =>
113
+ step.kind === "rpc" &&
114
+ actorIds.has(step.from) &&
115
+ responseStepIds.has(step.id),
116
+ )?.id;
117
+ const drawn = drawnStepIds(flow, crossOnly);
118
+ const out: string[] = [];
119
+ for (const stepId of drawn) {
120
+ out.push(stepId);
121
+ if (stepId !== deferred && responseStepIds.has(stepId)) out.push(stepId);
122
+ }
123
+ if (deferred && drawn.includes(deferred)) out.push(deferred);
124
+ return out;
125
+ }
@@ -8,7 +8,9 @@
8
8
  import { pickViewBounds } from "likec4/react";
9
9
  import { likec4model } from "./generated";
10
10
  import type { Flow } from "../catalog";
11
- import { drawnStepIds, pairEdgesToSteps } from "./flow-edges";
11
+ import { index } from "../data";
12
+ import { flowAnswers } from "../flow/answers";
13
+ import { drawnEdgeStepIds, pairEdgesToSteps } from "./flow-edges";
12
14
  import type { EdgeStepPairing } from "./flow-edges";
13
15
  import { EMPTY_PAIRING } from "./flow-edges";
14
16
  import { flowCrossViewId, flowViewId } from "./ids";
@@ -70,8 +72,12 @@ export function flowPairing(flow: Flow, crossOnly: boolean): EdgeStepPairing {
70
72
  const viewId = crossOnly ? flowCrossViewId(flow) : flowViewId(flow);
71
73
  const cached = pairings.get(viewId);
72
74
  if (cached) return cached;
75
+ const responseStepIds = new Set(flowAnswers(index, flow).keys());
73
76
  const pairing = shapeOf(viewId).edgeIds.length
74
- ? pairEdgesToSteps(shapeOf(viewId).edgeIds, drawnStepIds(flow, crossOnly))
77
+ ? pairEdgesToSteps(
78
+ shapeOf(viewId).edgeIds,
79
+ drawnEdgeStepIds(flow, crossOnly, responseStepIds),
80
+ )
75
81
  : EMPTY_PAIRING;
76
82
  pairings.set(viewId, pairing);
77
83
  return pairing;
package/src/merge.test.ts CHANGED
@@ -757,6 +757,29 @@ describe("mergeCatalogs: schema modules", () => {
757
757
  // came from.
758
758
  expect(channels?.[0]?.source).toBe("bus/asyncapi.yaml");
759
759
  });
760
+
761
+ it("keeps a message encoding and reports sources that disagree about it", () => {
762
+ const first = context("shop", ["shop.cart"]);
763
+ first.services[0]!.channels = [{
764
+ address: "shop.cart.basket",
765
+ messages: [{ name: "cart.BasketCreated", direction: "send", encoding: "msgpack", contentType: "application/msgpack" }],
766
+ }];
767
+ const second = context("shop", ["shop.cart"]);
768
+ second.services[0]!.channels = [{
769
+ address: "shop.cart.basket",
770
+ messages: [{ name: "cart.BasketCreated", direction: "send", encoding: "json", contentType: "application/json" }],
771
+ }];
772
+
773
+ const merged = mergeCatalogs([
774
+ source("a-msgpack.json", { contexts: [first] }),
775
+ source("b-json.json", { contexts: [second] }),
776
+ ]);
777
+ const message = merged.catalog.contexts[0]!.services[0]!.channels![0]!.messages[0]!;
778
+ expect(message.encoding).toBe("msgpack");
779
+ expect(message.contentType).toBe("application/msgpack");
780
+ expect(merged.conflicts.map((conflict) => conflict.message).join(" ")).toMatch(/encoding json.*msgpack/);
781
+ expect(merged.conflicts.map((conflict) => conflict.message).join(" ")).toMatch(/contentType application\/json.*application\/msgpack/);
782
+ });
760
783
  });
761
784
 
762
785
  describe("a second source that has seen the flow run", () => {
package/src/merge.ts CHANGED
@@ -442,6 +442,7 @@ function foldMaps(catalog: Catalog, stores: Store[]): Store[] {
442
442
  if (!aggregate) return table;
443
443
  return {
444
444
  ...table,
445
+ ...(table.accesses ? { accesses: table.accesses.map((access) => ({ ...access })) } : {}),
445
446
  columns: table.columns.map((column) => {
446
447
  if (!column.maps) return column;
447
448
  const maps = fold(aggregate, column.maps);
@@ -608,6 +609,21 @@ function mergeService(
608
609
  mine.messages,
609
610
  theirs.messages,
610
611
  (m) => `${m.direction} ${m.name}`,
612
+ (held, offered) => {
613
+ for (const field of ["encoding", "contentType"] as const) {
614
+ if (!held[field] && offered[field]) {
615
+ held[field] = offered[field];
616
+ } else if (held[field] && offered[field] && held[field] !== offered[field]) {
617
+ conflicts.push({
618
+ path,
619
+ where: incoming.id,
620
+ message: `message "${held.name}" on channel "${mine.address}" of service "${incoming.id}" has ${field} ${offered[field]} here and ${held[field]} in ${owner}; the first one is used`,
621
+ });
622
+ }
623
+ }
624
+
625
+ return undefined;
626
+ },
611
627
  );
612
628
 
613
629
  // Mutated in place: the channel that was here keeps its prose and its
@@ -620,7 +636,7 @@ function mergeService(
620
636
  }
621
637
 
622
638
  function copyChannel(channel: Channel): Channel {
623
- return { ...channel, messages: [...channel.messages] };
639
+ return { ...channel, messages: channel.messages.map((message) => ({ ...message })) };
624
640
  }
625
641
 
626
642
  /**
@@ -26,8 +26,8 @@ const HINTS: { test: RegExp; hint: string }[] = [
26
26
  hint: "A step names a lane the flow never declared. Add the service to the flow's `participants`, or point the step at one that is already there.",
27
27
  },
28
28
  {
29
- test: /resolves to neither an Event nor an RpcCall/,
30
- hint: "Either the ref is stale — the event was renamed or removed — or the step really does point at something outside the catalog, in which case its status belongs as `unresolved`.",
29
+ test: /resolves to neither an Event, an RpcCall nor a method/,
30
+ hint: "Either the ref is stale — the event or RPC method was renamed or removed — or the step really does point at something outside the catalog, in which case its status belongs as `unresolved`.",
31
31
  },
32
32
  {
33
33
  test: /must have id/,
@@ -56,6 +56,7 @@ import { MachineDocs } from "../components/MachineDocs";
56
56
  import { DeliverySettings } from "./settings/DeliverySettings";
57
57
  import { PreferencesSettings } from "./settings/PreferencesSettings";
58
58
  import { AboutSettings } from "./settings/AboutSettings";
59
+ import { IntegrationsSettings } from "./settings/IntegrationsSettings";
59
60
  import { CatEmptyState, CatIllustration } from "../components/CatIllustration";
60
61
  import { CommitLink } from "../components/CommitLink";
61
62
 
@@ -954,6 +955,7 @@ const SETTINGS_LINKS = [
954
955
  ["Projects", paths.settingsProjects()],
955
956
  ["Pipeline", paths.settingsPipeline()],
956
957
  ["Delivery", paths.settingsDelivery()],
958
+ ["Integrations", paths.settingsIntegrations()],
957
959
  ["Preferences", paths.settingsPreferences()],
958
960
  ["About", paths.settingsAbout()],
959
961
  ] as const;
@@ -1046,6 +1048,10 @@ function OverviewSettings({ local, onGenerate }: { local: boolean; onGenerate: (
1046
1048
  <div className="font-semibold text-ink">Delivery</div>
1047
1049
  <p className="mt-1 text-muted">Install review checks and static catalog publishing for GitHub or GitLab.</p>
1048
1050
  </Link>
1051
+ <Link to={paths.settingsIntegrations()} className="card">
1052
+ <div className="font-semibold text-ink">Integrations</div>
1053
+ <p className="mt-1 text-muted">Connect operational tools such as Kafka UI to the catalog.</p>
1054
+ </Link>
1049
1055
  <Link to={paths.settingsPreferences()} className="card">
1050
1056
  <div className="font-semibold text-ink">Preferences</div>
1051
1057
  <p className="mt-1 text-muted">Theme, row density, source editor and Ask the catalog.</p>
@@ -1112,15 +1118,16 @@ function SettingsContent({ local, onAdd, onRemove, onGenerate }: { local: boolea
1112
1118
  const pathname = useLocation().pathname.replace(/\/$/, "");
1113
1119
  const overview = pathname === paths.settings();
1114
1120
  const about = pathname === paths.settingsAbout();
1121
+ const browserIntegration = pathname === paths.settingsIntegrations();
1115
1122
  return (
1116
1123
  <div className="h-full overflow-y-auto p-gutter">
1117
1124
  <div className="max-w-table">
1118
1125
  <div className="flex flex-wrap items-start justify-between gap-3">
1119
1126
  <div>
1120
1127
  <div className="flex items-center gap-2"><h1 className="text-lg font-semibold">Settings</h1>{local ? <span className="chip status-verified">local mode</span> : null}</div>
1121
- <p className="mt-1 max-w-prose text-muted">Configure projects, extraction, delivery automation and local preferences. {local ? "This local session can write reviewed changes." : "Build configuration is read-only here."}</p>
1128
+ <p className="mt-1 max-w-prose text-muted">Configure projects, extraction, delivery automation, integrations and local preferences. {local ? "This local session can write reviewed changes." : "Build configuration is read-only here."}</p>
1122
1129
  </div>
1123
- {local && !overview && !about ? <button type="button" className="product-primary" onClick={onGenerate}><Play size={15} /> Preview generated diff</button> : null}
1130
+ {local && !overview && !about && !browserIntegration ? <button type="button" className="product-primary" onClick={onGenerate}><Play size={15} /> Preview generated diff</button> : null}
1124
1131
  </div>
1125
1132
  <SettingsNav />
1126
1133
  <div className="mt-section">
@@ -1129,12 +1136,13 @@ function SettingsContent({ local, onAdd, onRemove, onGenerate }: { local: boolea
1129
1136
  <Route path="projects" element={<ProjectsSettings local={local} onAdd={onAdd} onRemove={onRemove} />} />
1130
1137
  <Route path="pipeline" element={<PipelineSettings />} />
1131
1138
  <Route path="delivery" element={<section><SectionTitle right={local ? "preview before writing" : "local mode required"}>Delivery presets</SectionTitle><DeliverySettings local={local} /></section>} />
1139
+ <Route path="integrations" element={<IntegrationsSettings />} />
1132
1140
  <Route path="preferences" element={<PreferencesSettings />} />
1133
1141
  <Route path="about" element={<AboutSettings />} />
1134
1142
  <Route path="*" element={<Navigate to={paths.settings()} replace />} />
1135
1143
  </Routes>
1136
1144
  </div>
1137
- {!about ? <div className="mono mt-section flex items-center gap-2 pb-section text-muted"><Box size={14} aria-hidden />{local ? "Changes are written only after preview; generated files remain reviewable in git." : "Configuration is embedded at build time; changing it requires a new catalog build."}</div> : null}
1145
+ {!about ? <div className="mono mt-section flex items-center gap-2 pb-section text-muted"><Box size={14} aria-hidden />{browserIntegration ? "Integration settings stay in this browser and do not change the generated catalog." : local ? "Changes are written only after preview; generated files remain reviewable in git." : "Configuration is embedded at build time; changing it requires a new catalog build."}</div> : null}
1138
1146
  </div>
1139
1147
  </div>
1140
1148
  );
@@ -0,0 +1,117 @@
1
+ import { ExternalLink, Save, Trash2 } from "lucide-react";
2
+ import { useState } from "react";
3
+ import { SectionTitle } from "../../components/PageHeader";
4
+ import { TechIcon } from "../../components/TechIcon";
5
+ import { normalizeKafkaUiUrl, useKafkaUi } from "../../lib/kafka-ui";
6
+ import { techGlyph } from "../../lib/tech";
7
+
8
+ const FIELD =
9
+ "mono w-full rounded-control border border-line bg-canvas px-3 py-2 text-ink outline-none focus:border-accent";
10
+
11
+ function KafkaUiCard() {
12
+ const configured = useKafkaUi((state) => state.url);
13
+ const setUrl = useKafkaUi((state) => state.setUrl);
14
+ const [draft, setDraft] = useState(configured);
15
+ const normalized = normalizeKafkaUiUrl(draft);
16
+ const valid = normalized !== null;
17
+ const dirty = valid && normalized !== configured;
18
+ const glyph = techGlyph("Kafka");
19
+
20
+ const save = () => {
21
+ if (normalized === null) return;
22
+ setUrl(normalized);
23
+ setDraft(normalized);
24
+ };
25
+
26
+ const remove = () => {
27
+ setUrl("");
28
+ setDraft("");
29
+ };
30
+
31
+ return (
32
+ <section className="rounded-card border border-line bg-canvas p-card shadow-xs">
33
+ <div className="flex items-start gap-3">
34
+ <span className="flex size-9 shrink-0 items-center justify-center rounded-control border border-line bg-surface text-ink">
35
+ {glyph ? <TechIcon glyph={glyph} size={18} /> : null}
36
+ </span>
37
+ <div className="min-w-0 flex-1">
38
+ <div className="flex flex-wrap items-center gap-2">
39
+ <h2 className="font-semibold text-ink">Kafka UI</h2>
40
+ <span className={`chip ${configured ? "status-verified" : "text-muted"}`}>
41
+ {configured ? "configured" : "not configured"}
42
+ </span>
43
+ </div>
44
+ <p className="mt-1 text-muted">
45
+ Opens catalogued Kafka topics in your Kafbat Kafka UI installation.
46
+ </p>
47
+ </div>
48
+ </div>
49
+
50
+ <label className="mt-4 block">
51
+ <span className="label mb-1.5 block">Kafka UI cluster URL</span>
52
+ <input
53
+ type="url"
54
+ value={draft}
55
+ onChange={(event) => setDraft(event.target.value)}
56
+ onKeyDown={(event) => {
57
+ if (event.key === "Enter" && dirty) save();
58
+ }}
59
+ placeholder="https://kafka.example/ui/clusters/production"
60
+ spellCheck={false}
61
+ autoComplete="off"
62
+ aria-invalid={!valid}
63
+ className={`${FIELD} ${valid ? "" : "border-unresolved"}`}
64
+ />
65
+ </label>
66
+ <p className={`mono mt-2 ${valid ? "text-muted" : "text-unresolved"}`}>
67
+ {valid
68
+ ? "Use a cluster or topics URL to open the exact topic. A plain installation URL opens Kafka UI itself."
69
+ : "Enter an http:// or https:// URL."}
70
+ </p>
71
+
72
+ <div className="mt-4 flex flex-wrap items-center gap-2 border-t border-line pt-4">
73
+ <button
74
+ type="button"
75
+ onClick={save}
76
+ disabled={!dirty}
77
+ className="product-primary"
78
+ >
79
+ <Save size={14} aria-hidden />
80
+ Save integration
81
+ </button>
82
+ {configured ? (
83
+ <>
84
+ <a
85
+ href={configured}
86
+ target="_blank"
87
+ rel="noreferrer"
88
+ className="tbtn px-3 py-1.5 text-ink"
89
+ >
90
+ Open Kafka UI
91
+ <ExternalLink size={14} aria-hidden />
92
+ </a>
93
+ <button
94
+ type="button"
95
+ onClick={remove}
96
+ className="tbtn ml-auto px-3 py-1.5 text-unresolved"
97
+ >
98
+ <Trash2 size={14} aria-hidden />
99
+ Remove
100
+ </button>
101
+ </>
102
+ ) : null}
103
+ </div>
104
+ </section>
105
+ );
106
+ }
107
+
108
+ export function IntegrationsSettings() {
109
+ return (
110
+ <section>
111
+ <SectionTitle right="stored in this browser">Integrations</SectionTitle>
112
+ <div className="grid gap-grid lg:grid-cols-2">
113
+ <KafkaUiCard />
114
+ </div>
115
+ </section>
116
+ );
117
+ }
@@ -169,12 +169,14 @@ describe("routes", () => {
169
169
  expect(paths.settingsProjects()).toBe("/settings/projects");
170
170
  expect(paths.settingsPipeline()).toBe("/settings/pipeline");
171
171
  expect(paths.settingsDelivery()).toBe("/settings/delivery");
172
+ expect(paths.settingsIntegrations()).toBe("/settings/integrations");
172
173
  expect(paths.settingsPreferences()).toBe("/settings/preferences");
173
174
  expect(paths.settingsAbout()).toBe("/settings/about");
174
175
  for (const path of [
175
176
  paths.settingsProjects(),
176
177
  paths.settingsPipeline(),
177
178
  paths.settingsDelivery(),
179
+ paths.settingsIntegrations(),
178
180
  paths.settingsPreferences(),
179
181
  paths.settingsAbout(),
180
182
  ]) expect(isRoutable(path)).toBe(true);
package/src/routes.ts CHANGED
@@ -45,6 +45,7 @@ export const paths = {
45
45
  settingsProjects: () => "/settings/projects",
46
46
  settingsPipeline: () => "/settings/pipeline",
47
47
  settingsDelivery: () => "/settings/delivery",
48
+ settingsIntegrations: () => "/settings/integrations",
48
49
  settingsPreferences: () => "/settings/preferences",
49
50
  settingsAbout: () => "/settings/about",
50
51
  external: (slug: string) => `/externals/${slug}`,
@@ -375,7 +376,7 @@ const ROUTES: RegExp[] = [
375
376
  /^\/language$/,
376
377
  /^\/problems$/,
377
378
  /^\/changes$/,
378
- /^\/settings(?:\/(?:projects|pipeline|delivery|preferences|about))?$/,
379
+ /^\/settings(?:\/(?:projects|pipeline|delivery|integrations|preferences|about))?$/,
379
380
  /^\/externals\/[^/]+$/,
380
381
  /^\/map$/,
381
382
  /^\/adrs\/[^/]+$/,
@@ -34,7 +34,7 @@ import { stepLabel } from "../flow/labels";
34
34
  import { KIND_LABEL } from "../lib/kinds";
35
35
  import { eventScope, resolveShape } from "../lib/shape";
36
36
  import { stepsInto } from "../lib/backlinks";
37
- import { treeHref } from "../lib/source-link";
37
+ import { sourceLocation, treeHref } from "../lib/source-link";
38
38
  import { walkSteps } from "../catalog";
39
39
  import { methodCount } from "../lib/api";
40
40
  import {
@@ -49,6 +49,7 @@ import { upstreamOf } from "../er/lineage";
49
49
  import type { LineageMaps } from "../er/lineage";
50
50
  import { ctxStyle } from "../lib/context-color";
51
51
  import { Ident } from "../components/Ident";
52
+ import { SourcePreviewLink } from "../components/SourcePreview";
52
53
  import { StatusChip } from "../components/primitives";
53
54
  import { StepDetailBody } from "../flow/StepDetail";
54
55
  import {
@@ -420,6 +421,50 @@ function TableBody({
420
421
  </>
421
422
  ) : null}
422
423
 
424
+ {(table.accesses ?? []).length > 0 ? (
425
+ <>
426
+ <Label>Reads / writes</Label>
427
+ {(table.accesses ?? []).map((access, accessIndex) => {
428
+ const location = access.source
429
+ ? sourceLocation(
430
+ access.source,
431
+ index.serviceById.get(store.owner),
432
+ allRepos(catalog),
433
+ )
434
+ : null;
435
+ const tone =
436
+ access.operation === "read"
437
+ ? "text-accent"
438
+ : access.operation === "write"
439
+ ? "text-verified"
440
+ : "text-unresolved";
441
+ return (
442
+ <div
443
+ key={`${access.operation}:${access.method}:${access.source}:${accessIndex}`}
444
+ className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-2 gap-y-0.5 py-1"
445
+ >
446
+ <span className={`chip self-start uppercase ${tone}`}>
447
+ {access.operation}
448
+ </span>
449
+ <div className="min-w-0">
450
+ <div className="mono break-all text-ink">
451
+ {access.method ?? "SQL client call"}
452
+ </div>
453
+ {access.source ? (
454
+ <SourcePreviewLink
455
+ location={location}
456
+ className="mono block break-all text-muted hover:text-accent"
457
+ >
458
+ {access.source}
459
+ </SourcePreviewLink>
460
+ ) : null}
461
+ </div>
462
+ </div>
463
+ );
464
+ })}
465
+ </>
466
+ ) : null}
467
+
423
468
  <Label>Referenced by</Label>
424
469
  {into.length === 0 ? (
425
470
  <div className="mono text-muted">nothing points at this table</div>