@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.
Files changed (188) hide show
  1. package/README.md +151 -6
  2. package/catalog/model.go +147 -0
  3. package/catalog/roundtrip_test.go +7 -0
  4. package/cli/portolan.mjs +6 -1
  5. package/go.mod +7 -0
  6. package/go.sum +20 -6
  7. package/package.json +10 -9
  8. package/plugin/describe.go +38 -0
  9. package/plugins/README.md +296 -5
  10. package/plugins/extract-argocd/options.schema.json +44 -0
  11. package/plugins/extract-celery/main.py +1 -0
  12. package/plugins/extract-csharp-ddd/README.md +213 -0
  13. package/plugins/extract-csharp-ddd/options.schema.json +55 -0
  14. package/plugins/extract-django/README.md +13 -2
  15. package/plugins/extract-django/choices.py +47 -0
  16. package/plugins/extract-django/domain.py +2 -1
  17. package/plugins/extract-django/extract_test.py +13 -0
  18. package/plugins/extract-django/lifecycle.py +2 -28
  19. package/plugins/extract-django/main.py +1 -0
  20. package/plugins/extract-django/rules.py +154 -0
  21. package/plugins/extract-django/rules_test.py +158 -0
  22. package/plugins/extract-go-sqs/options.schema.json +21 -0
  23. package/plugins/extract-http-clients/describe.go +5 -4
  24. package/plugins/extract-java/build/org/portolan/extract/Main.class +0 -0
  25. package/plugins/extract-k8s/options.schema.json +32 -0
  26. package/plugins/extract-laravel/Cargo.lock +962 -0
  27. package/plugins/extract-laravel/Cargo.toml +20 -0
  28. package/plugins/extract-laravel/README.md +200 -0
  29. package/plugins/extract-laravel/options.schema.json +68 -0
  30. package/plugins/extract-laravel/rustfmt.toml +1 -0
  31. package/plugins/extract-php-ddd/Cargo.lock +972 -0
  32. package/plugins/extract-php-ddd/Cargo.toml +22 -0
  33. package/plugins/extract-php-ddd/README.md +141 -0
  34. package/plugins/extract-php-ddd/options.schema.json +50 -0
  35. package/plugins/extract-php-ddd/rustfmt.toml +1 -0
  36. package/plugins/extract-python-kafka/main.py +1 -0
  37. package/plugins/extract-terraform/options.schema.json +26 -0
  38. package/plugins/extract-ts/main.ts +1 -0
  39. package/plugins/phpscan/Cargo.lock +953 -0
  40. package/plugins/phpscan/Cargo.toml +19 -0
  41. package/plugins/phpscan/rustfmt.toml +1 -0
  42. package/plugins/portolan-go.wasm +0 -0
  43. package/plugins/pyplugin/catalog.py +24 -1
  44. package/plugins/verify-otel/options.schema.json +12 -0
  45. package/portolan.json +130 -3
  46. package/rules/builtin.json +218 -0
  47. package/schema/portolan.schema.json +880 -13
  48. package/scripts/README.md +3 -1
  49. package/scripts/delivery-presets.mjs +36 -3
  50. package/scripts/gen-likec4.mjs +149 -16
  51. package/scripts/gen-likec4.test.mjs +96 -0
  52. package/scripts/gen.mjs +30 -3
  53. package/scripts/gitops-example.test.mjs +108 -0
  54. package/scripts/host-plugins/fetch-argocd.mjs +338 -0
  55. package/scripts/host-plugins/fetch-argocd.options.json +49 -0
  56. package/scripts/host-plugins/fetch-argocd.test.mjs +274 -0
  57. package/scripts/host-plugins/fetch-bsr.mjs +1 -0
  58. package/scripts/host-plugins/fetch-csr.mjs +1 -0
  59. package/scripts/host-plugins/fetch-git.mjs +1 -0
  60. package/scripts/host-plugins/fetch-k8s.mjs +263 -0
  61. package/scripts/host-plugins/fetch-k8s.options.json +50 -0
  62. package/scripts/host-plugins/fetch-k8s.test.mjs +259 -0
  63. package/scripts/host-plugins/k8s-topology.mjs +183 -0
  64. package/scripts/json-format.mjs +192 -0
  65. package/scripts/json-format.test.mjs +97 -0
  66. package/scripts/local-api.mjs +216 -8
  67. package/scripts/local-api.test.mjs +88 -2
  68. package/scripts/local-discovery.mjs +12 -0
  69. package/scripts/manifest.mjs +10 -1
  70. package/scripts/plugin-host.mjs +23 -1
  71. package/scripts/plugins-fresh.mjs +97 -0
  72. package/scripts/plugins-fresh.test.mjs +64 -0
  73. package/scripts/run-builtin.mjs +26 -5
  74. package/scripts/schema.mjs +138 -19
  75. package/scripts/trace-trials.mjs +176 -0
  76. package/scripts/trace-trials.test.mjs +142 -0
  77. package/scripts/warning-policy.mjs +12 -6
  78. package/scripts/warning-policy.test.mjs +40 -3
  79. package/src/app/Breadcrumbs.test.ts +3 -0
  80. package/src/app/Breadcrumbs.tsx +3 -0
  81. package/src/app/CatalogApp.tsx +2 -0
  82. package/src/app/SidebarFooter.tsx +20 -4
  83. package/src/catalog-index.ts +25 -0
  84. package/src/catalog-model.ts +246 -1
  85. package/src/catalog-validation.ts +104 -2
  86. package/src/catalog.test.ts +40 -1
  87. package/src/components/ChannelRows.test.tsx +76 -1
  88. package/src/components/ChannelRows.tsx +223 -60
  89. package/src/components/DeploymentRows.tsx +131 -0
  90. package/src/components/DocsLinks.test.tsx +27 -0
  91. package/src/components/DocsLinks.tsx +56 -0
  92. package/src/components/FieldTree.tsx +5 -2
  93. package/src/components/HTTPDestinationEvidence.tsx +1 -1
  94. package/src/components/PluginIcon.tsx +77 -0
  95. package/src/components/ProblemRow.tsx +173 -153
  96. package/src/components/RuleMarks.tsx +22 -0
  97. package/src/components/ShapeRows.tsx +24 -20
  98. package/src/data.ts +21 -0
  99. package/src/enrich.test.ts +127 -3
  100. package/src/enrich.ts +104 -6
  101. package/src/er/ErCanvas.tsx +218 -12
  102. package/src/er/GroupNode.tsx +57 -0
  103. package/src/er/StoreHeader.tsx +1 -0
  104. package/src/er/layout.test.ts +85 -2
  105. package/src/er/layout.ts +140 -5
  106. package/src/er/spec.test.ts +17 -0
  107. package/src/er/spec.ts +23 -10
  108. package/src/flow/Recordings.test.tsx +52 -0
  109. package/src/flow/Recordings.tsx +236 -0
  110. package/src/flow/StepDetail.tsx +53 -0
  111. package/src/flow/TraceTrial.tsx +419 -0
  112. package/src/flow/examples.test.ts +33 -0
  113. package/src/flow/examples.ts +37 -0
  114. package/src/flow/outline.test.ts +28 -0
  115. package/src/flow/outline.ts +7 -2
  116. package/src/flow/trace-trial-resume.test.ts +42 -0
  117. package/src/flow/trace-trial-resume.ts +74 -0
  118. package/src/graph/elk.ts +78 -0
  119. package/src/index.css +24 -0
  120. package/src/landing/LandingPage.tsx +15 -11
  121. package/src/landing/ProductTour.tsx +6 -6
  122. package/src/lib/all-problems.ts +27 -17
  123. package/src/lib/centrality.test.ts +251 -0
  124. package/src/lib/centrality.ts +232 -0
  125. package/src/lib/confluence.test.ts +37 -0
  126. package/src/lib/confluence.ts +41 -0
  127. package/src/lib/context-color.ts +1 -1
  128. package/src/lib/deployment-drift.ts +22 -0
  129. package/src/lib/derive.ts +43 -69
  130. package/src/lib/environments.test.ts +83 -0
  131. package/src/lib/environments.ts +57 -0
  132. package/src/lib/integration-url.test.ts +30 -0
  133. package/src/lib/integration-url.ts +63 -0
  134. package/src/lib/kafka-ui.ts +3 -45
  135. package/src/lib/local-api.ts +96 -2
  136. package/src/lib/notion.ts +13 -0
  137. package/src/lib/plugin-index.json +2765 -0
  138. package/src/lib/plugins.test.ts +68 -0
  139. package/src/lib/plugins.ts +264 -0
  140. package/src/lib/problem-flows.test.ts +61 -0
  141. package/src/lib/problem-flows.ts +78 -0
  142. package/src/lib/problem-rules-cel.d.mts +43 -0
  143. package/src/lib/problem-rules-cel.mjs +407 -0
  144. package/src/lib/problem-rules.test.ts +287 -0
  145. package/src/lib/problem-rules.ts +271 -0
  146. package/src/lib/problem-subjects.ts +737 -0
  147. package/src/lib/rule-entries.ts +39 -0
  148. package/src/lib/{data-problems.test.ts → rules-data.test.ts} +18 -16
  149. package/src/lib/rules-deploy.test.ts +133 -0
  150. package/src/lib/{problems.test.ts → rules-edges.test.ts} +8 -3
  151. package/src/lib/{proto-problems.test.ts → rules-proto.test.ts} +9 -25
  152. package/src/lib/{wire-problems.test.ts → rules-wire.test.ts} +13 -11
  153. package/src/lib/rules.test.ts +51 -0
  154. package/src/lib/rules.ts +86 -0
  155. package/src/lib/shape.test.ts +32 -0
  156. package/src/lib/shape.ts +30 -6
  157. package/src/lib/tech.ts +16 -0
  158. package/src/lib/trace-project.test.ts +34 -0
  159. package/src/lib/trace-project.ts +50 -0
  160. package/src/lib/use-problems.ts +23 -0
  161. package/src/lib/warnings.test.ts +9 -0
  162. package/src/lib/warnings.ts +10 -10
  163. package/src/likec4/ids.test.ts +6 -2
  164. package/src/likec4/ids.ts +43 -0
  165. package/src/main.tsx +23 -0
  166. package/src/merge-deployments.test.ts +127 -0
  167. package/src/merge.test.ts +82 -0
  168. package/src/merge.ts +173 -9
  169. package/src/pages/AggregatePage.tsx +57 -7
  170. package/src/pages/ContextMap.tsx +45 -3
  171. package/src/pages/ContextPage.tsx +2 -0
  172. package/src/pages/EventPage.tsx +15 -5
  173. package/src/pages/FlowDetail.tsx +23 -2
  174. package/src/pages/GraphPage.tsx +40 -3
  175. package/src/pages/Overview.tsx +152 -12
  176. package/src/pages/PluginIndex.tsx +190 -0
  177. package/src/pages/Problems.tsx +396 -128
  178. package/src/pages/ServicePage.tsx +58 -2
  179. package/src/pages/Settings.tsx +28 -2
  180. package/src/pages/settings/AboutSettings.tsx +8 -1
  181. package/src/pages/settings/IntegrationsSettings.tsx +63 -17
  182. package/src/pages/settings/RecordingSettings.tsx +138 -0
  183. package/src/pages/settings/RulesSettings.tsx +825 -0
  184. package/src/routes.test.ts +9 -0
  185. package/src/routes.ts +22 -1
  186. package/src/lib/data-problems.ts +0 -314
  187. package/src/lib/proto-problems.ts +0 -237
  188. package/src/lib/wire-problems.ts +0 -342
package/src/merge.ts CHANGED
@@ -15,8 +15,12 @@
15
15
  // context's name is a fact about the estate that belongs on the Problems
16
16
  // page, next to the other things that are true and unfortunate.
17
17
 
18
+ import { deploymentBasis } from "./catalog-model.ts";
18
19
  import type {
19
20
  Aggregate,
21
+ Deployment,
22
+ DeploymentBasis,
23
+ DeploymentDrift,
20
24
  Alt,
21
25
  AltBranch,
22
26
  Block,
@@ -121,6 +125,8 @@ export function mergeCatalogs(sources: CatalogSource[]): MergeResult {
121
125
  const terms: NonNullable<Catalog["terms"]> = [];
122
126
  const repos: NonNullable<Catalog["repos"]> = [];
123
127
  const repoOrigin = new Map<string, string>();
128
+ const deployments: NonNullable<Catalog["deployments"]> = [];
129
+ const deploymentOrigin = new Map<string, Map<DeploymentBasis, string>>(); // id -> basis -> source path
124
130
  const externals = new Map<string, External>();
125
131
  const externalOrigin = new Map<string, string>();
126
132
  const seen = new Map<string, string>(); // flow/adr/store/module/term id -> source path
@@ -249,6 +255,15 @@ export function mergeCatalogs(sources: CatalogSource[]): MergeResult {
249
255
  for (const store of catalog.stores ?? []) {
250
256
  if (claim(seen, store.id, path, conflicts, "store")) stores.push(store);
251
257
  }
258
+ // Two sources may speak about one Application, and are meant to: the
259
+ // GitOps tree says what should run, the deployer says what does, and
260
+ // the row a reader sees is the two laid over each other with the
261
+ // difference kept as drift (portolan.0013). Two rows of the SAME basis
262
+ // for one id is the collision - two snapshots of one server, two trees
263
+ // declaring one name - and is reported like any other.
264
+ for (const deployment of catalog.deployments ?? []) {
265
+ foldDeployment(deployments, deploymentOrigin, deployment, path, conflicts);
266
+ }
252
267
  // Claimed by id like everything else at this level, and that is exactly why
253
268
  // a module's id is its registry-global name rather than one derived from an
254
269
  // owner: the producer and each consumer describe the same module from
@@ -324,6 +339,7 @@ export function mergeCatalogs(sources: CatalogSource[]): MergeResult {
324
339
  if (modules.length > 0) merged.modules = modules;
325
340
  if (terms.length > 0) merged.terms = terms;
326
341
  if (repos.length > 0) merged.repos = repos;
342
+ if (deployments.length > 0) merged.deployments = deployments;
327
343
  if (externals.size > 0) merged.externals = [...externals.values()];
328
344
 
329
345
  return { catalog: merged, sources: stamps, conflicts };
@@ -499,6 +515,8 @@ function mergeService(
499
515
  ...(incoming.commands
500
516
  ? { commands: incoming.commands.map((c) => ({ ...c })) }
501
517
  : {}),
518
+ ...(incoming.hosts ? { hosts: [...incoming.hosts] } : {}),
519
+ ...(incoming.dials ? { dials: [...incoming.dials] } : {}),
502
520
  });
503
521
  origin.set(incoming.id, path);
504
522
 
@@ -592,6 +610,18 @@ function mergeService(
592
610
  existing.commands = commands;
593
611
  }
594
612
 
613
+ // Unioned like owners: two manifest trees naming where a service answers,
614
+ // or what it dials, are two deployments of it, and the page shows both.
615
+ for (const field of ["hosts", "dials"] as const) {
616
+ const theirs = incoming[field];
617
+ if (!theirs?.length) continue;
618
+ const names = existing[field] ?? [];
619
+ for (const name of theirs) {
620
+ if (!names.includes(name)) names.push(name);
621
+ }
622
+ existing[field] = names;
623
+ }
624
+
595
625
  if (incoming.channels?.length) {
596
626
  // Keyed by address, because that is the whole of a channel's identity. Two
597
627
  // documents describing one channel is not a conflict worth reporting - a
@@ -930,26 +960,84 @@ function raise<T extends { status: Status; note?: string }>(
930
960
  *
931
961
  * Only `declared` is raised. `unresolved` means the far end is not in the
932
962
  * catalog, and a trace showing the hop does not put it there.
963
+ *
964
+ * The second declaration may also say more than the first: a hop it saw
965
+ * that the code does not declare, carried as a step with `seen` on it and
966
+ * an id the code never gave, sitting where the recording put it; a lane
967
+ * such a hop needed, added after the declared ones; and the recordings
968
+ * themselves, as examples. Each is taken as it comes - a recording is the
969
+ * one witness for it - and nothing already declared moves.
933
970
  */
934
971
  export function overlayFlow(existing: Flow, incoming: Flow): Flow | undefined {
935
- const lanes = (flow: Flow) => flow.participants.map((p) => p.id).join(" ");
936
- if (lanes(existing) !== lanes(incoming)) return undefined;
972
+ const mine = existing.participants.map((p) => p.id);
973
+ const theirs = incoming.participants.map((p) => p.id);
974
+ if (theirs.length < mine.length || mine.some((id, i) => theirs[i] !== id))
975
+ return undefined;
937
976
 
938
977
  const steps = overlayNodes(existing.steps, incoming.steps);
939
978
  if (!steps) return undefined;
940
979
 
941
- return { ...existing, steps };
980
+ const out: Flow = { ...existing, steps };
981
+ if (theirs.length > mine.length) {
982
+ out.participants = [
983
+ ...existing.participants,
984
+ ...incoming.participants.slice(mine.length),
985
+ ];
986
+ }
987
+ const examples = [...(existing.examples ?? [])];
988
+ const held = new Set(examples.map((example) => example.id));
989
+ for (const example of incoming.examples ?? []) {
990
+ if (!held.has(example.id)) {
991
+ held.add(example.id);
992
+ examples.push(example);
993
+ }
994
+ }
995
+ if (examples.length) out.examples = examples;
996
+
997
+ return out;
998
+ }
999
+
1000
+ /**
1001
+ * A node the second declaration added: a step seen in a recording and
1002
+ * unknown to the code, or a frame where the recordings parted - an `alt`
1003
+ * whose every step carries `seen` and whose every branch is counted. Its
1004
+ * id is not one the first declaration has, which is how it is told from a
1005
+ * declared node that moved.
1006
+ */
1007
+ function isSeenAddition(node: FlowNode, against: FlowNode | undefined): boolean {
1008
+ if (against !== undefined && against.id === node.id) return false;
1009
+ if (node.type === "step") return node.seen !== undefined;
1010
+ if (node.type !== "alt") return false;
1011
+ let steps = 0;
1012
+ const allSeen = (nodes: FlowNode[]): boolean =>
1013
+ nodes.every((inner) => {
1014
+ if (inner.type === "step") {
1015
+ steps += 1;
1016
+
1017
+ return inner.seen !== undefined;
1018
+ }
1019
+
1020
+ return inner.type === "alt" && inner.branches.every((b) => b.seen !== undefined && allSeen(b.steps));
1021
+ });
1022
+
1023
+ return node.branches.every((b) => b.seen !== undefined && allSeen(b.steps)) && steps > 0;
942
1024
  }
943
1025
 
944
1026
  function overlayNodes(
945
1027
  existing: FlowNode[],
946
1028
  incoming: FlowNode[],
947
1029
  ): FlowNode[] | undefined {
948
- if (existing.length !== incoming.length) return undefined;
949
1030
  const out: FlowNode[] = [];
950
- for (let i = 0; i < existing.length; i++) {
951
- const a = existing[i]!;
952
- const b = incoming[i]!;
1031
+ let i = 0;
1032
+ for (const b of incoming) {
1033
+ const a = existing[i];
1034
+ if (isSeenAddition(b, a)) {
1035
+ out.push(b);
1036
+
1037
+ continue;
1038
+ }
1039
+ if (a === undefined) return undefined;
1040
+ i++;
953
1041
  if (a.type !== b.type || a.id !== b.id) return undefined;
954
1042
  switch (a.type) {
955
1043
  case "step": {
@@ -961,10 +1049,11 @@ function overlayNodes(
961
1049
  (a.ref ?? "") !== (step.ref ?? "")
962
1050
  )
963
1051
  return undefined;
1052
+ const seen = step.seen ? { seen: step.seen } : {};
964
1053
  if (step.status === "verified" && a.status === "declared") {
965
- out.push({ ...a, status: "verified", note: a.note || step.note });
1054
+ out.push({ ...a, ...seen, status: "verified", note: a.note || step.note });
966
1055
  } else {
967
- out.push(a);
1056
+ out.push({ ...a, ...seen });
968
1057
  }
969
1058
  break;
970
1059
  }
@@ -1006,6 +1095,8 @@ function overlayNodes(
1006
1095
  }
1007
1096
  }
1008
1097
 
1098
+ if (i !== existing.length) return undefined;
1099
+
1009
1100
  return out;
1010
1101
  }
1011
1102
 
@@ -1013,6 +1104,79 @@ function overlayNodes(
1013
1104
  * Records who owns an id. Returns false when somebody already did, which is
1014
1105
  * how a duplicate is skipped rather than appended twice.
1015
1106
  */
1107
+ /**
1108
+ * Lays one deployment over what is already held for its id.
1109
+ *
1110
+ * A manifest row and an api row fold into one: the api row's shape - it is
1111
+ * what runs, and carries the revision, the images and the link - with the
1112
+ * manifest filling what the deployer did not say (a service the labels
1113
+ * name, images the overlay pins when nothing runs yet), and the fields the
1114
+ * two disagree on kept as the tree's word under `drift`. Whichever arrives
1115
+ * first is held; the second folds onto it. Two rows of one basis are a
1116
+ * collision and the first is kept, said out loud.
1117
+ */
1118
+ function foldDeployment(
1119
+ held: Deployment[],
1120
+ origins: Map<string, Map<DeploymentBasis, string>>,
1121
+ incoming: Deployment,
1122
+ path: string,
1123
+ conflicts: MergeConflict[],
1124
+ ): void {
1125
+ const basis = deploymentBasis(incoming);
1126
+ const bases = origins.get(incoming.id) ?? new Map<DeploymentBasis, string>();
1127
+ origins.set(incoming.id, bases);
1128
+ const index = held.findIndex((d) => d.id === incoming.id);
1129
+ if (index < 0) {
1130
+ bases.set(basis, path);
1131
+ held.push(incoming);
1132
+ return;
1133
+ }
1134
+ const already = bases.get(basis);
1135
+ if (already !== undefined || basis === "both") {
1136
+ conflicts.push({
1137
+ path,
1138
+ where: incoming.id,
1139
+ message: `deployment "${incoming.id}" is ${basis === "manifest" ? "declared" : "listed"} here and in ${already ?? "another source"}; the first one is used`,
1140
+ });
1141
+ return;
1142
+ }
1143
+ bases.set(basis, path);
1144
+ const current = held[index]!;
1145
+ const [manifest, api] = basis === "manifest" ? [incoming, current] : [current, incoming];
1146
+ held[index] = laidOver(manifest, api);
1147
+ }
1148
+
1149
+ /** The api row with the manifest laid under it, and their differences as drift. */
1150
+ function laidOver(manifest: Deployment, api: Deployment): Deployment {
1151
+ const drift: DeploymentDrift = {};
1152
+ if (manifest.project && manifest.project !== api.project) drift.project = manifest.project;
1153
+ if (manifest.cluster && manifest.cluster !== api.cluster) drift.cluster = manifest.cluster;
1154
+ if (manifest.namespace && manifest.namespace !== api.namespace) drift.namespace = manifest.namespace;
1155
+ if (manifest.path && manifest.path !== api.path) drift.path = manifest.path;
1156
+ if (manifest.targetRevision && manifest.targetRevision !== api.targetRevision) {
1157
+ drift.targetRevision = manifest.targetRevision;
1158
+ }
1159
+ // The overlay pins some images; the cluster runs those and the base's.
1160
+ // Drift is a pinned image that is not running, not a running image the
1161
+ // overlay never mentioned.
1162
+ const running = new Set(api.images ?? []);
1163
+ const pinnedElsewhere = (manifest.images ?? []).filter((image) => !running.has(image));
1164
+ if (pinnedElsewhere.length > 0 && (api.images?.length ?? 0) > 0) drift.images = pinnedElsewhere;
1165
+
1166
+ const folded: Deployment = {
1167
+ ...api,
1168
+ service: api.service ?? manifest.service,
1169
+ tool: api.tool || manifest.tool,
1170
+ images: api.images?.length ? api.images : manifest.images,
1171
+ basis: "both",
1172
+ };
1173
+ if (folded.service === undefined) delete folded.service;
1174
+ if (!folded.images?.length) delete folded.images;
1175
+ if (Object.keys(drift).length > 0) folded.drift = drift;
1176
+ else delete folded.drift;
1177
+ return folded;
1178
+ }
1179
+
1016
1180
  function claim(
1017
1181
  owners: Map<string, string>,
1018
1182
  id: string,
@@ -1,7 +1,7 @@
1
1
  import { useMemo } from "react";
2
2
  import { Link, useParams } from "react-router";
3
3
  import { catalog, index } from "../data";
4
- import { blockCounts, blockFields, enumsOf, rootEntity } from "../catalog";
4
+ import { allRepos, blockCounts, blockFields, enumsOf, rootEntity } from "../catalog";
5
5
  import { isStatusEnum } from "../lib/shape";
6
6
  import type {
7
7
  Aggregate,
@@ -11,7 +11,7 @@ import type {
11
11
  Service,
12
12
  Enum,
13
13
  } from "../catalog";
14
- import { markdownOutline } from "../lib/derive";
14
+ import { flowsRunning, markdownOutline } from "../lib/derive";
15
15
  import {
16
16
  redisKeyspacesPersisting,
17
17
  tablesPersisting,
@@ -33,6 +33,8 @@ import {
33
33
  } from "../routes";
34
34
  import { methodId } from "../lib/api";
35
35
  import { Markdown } from "../components/Markdown";
36
+ import { SourcePreviewLink } from "../components/SourcePreview";
37
+ import { sourceLocation, splitLine } from "../lib/source-link";
36
38
  import { Empty, PageHeader, SectionTitle } from "../components/PageHeader";
37
39
  import { Ident } from "../components/Ident";
38
40
  import { RowActions } from "../components/RowActions";
@@ -265,10 +267,12 @@ function OperationList({
265
267
  kind,
266
268
  operations,
267
269
  service,
270
+ aggregate,
268
271
  }: {
269
272
  kind: "command" | "query";
270
273
  operations: Operation[];
271
274
  service: Service;
275
+ aggregate: Aggregate;
272
276
  }) {
273
277
  // Whether this service records what exposes an operation at all. A catalog
274
278
  // written before anything read a transport layer says nothing either way,
@@ -277,10 +281,14 @@ function OperationList({
277
281
  aggregate.operations.some((operation) => operation.exposedBy?.length),
278
282
  );
279
283
  const to = servicePath(service.id);
284
+ const pins = allRepos(catalog);
280
285
 
281
286
  return (
282
287
  <ul className="flex flex-col gap-1">
283
- {operations.map((op) => (
288
+ {operations.map((op) => {
289
+ const runs = flowsRunning(catalog, service, aggregate, op);
290
+ const location = op.source ? sourceLocation(op.source, service, pins) : null;
291
+ return (
284
292
  <li
285
293
  key={op.id}
286
294
  className={`flex items-start gap-2 border-l-2 px-2 py-1.5 bg-surface ${
@@ -290,7 +298,7 @@ function OperationList({
290
298
  <span className="mt-px shrink-0">
291
299
  <KindIcon kind={kind} />
292
300
  </span>
293
- <div className="min-w-0">
301
+ <div className="min-w-0 flex-1">
294
302
  <span className="flex flex-wrap items-center gap-x-2">
295
303
  <Ident block value={op.id} className={op.deprecated ? "line-through" : undefined} />
296
304
  {op.deprecated ? (
@@ -298,8 +306,49 @@ function OperationList({
298
306
  deprecated
299
307
  </span>
300
308
  ) : null}
309
+ {op.source ? (
310
+ <SourcePreviewLink location={location} className="mono ml-auto text-muted hover:text-ink">
311
+ {splitLine(op.source).path.split("/").pop()}
312
+ {splitLine(op.source).line ? `:${splitLine(op.source).line}` : ""}
313
+ </SourcePreviewLink>
314
+ ) : null}
301
315
  </span>
302
316
  {op.doc ? <p className="mt-0.5 text-muted">{op.doc}</p> : null}
317
+ {/* What the caller hands in: the message's own shape. An empty
318
+ list is said out loud - a query that takes nothing is a fact
319
+ about the query, not a gap in the reading. */}
320
+ {op.fields ? (
321
+ op.fields.length ? (
322
+ <dl className="mono mt-1 grid grid-cols-[max-content_1fr] gap-x-3 gap-y-0.5 text-muted">
323
+ {op.fields.map((field) => (
324
+ <div key={field.name} className="contents">
325
+ <dt className={field.deprecated ? "line-through" : undefined}>{field.name}</dt>
326
+ <dd className="min-w-0 truncate">
327
+ <span className="text-ink">{field.type}</span>
328
+ {field.doc ? <span className="ml-2">{field.doc}</span> : null}
329
+ </dd>
330
+ </div>
331
+ ))}
332
+ </dl>
333
+ ) : (
334
+ <p className="mono mt-1 text-muted">takes nothing</p>
335
+ )
336
+ ) : null}
337
+ {runs.length ? (
338
+ <p className="mono mt-1 flex flex-wrap items-center gap-x-2 text-muted">
339
+ <span>runs in</span>
340
+ {runs.map(({ flow, stepId }) => (
341
+ <Link
342
+ key={flow.slug}
343
+ to={paths.flowStep(flow.slug, stepId)}
344
+ className="rounded-control hover:text-ink"
345
+ title={flow.name}
346
+ >
347
+ {flow.slug}
348
+ </Link>
349
+ ))}
350
+ </p>
351
+ ) : null}
303
352
  {op.exposedBy?.length ? (
304
353
  <p className="mono mt-1 flex flex-wrap items-center gap-x-2 text-muted">
305
354
  <span>exposed by</span>
@@ -328,7 +377,8 @@ function OperationList({
328
377
  ) : null}
329
378
  </div>
330
379
  </li>
331
- ))}
380
+ );
381
+ })}
332
382
  </ul>
333
383
  );
334
384
  }
@@ -593,7 +643,7 @@ export function AggregatePage() {
593
643
  {commands.length === 0 ? (
594
644
  <Empty>{aggregate.kind === "model-group" ? "no application commands discovered" : "nothing changes this aggregate from outside"}</Empty>
595
645
  ) : null}
596
- <OperationList kind="command" operations={commands} service={service} />
646
+ <OperationList kind="command" operations={commands} service={service} aggregate={aggregate} />
597
647
  </div>
598
648
  <div id={AGGREGATE_ANCHOR.queries}>
599
649
  <SectionTitle
@@ -609,7 +659,7 @@ export function AggregatePage() {
609
659
  {queries.length === 0 ? (
610
660
  <Empty>{aggregate.kind === "model-group" ? "no application queries discovered" : "nothing reads this aggregate by name"}</Empty>
611
661
  ) : null}
612
- <OperationList kind="query" operations={queries} service={service} />
662
+ <OperationList kind="query" operations={queries} service={service} aggregate={aggregate} />
613
663
  </div>
614
664
  </div>
615
665
 
@@ -17,6 +17,8 @@ import { Link, useSearchParams } from "react-router";
17
17
  import { ArrowRight, ArrowLeftRight, Boxes, Minus } from "lucide-react";
18
18
  import { catalog } from "../data";
19
19
  import { plural } from "../lib/format";
20
+ import { narrowToEnvironments } from "../lib/environments";
21
+ import { environmentsOf } from "../likec4/ids";
20
22
  import { PATTERN_LABEL, PATTERN_MEANING, contextMap } from "../lib/context-map";
21
23
  import type {
22
24
  ContextDependency,
@@ -439,7 +441,22 @@ export function ContextMap() {
439
441
  useDocumentTitle("Context map");
440
442
  const [search, setSearch] = useSearchParams();
441
443
  const [tour, setTour] = useState(() => search.get("tour") === "1");
442
- const relations = useMemo(() => contextMap(catalog), []);
444
+ // The map of one environment is the same map over the services deployed
445
+ // there: a pair joined only through a service that is not in staging is
446
+ // not joined in staging. Empty means the whole estate, as every filter here.
447
+ const environmentChips = useMemo(() => environmentsOf(catalog), []);
448
+ const [environments, setEnvironments] = useState<Set<string>>(new Set());
449
+ const narrowed = useMemo(
450
+ () => narrowToEnvironments(catalog, environments),
451
+ [environments],
452
+ );
453
+ const relations = useMemo(() => contextMap(narrowed), [narrowed]);
454
+ const toggleEnvironment = (env: string) =>
455
+ setEnvironments((prev) => {
456
+ const next = new Set(prev);
457
+ if (!next.delete(env)) next.add(env);
458
+ return next;
459
+ });
443
460
  const joined = relations.filter(
444
461
  (r) => r.dependencies.length > 0 || r.shared.length > 0,
445
462
  );
@@ -457,9 +474,34 @@ export function ContextMap() {
457
474
  <div className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-2 border-b px-gutter py-3 border-line">
458
475
  <h1 className="text-lg font-semibold">Context map</h1>
459
476
  <span className="mono text-muted">
460
- {catalog.contexts.length} {plural(catalog.contexts.length, "domain")} ·{" "}
477
+ {narrowed.contexts.length} {plural(narrowed.contexts.length, "domain")} ·{" "}
461
478
  {wired} of {relations.length} {plural(relations.length, "pair")} joined
462
479
  </span>
480
+ {environmentChips.length > 0 ? (
481
+ <div className="seg" role="group" aria-label="Filter by environment">
482
+ {environmentChips.map((env) => {
483
+ const on = environments.has(env);
484
+ return (
485
+ <button
486
+ key={env}
487
+ type="button"
488
+ onClick={() => toggleEnvironment(env)}
489
+ aria-pressed={on}
490
+ title={`only the services the deployer places in ${env}`}
491
+ className="flex items-center gap-1.5"
492
+ style={{
493
+ color: on ? "var(--accent)" : "var(--fg-muted)",
494
+ background: on
495
+ ? "color-mix(in srgb, var(--accent) 12%, transparent)"
496
+ : undefined,
497
+ }}
498
+ >
499
+ {env}
500
+ </button>
501
+ );
502
+ })}
503
+ </div>
504
+ ) : null}
463
505
 
464
506
  {/* Two legends, and they answer different questions: what the arrow
465
507
  means, and how far a word on a chip can be trusted. */}
@@ -505,7 +547,7 @@ export function ContextMap() {
505
547
  </SectionTitle>
506
548
  <div className="h-[340px] overflow-hidden rounded-card border border-line">
507
549
  <ContextMapPane
508
- catalog={catalog}
550
+ catalog={narrowed}
509
551
  relations={relations}
510
552
  zoomOnScroll={false}
511
553
  />
@@ -9,6 +9,7 @@ import { staggerStyle } from "../lib/motion";
9
9
  import { CONTEXT_ANCHOR, EVENT_ANCHOR, LINKS_HERE, paths } from "../routes";
10
10
  import { Empty, PageHeader, SectionTitle } from "../components/PageHeader";
11
11
  import { Ident } from "../components/Ident";
12
+ import { DocsLinks } from "../components/DocsLinks";
12
13
  import { ClassificationBadge } from "../components/primitives";
13
14
  import { KindIcon } from "../components/kind";
14
15
  import { RowActions } from "../components/RowActions";
@@ -94,6 +95,7 @@ export function ContextPage() {
94
95
  </a>
95
96
  </>
96
97
  ) : null}
98
+ <DocsLinks name={context.name} />
97
99
  </div>
98
100
  {/* Who to ask about the whole domain, folded up from what its
99
101
  services' CODEOWNERS say - the context itself names nobody. The
@@ -48,6 +48,8 @@ interface SchemaRow extends Field {
48
48
  change?: Change;
49
49
  /** The type the previous version gave the field, when this one changed it. */
50
50
  from?: string;
51
+ /** The rules the previous version gave the field, when this one changed them; "" for none. */
52
+ rulesFrom?: string;
51
53
  }
52
54
 
53
55
  /** What the change column says, and in what colour. */
@@ -60,7 +62,7 @@ const CHANGE: Record<Change, { label: string; className: string; title: string }
60
62
  changed: {
61
63
  label: "changed",
62
64
  className: "text-declared",
63
- title: "type changed in this version",
65
+ title: "changed in this version",
64
66
  },
65
67
  removed: {
66
68
  label: "removed",
@@ -69,13 +71,21 @@ const CHANGE: Record<Change, { label: string; className: string; title: string }
69
71
  },
70
72
  };
71
73
 
74
+ /**
75
+ * What the previous version had, for the tooltip of a changed row: the type
76
+ * when it moved, the rules when they did. What the row has now is the row.
77
+ */
78
+ function wasBefore(row: SchemaRow): string | null {
79
+ const parts: string[] = [];
80
+ if (row.from) parts.push(`was ${row.from}`);
81
+ if (row.rulesFrom !== undefined) parts.push(`rules were ${row.rulesFrom || "none"}`);
82
+ return parts.length ? `${parts.join(", ")} in the previous version` : null;
83
+ }
84
+
72
85
  function ChangeMark({ row }: { row: SchemaRow }) {
73
86
  if (!row.change) return null;
74
87
  const mark = CHANGE[row.change];
75
- const title =
76
- row.change === "changed" && row.from
77
- ? `was ${row.from} in the previous version`
78
- : mark.title;
88
+ const title = (row.change === "changed" && wasBefore(row)) || mark.title;
79
89
  return (
80
90
  <span
81
91
  className={`mono inline-flex items-center gap-1 ${mark.className}`}
@@ -30,6 +30,8 @@ import {
30
30
  isCrossContext,
31
31
  } from "../flow/cross-context";
32
32
  import { StepRail } from "../flow/StepRail";
33
+ import { RecordingsChip } from "../flow/Recordings";
34
+ import { stepsShownBy } from "../flow/examples";
33
35
  import { FlowTable } from "../flow/FlowTable";
34
36
  import { FlowToolbar } from "../flow/FlowToolbar";
35
37
  import { buildChapters, groupRows } from "../flow/chapters";
@@ -127,6 +129,8 @@ export function FlowDetail() {
127
129
  const [hoverStep, setHoverStep] = useState<string | null>(null);
128
130
  /** The step LikeC4's walkthrough is on, when it is running. */
129
131
  const [walkStep, setWalkStep] = useState<string | null>(null);
132
+ /** The recording whose steps are lit on the picture, when one is chosen. */
133
+ const [exampleId, setExampleId] = useState<string | null>(null);
130
134
 
131
135
  const canvas = useRef<CanvasHandle | null>(null);
132
136
 
@@ -264,6 +268,17 @@ export function FlowDetail() {
264
268
  // watching rather than pointing, and the rail's job is to follow.
265
269
  const activeId = walkStep ?? selectedStepId ?? focusedMatch;
266
270
 
271
+ /**
272
+ * The steps a chosen recording showed. A recording is a reading of the
273
+ * flow like a path is, and it lights the picture the same way, below
274
+ * whatever the reader is pointing at.
275
+ */
276
+ const exampleSteps = useMemo(() => {
277
+ const example = exampleId
278
+ ? flow?.examples?.find((e) => e.id === exampleId)
279
+ : undefined;
280
+ return example ? stepsShownBy(example) : [];
281
+ }, [flow, exampleId]);
267
282
  const litSteps = useMemo(
268
283
  () =>
269
284
  walkStep
@@ -272,8 +287,10 @@ export function FlowDetail() {
272
287
  ? [selectedStepId]
273
288
  : hoverStep
274
289
  ? [hoverStep]
275
- : matches,
276
- [walkStep, selectedStepId, hoverStep, matches],
290
+ : matches.length > 0
291
+ ? matches
292
+ : exampleSteps,
293
+ [walkStep, selectedStepId, hoverStep, matches, exampleSteps],
277
294
  );
278
295
 
279
296
  useEffect(() => {
@@ -511,6 +528,10 @@ export function FlowDetail() {
511
528
  </a>
512
529
  ) : null}
513
530
  <EditorLink location={flowSource} variant="text" />
531
+ {/* A recording is evidence too: the traces the flow was seen
532
+ running in, opened behind a chip rather than laid out in the
533
+ rail, which is for the steps. */}
534
+ <RecordingsChip flow={flow} exampleId={exampleId} onExample={setExampleId} />
514
535
  <WhatLinksHere
515
536
  target={{ kind: "flow", id: flow.slug }}
516
537
  variant="line"
@@ -11,6 +11,8 @@ import {
11
11
  filterEventGraph,
12
12
  } from "../lib/event-graph";
13
13
  import { contextVar } from "../lib/context-color";
14
+ import { narrowToEnvironments } from "../lib/environments";
15
+ import { environmentsOf } from "../likec4/ids";
14
16
  import { statusColor, statusDash } from "../graph/theme";
15
17
  import { DependencyGraphPane } from "../graph/DependencyGraph";
16
18
  import type { GraphMode } from "../graph/dependency-layout";
@@ -28,8 +30,16 @@ export function GraphPage() {
28
30
  const [contexts, setContexts] = useState<Set<string>>(new Set());
29
31
  const [statuses, setStatuses] = useState<Set<Status>>(new Set());
30
32
  const [mode, setMode] = useState<GraphMode>("bipartite");
33
+ // Where the estate runs, when a snapshot says. Narrowing the catalog
34
+ // rather than the graph: a service not deployed in staging is not a node
35
+ // to fade but a service that is not there, and every count follows.
36
+ const environmentChips = useMemo(() => environmentsOf(catalog), []);
37
+ const [environments, setEnvironments] = useState<Set<string>>(new Set());
31
38
 
32
- const whole = useMemo(() => eventGraph(catalog), []);
39
+ const whole = useMemo(
40
+ () => eventGraph(narrowToEnvironments(catalog, environments)),
41
+ [environments],
42
+ );
33
43
  const graph = useMemo(
34
44
  () => filterEventGraph(whole, { contexts, statuses }),
35
45
  [whole, contexts, statuses],
@@ -61,7 +71,8 @@ export function GraphPage() {
61
71
  // pills with no line between them reads as a rendering failure rather than
62
72
  // as an answer. Either the filters left no event - the common case, and the
63
73
  // reader wants the way back - or the catalog has none yet.
64
- const filtered = contexts.size > 0 || statuses.size > 0;
74
+ const filtered =
75
+ contexts.size > 0 || statuses.size > 0 || environments.size > 0;
65
76
  const nothing = graph.events.length === 0;
66
77
 
67
78
  const toggle = <T,>(set: Set<T>, value: T): Set<T> => {
@@ -74,7 +85,7 @@ export function GraphPage() {
74
85
  // Filters and mode both replace the layout wholesale, so both refit. Nothing
75
86
  // else does: a selection or a focus leaves the viewport where the reader put
76
87
  // it.
77
- const fitKey = `${[...contexts].sort().join(",")}|${[...statuses].sort().join(",")}|${mode}`;
88
+ const fitKey = `${[...contexts].sort().join(",")}|${[...statuses].sort().join(",")}|${[...environments].sort().join(",")}|${mode}`;
78
89
 
79
90
  return (
80
91
  <div className="flex h-full flex-col">
@@ -123,6 +134,32 @@ export function GraphPage() {
123
134
  })}
124
135
  </div>
125
136
 
137
+ {environmentChips.length > 0 ? (
138
+ <div className="seg" role="group" aria-label="Filter by environment">
139
+ {environmentChips.map((env) => {
140
+ const on = environments.has(env);
141
+ return (
142
+ <button
143
+ key={env}
144
+ type="button"
145
+ onClick={() => setEnvironments((prev) => toggle(prev, env))}
146
+ aria-pressed={on}
147
+ title={`only the services the deployer places in ${env}`}
148
+ className="flex items-center gap-1.5"
149
+ style={{
150
+ color: on ? "var(--accent)" : "var(--fg-muted)",
151
+ background: on
152
+ ? "color-mix(in srgb, var(--accent) 12%, transparent)"
153
+ : undefined,
154
+ }}
155
+ >
156
+ {env}
157
+ </button>
158
+ );
159
+ })}
160
+ </div>
161
+ ) : null}
162
+
126
163
  <div className="mono ml-auto flex flex-wrap items-center gap-3">
127
164
  {/* The legend IS the filter. Three swatches that explain the three
128
165
  dashes and three buttons that turn them off are the same three