@shortlink-org/portolan 0.2.4 → 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 (291) hide show
  1. package/README.md +174 -6
  2. package/catalog/enum_test.go +46 -0
  3. package/catalog/evidence_test.go +35 -0
  4. package/catalog/model.go +1213 -0
  5. package/catalog/roundtrip_test.go +210 -0
  6. package/catalog/via_test.go +38 -0
  7. package/cli/init.test.mjs +6 -1
  8. package/cli/portolan.mjs +14 -1
  9. package/cli/portolan.test.mjs +49 -0
  10. package/go.mod +21 -0
  11. package/go.sum +34 -0
  12. package/internal/gocall/README.md +19 -0
  13. package/internal/gocall/analyze.go +189 -0
  14. package/internal/gocall/analyze_test.go +107 -0
  15. package/internal/gohttp/analyze.go +2562 -0
  16. package/internal/gohttp/destination.go +373 -0
  17. package/internal/gohttp/endpoints.go +1067 -0
  18. package/internal/gohttp/roots.go +320 -0
  19. package/internal/gohttp/typed.go +96 -0
  20. package/internal/goscan/constants.go +85 -0
  21. package/internal/goscan/goscan_test.go +227 -0
  22. package/internal/goscan/index.go +629 -0
  23. package/internal/goscan/index_test.go +66 -0
  24. package/internal/goscan/names.go +52 -0
  25. package/internal/goscan/parse_test.go +11 -0
  26. package/internal/goscan/source.go +37 -0
  27. package/internal/goscan/tree.go +284 -0
  28. package/internal/goscan/types.go +99 -0
  29. package/internal/wsdl/ids.go +127 -0
  30. package/internal/wsdl/ids_test.go +21 -0
  31. package/internal/wsdl/model.go +70 -0
  32. package/internal/wsdl/parse.go +949 -0
  33. package/internal/wsdl/parse_test.go +170 -0
  34. package/package.json +28 -15
  35. package/plugin/describe.go +156 -0
  36. package/plugin/describe_test.go +114 -0
  37. package/plugin/protocol.go +141 -0
  38. package/plugin/schematest/schematest.go +126 -0
  39. package/plugins/README.md +404 -50
  40. package/plugins/cmd/portolan-http-clients/main.go +19 -0
  41. package/plugins/extract-argocd/options.schema.json +44 -0
  42. package/plugins/extract-celery/extract.py +0 -2
  43. package/plugins/extract-celery/extract_test.py +1 -1
  44. package/plugins/extract-celery/main.py +1 -0
  45. package/plugins/extract-csharp-ddd/README.md +213 -0
  46. package/plugins/extract-csharp-ddd/options.schema.json +55 -0
  47. package/plugins/extract-django/README.md +52 -19
  48. package/plugins/extract-django/choices.py +47 -0
  49. package/plugins/extract-django/domain.py +30 -18
  50. package/plugins/extract-django/extract.py +21 -7
  51. package/plugins/extract-django/extract_test.py +68 -2
  52. package/plugins/extract-django/lifecycle.py +4 -28
  53. package/plugins/extract-django/main.py +1 -0
  54. package/plugins/extract-django/operations.py +1 -1
  55. package/plugins/extract-django/routing_test.py +109 -1
  56. package/plugins/extract-django/rules.py +154 -0
  57. package/plugins/extract-django/rules_test.py +158 -0
  58. package/plugins/extract-django/store.py +1 -1
  59. package/plugins/extract-django/transport.py +101 -55
  60. package/plugins/extract-django/verbs.py +241 -0
  61. package/plugins/extract-go/README.md +47 -0
  62. package/plugins/extract-go-sqs/options.schema.json +21 -0
  63. package/plugins/extract-http-clients/describe.go +20 -0
  64. package/plugins/extract-http-clients/describe_test.go +11 -0
  65. package/plugins/extract-http-clients/extract.go +740 -0
  66. package/plugins/extract-http-clients/extract_test.go +1561 -0
  67. package/plugins/extract-http-clients/main.go +41 -0
  68. package/plugins/extract-java/build/org/portolan/extract/Extract.class +0 -0
  69. package/plugins/extract-java/build/org/portolan/extract/Main.class +0 -0
  70. package/plugins/extract-java/build/org/portolan/extract/Protocol$Builder.class +0 -0
  71. package/plugins/extract-java/build/org/portolan/extract/Protocol$Input.class +0 -0
  72. package/plugins/extract-java/build/org/portolan/extract/Protocol$Options.class +0 -0
  73. package/plugins/extract-k8s/options.schema.json +32 -0
  74. package/plugins/extract-laravel/Cargo.lock +962 -0
  75. package/plugins/extract-laravel/Cargo.toml +20 -0
  76. package/plugins/extract-laravel/README.md +200 -0
  77. package/plugins/extract-laravel/options.schema.json +68 -0
  78. package/plugins/extract-laravel/rustfmt.toml +1 -0
  79. package/plugins/extract-php-ddd/Cargo.lock +972 -0
  80. package/plugins/extract-php-ddd/Cargo.toml +22 -0
  81. package/plugins/extract-php-ddd/README.md +141 -0
  82. package/plugins/extract-php-ddd/options.schema.json +50 -0
  83. package/plugins/extract-php-ddd/rustfmt.toml +1 -0
  84. package/plugins/extract-python-kafka/extract.py +0 -2
  85. package/plugins/extract-python-kafka/extract_test.py +1 -1
  86. package/plugins/extract-python-kafka/main.py +1 -0
  87. package/plugins/extract-terraform/options.schema.json +26 -0
  88. package/plugins/extract-ts/extract.test.ts +2 -2
  89. package/plugins/extract-ts/extract.ts +4 -5
  90. package/plugins/extract-ts/graphql.test.ts +1 -1
  91. package/plugins/extract-ts/main.ts +1 -0
  92. package/plugins/openapi/ids.go +261 -0
  93. package/plugins/openapi/ids_test.go +98 -0
  94. package/plugins/phpscan/Cargo.lock +953 -0
  95. package/plugins/phpscan/Cargo.toml +19 -0
  96. package/plugins/phpscan/rustfmt.toml +1 -0
  97. package/plugins/portolan-go.wasm +0 -0
  98. package/plugins/pyplugin/catalog.py +24 -1
  99. package/plugins/pyplugin/protocol.py +1 -5
  100. package/plugins/verify-otel/options.schema.json +12 -0
  101. package/portolan.json +132 -4
  102. package/rules/builtin.json +218 -0
  103. package/schema/portolan.schema.json +905 -4
  104. package/scripts/README.md +21 -13
  105. package/scripts/catalog-sources.mjs +6 -0
  106. package/scripts/delivery-presets.mjs +57 -14
  107. package/scripts/diff.mjs +5 -1
  108. package/scripts/django-aggregates.test.mjs +58 -0
  109. package/scripts/gen-likec4.mjs +150 -17
  110. package/scripts/gen-likec4.test.mjs +96 -0
  111. package/scripts/gen.mjs +148 -118
  112. package/scripts/gitops-example.test.mjs +108 -0
  113. package/scripts/go-discovery.test.mjs +30 -0
  114. package/scripts/history.mjs +186 -3
  115. package/scripts/history.test.mjs +1 -1
  116. package/scripts/host-plugins/fetch-argocd.mjs +338 -0
  117. package/scripts/host-plugins/fetch-argocd.options.json +49 -0
  118. package/scripts/host-plugins/fetch-argocd.test.mjs +274 -0
  119. package/scripts/host-plugins/fetch-bsr.mjs +1 -0
  120. package/scripts/host-plugins/fetch-csr.mjs +1 -0
  121. package/scripts/host-plugins/fetch-git.mjs +78 -21
  122. package/scripts/host-plugins/fetch-git.test.mjs +62 -8
  123. package/scripts/host-plugins/fetch-k8s.mjs +263 -0
  124. package/scripts/host-plugins/fetch-k8s.options.json +50 -0
  125. package/scripts/host-plugins/fetch-k8s.test.mjs +259 -0
  126. package/scripts/host-plugins/k8s-topology.mjs +183 -0
  127. package/scripts/json-format.mjs +192 -0
  128. package/scripts/json-format.test.mjs +97 -0
  129. package/scripts/local-api.mjs +287 -12
  130. package/scripts/local-api.test.mjs +150 -5
  131. package/scripts/local-discovery.mjs +94 -9
  132. package/scripts/manifest.mjs +14 -3
  133. package/scripts/manifest.test.mjs +24 -0
  134. package/scripts/output-diff.mjs +94 -0
  135. package/scripts/output-diff.test.mjs +36 -0
  136. package/scripts/package-smoke.mjs +62 -4
  137. package/scripts/plugin-host.mjs +45 -3
  138. package/scripts/plugin-host.test.mjs +9 -0
  139. package/scripts/plugin-wasm-worker.mjs +4 -1
  140. package/scripts/plugins-fresh.mjs +97 -0
  141. package/scripts/plugins-fresh.test.mjs +64 -0
  142. package/scripts/provenance.mjs +72 -0
  143. package/scripts/provenance.test.mjs +149 -0
  144. package/scripts/run-builtin.mjs +62 -7
  145. package/scripts/schema.mjs +167 -19
  146. package/scripts/trace-trials.mjs +176 -0
  147. package/scripts/trace-trials.test.mjs +142 -0
  148. package/scripts/warning-policy.mjs +167 -0
  149. package/scripts/warning-policy.test.mjs +93 -0
  150. package/src/app/Breadcrumbs.test.ts +3 -0
  151. package/src/app/Breadcrumbs.tsx +3 -0
  152. package/src/app/CatalogApp.tsx +2 -0
  153. package/src/app/Sidebar.tsx +3 -3
  154. package/src/app/SidebarFooter.tsx +20 -4
  155. package/src/catalog-docs.test.ts +64 -0
  156. package/src/catalog-docs.ts +35 -0
  157. package/src/catalog-error.test.ts +15 -0
  158. package/src/catalog-index.ts +25 -0
  159. package/src/catalog-model.ts +293 -5
  160. package/src/catalog-validation.ts +113 -2
  161. package/src/catalog.test.ts +40 -1
  162. package/src/chat/Starter.tsx +5 -11
  163. package/src/chat/tools.test.ts +27 -0
  164. package/src/chat/tools.ts +5 -9
  165. package/src/components/CatalogStamp.tsx +10 -8
  166. package/src/components/ChannelRows.test.tsx +76 -1
  167. package/src/components/ChannelRows.tsx +223 -60
  168. package/src/components/DeploymentRows.tsx +131 -0
  169. package/src/components/DocsLinks.test.tsx +27 -0
  170. package/src/components/DocsLinks.tsx +56 -0
  171. package/src/components/FieldTree.tsx +5 -2
  172. package/src/components/HTTPDestinationEvidence.test.tsx +23 -0
  173. package/src/components/HTTPDestinationEvidence.tsx +31 -0
  174. package/src/components/Integrations.tsx +1 -1
  175. package/src/components/MachineDocs.tsx +6 -5
  176. package/src/components/MethodRows.tsx +9 -2
  177. package/src/components/PluginIcon.tsx +77 -0
  178. package/src/components/ProblemRow.tsx +173 -153
  179. package/src/components/RelationEvidence.test.tsx +14 -0
  180. package/src/components/RelationEvidence.tsx +53 -0
  181. package/src/components/RuleMarks.tsx +22 -0
  182. package/src/components/ShapeRows.tsx +24 -20
  183. package/src/data.ts +46 -7
  184. package/src/enrich.test.ts +459 -4
  185. package/src/enrich.ts +308 -7
  186. package/src/er/ErCanvas.tsx +218 -12
  187. package/src/er/GroupNode.tsx +57 -0
  188. package/src/er/StoreHeader.tsx +1 -0
  189. package/src/er/layout.test.ts +85 -2
  190. package/src/er/layout.ts +140 -5
  191. package/src/er/spec.test.ts +17 -0
  192. package/src/er/spec.ts +23 -10
  193. package/src/flow/Recordings.test.tsx +52 -0
  194. package/src/flow/Recordings.tsx +236 -0
  195. package/src/flow/StepDetail.tsx +59 -0
  196. package/src/flow/TraceTrial.tsx +419 -0
  197. package/src/flow/evidence.test.ts +16 -0
  198. package/src/flow/evidence.ts +34 -0
  199. package/src/flow/examples.test.ts +33 -0
  200. package/src/flow/examples.ts +37 -0
  201. package/src/flow/outline.test.ts +28 -0
  202. package/src/flow/outline.ts +7 -2
  203. package/src/flow/trace-trial-resume.test.ts +42 -0
  204. package/src/flow/trace-trial-resume.ts +74 -0
  205. package/src/graph/elk.ts +78 -0
  206. package/src/index.css +68 -0
  207. package/src/landing/DraggableReveal.tsx +3 -2
  208. package/src/landing/EvidencePipeline.tsx +105 -0
  209. package/src/landing/LandingPage.tsx +17 -70
  210. package/src/landing/ProductTour.tsx +6 -6
  211. package/src/lib/all-problems.ts +27 -17
  212. package/src/lib/catalog-diff.ts +1 -1
  213. package/src/lib/centrality.test.ts +251 -0
  214. package/src/lib/centrality.ts +232 -0
  215. package/src/lib/confluence.test.ts +37 -0
  216. package/src/lib/confluence.ts +41 -0
  217. package/src/lib/context-color.ts +1 -1
  218. package/src/lib/deployment-drift.ts +22 -0
  219. package/src/lib/derive.ts +43 -69
  220. package/src/lib/django-aggregates.d.mts +9 -0
  221. package/src/lib/django-aggregates.mjs +36 -0
  222. package/src/lib/django-aggregates.test.ts +29 -0
  223. package/src/lib/django-aggregates.ts +5 -0
  224. package/src/lib/environments.test.ts +83 -0
  225. package/src/lib/environments.ts +57 -0
  226. package/src/lib/integration-url.test.ts +30 -0
  227. package/src/lib/integration-url.ts +63 -0
  228. package/src/lib/kafka-ui.ts +3 -45
  229. package/src/lib/local-api.ts +116 -4
  230. package/src/lib/notion.ts +13 -0
  231. package/src/lib/plugin-index.json +2765 -0
  232. package/src/lib/plugins.test.ts +68 -0
  233. package/src/lib/plugins.ts +264 -0
  234. package/src/lib/problem-flows.test.ts +61 -0
  235. package/src/lib/problem-flows.ts +78 -0
  236. package/src/lib/problem-rules-cel.d.mts +43 -0
  237. package/src/lib/problem-rules-cel.mjs +407 -0
  238. package/src/lib/problem-rules.test.ts +287 -0
  239. package/src/lib/problem-rules.ts +271 -0
  240. package/src/lib/problem-subjects.ts +737 -0
  241. package/src/lib/rule-entries.ts +39 -0
  242. package/src/lib/{data-problems.test.ts → rules-data.test.ts} +18 -16
  243. package/src/lib/rules-deploy.test.ts +133 -0
  244. package/src/lib/{problems.test.ts → rules-edges.test.ts} +8 -3
  245. package/src/lib/{proto-problems.test.ts → rules-proto.test.ts} +9 -25
  246. package/src/lib/{wire-problems.test.ts → rules-wire.test.ts} +13 -11
  247. package/src/lib/rules.test.ts +51 -0
  248. package/src/lib/rules.ts +86 -0
  249. package/src/lib/setup-info.test.ts +17 -0
  250. package/src/lib/setup-info.ts +58 -0
  251. package/src/lib/shape.test.ts +32 -0
  252. package/src/lib/shape.ts +30 -6
  253. package/src/lib/tech.ts +16 -0
  254. package/src/lib/trace-project.test.ts +34 -0
  255. package/src/lib/trace-project.ts +50 -0
  256. package/src/lib/use-problems.ts +23 -0
  257. package/src/lib/warnings.test.ts +63 -0
  258. package/src/lib/warnings.ts +260 -0
  259. package/src/likec4/ids.test.ts +6 -2
  260. package/src/likec4/ids.ts +43 -0
  261. package/src/main.tsx +23 -0
  262. package/src/map/ContextMapGraph.tsx +76 -32
  263. package/src/merge-deployments.test.ts +127 -0
  264. package/src/merge.test.ts +82 -0
  265. package/src/merge.ts +189 -18
  266. package/src/pages/AggregatePage.tsx +65 -14
  267. package/src/pages/ContextMap.tsx +45 -3
  268. package/src/pages/ContextPage.tsx +8 -5
  269. package/src/pages/EventPage.tsx +15 -5
  270. package/src/pages/FlowDetail.tsx +23 -2
  271. package/src/pages/GraphPage.tsx +40 -3
  272. package/src/pages/Overview.tsx +152 -12
  273. package/src/pages/PluginIndex.tsx +190 -0
  274. package/src/pages/Problems.tsx +396 -128
  275. package/src/pages/ServicePage.tsx +62 -5
  276. package/src/pages/Settings.tsx +217 -43
  277. package/src/pages/settings/AboutSettings.tsx +8 -1
  278. package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
  279. package/src/pages/settings/IntegrationsSettings.tsx +63 -17
  280. package/src/pages/settings/RecordingSettings.tsx +138 -0
  281. package/src/pages/settings/RulesSettings.tsx +825 -0
  282. package/src/routes.test.ts +9 -0
  283. package/src/routes.ts +22 -1
  284. package/src/selection/DetailPanel.tsx +15 -0
  285. package/src/virtual-provenance.d.ts +11 -0
  286. package/vite.config.ts +5 -0
  287. package/scripts/vendor-lock.mjs +0 -58
  288. package/scripts/vendor-lock.test.mjs +0 -69
  289. package/src/lib/data-problems.ts +0 -314
  290. package/src/lib/proto-problems.ts +0 -237
  291. package/src/lib/wire-problems.ts +0 -342
@@ -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";
@@ -37,6 +38,7 @@ export function ContextPage() {
37
38
  );
38
39
  const neutral = groupKind(context) !== "bounded-context";
39
40
  const showDomain = !neutral || aggregates.length > 0;
41
+ const hasModelGroups = aggregates.some(({ aggregate }) => aggregate.kind === "model-group");
40
42
  const toc: TocItem[] = [
41
43
  { id: CONTEXT_ANCHOR.services, label: neutral ? "Components" : "Services" },
42
44
  ...(showDomain
@@ -86,13 +88,14 @@ export function ContextPage() {
86
88
  {showDomain ? (
87
89
  <>
88
90
  <a href={`#${CONTEXT_ANCHOR.aggregates}`} className="rounded-control hover:text-ink">
89
- <span className="tnum">{stats.aggregates}</span>{" "}{plural(stats.aggregates, "aggregate")}
91
+ <span className="tnum">{stats.aggregates}</span>{" "}{plural(stats.aggregates, hasModelGroups ? "group" : "aggregate")}
90
92
  </a>
91
93
  <a href={`#${CONTEXT_ANCHOR.events}`} className="rounded-control hover:text-ink">
92
94
  <span className="tnum">{stats.events}</span>{" "}{plural(stats.events, "event")}
93
95
  </a>
94
96
  </>
95
97
  ) : null}
98
+ <DocsLinks name={context.name} />
96
99
  </div>
97
100
  {/* Who to ask about the whole domain, folded up from what its
98
101
  services' CODEOWNERS say - the context itself names nobody. The
@@ -173,7 +176,7 @@ export function ContextPage() {
173
176
  <div className="mono mt-4 flex flex-wrap gap-x-4 text-muted">
174
177
  {service.aggregates.length > 0 || componentKind(service) === "service" ? (
175
178
  <Link to={`${paths.service(context.id, service.slug)}#svc-aggregates`} className="rounded-control hover:text-ink">
176
- <span className="tnum">{service.aggregates.length}</span>{" "}{plural(service.aggregates.length, "aggregate")}
179
+ <span className="tnum">{service.aggregates.length}</span>{" "}{plural(service.aggregates.length, service.aggregates.some((aggregate) => aggregate.kind === "model-group") ? "group" : "aggregate")}
177
180
  </Link>
178
181
  ) : null}
179
182
  <Link
@@ -234,11 +237,11 @@ export function ContextPage() {
234
237
  anchor={CONTEXT_ANCHOR.aggregates}
235
238
  right={
236
239
  <span>
237
- every aggregate this domain owns, whichever service holds it
240
+ {hasModelGroups ? "models grouped by their source application or aggregate" : "every aggregate this domain owns, whichever service holds it"}
238
241
  </span>
239
242
  }
240
243
  >
241
- Aggregates
244
+ {hasModelGroups ? "Aggregates and model groups" : "Aggregates"}
242
245
  </SectionTitle>
243
246
  {aggregates.length === 0 ? (
244
247
  <Empty>this domain owns nothing yet — only services</Empty>
@@ -264,7 +267,7 @@ export function ContextPage() {
264
267
  {aggregate.name}
265
268
  </Link>
266
269
  <span className="meta">{service.slug}</span>
267
- <span className="meta">root {aggregate.root}</span>
270
+ <span className="meta">{aggregate.kind === "model-group" ? "model group · boundary not specified" : `root ${aggregate.root}`}</span>
268
271
  <RowActions
269
272
  copy={aggregate.id}
270
273
  reveal={aggregate.id}
@@ -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
@@ -2,18 +2,25 @@ import { useDocumentTitle } from "../app/title";
2
2
  import { useState } from "react";
3
3
  import { Link } from "react-router";
4
4
  import { AlertTriangle } from "lucide-react";
5
- import { activeCatalogProfile, CATALOG_PATH, catalog, index } from "../data";
5
+ import { activeCatalogProfile, CATALOG_PATH, catalog } from "../data";
6
6
  import { contextOwners, contextStats, widestFlows } from "../lib/derive";
7
7
  import type { ContextOwner } from "../lib/derive";
8
- import { allProblems } from "../lib/all-problems";
8
+ import { bridges } from "../lib/centrality";
9
+ import { useProblems } from "../lib/use-problems";
9
10
  import { ctxStyle } from "../lib/context-color";
10
11
  import { middleTruncate, plural } from "../lib/format";
11
12
  import { useCountUp, staggerStyle } from "../lib/motion";
12
13
  import { usePhone } from "../app/responsive";
13
- import { CONTEXT_ANCHOR, OVERVIEW_ANCHOR, paths } from "../routes";
14
+ import { CONTEXT_ANCHOR, OVERVIEW_ANCHOR, paths, servicePath } from "../routes";
14
15
  import { Blank, SectionTitle } from "../components/PageHeader";
15
16
  import { C4View } from "../likec4/C4View";
16
- import { profileContainersViewId, profileLandscapeViewId } from "../likec4/ids";
17
+ import {
18
+ deploymentViewId,
19
+ environmentsOf,
20
+ profileContainersViewId,
21
+ profileLandscapeViewId,
22
+ } from "../likec4/ids";
23
+ import { servicesDeployedIn } from "../lib/environments";
17
24
  import { LevelSwitch } from "../likec4/levels";
18
25
  import type { C4Level } from "../likec4/levels";
19
26
  import { CatalogStamp } from "../components/CatalogStamp";
@@ -28,6 +35,9 @@ import {
28
35
  /** How many problems the overview lists before pointing at the page that lists them all. */
29
36
  const OVERVIEW_PROBLEMS = 4;
30
37
 
38
+ /** How many bridges the overview lists. Past the first few the shares are small and the list is the graph. */
39
+ const OVERVIEW_BRIDGES = 5;
40
+
31
41
  /**
32
42
  * A measurement that arrives rather than appears - 200ms, linear, once - and
33
43
  * then takes you to what it measured. A number that counts something the app
@@ -96,12 +106,23 @@ export function Overview() {
96
106
  useDocumentTitle("Overview");
97
107
  const phone = usePhone();
98
108
  const [level, setLevel] = useState<C4Level>(1);
109
+ // Where the estate runs, one environment at a time. Production first when
110
+ // there is one by that name: it is the environment a reader means by
111
+ // "deployed" until they say otherwise.
112
+ const environments = environmentsOf(catalog);
113
+ const [environment, setEnvironment] = useState<string>(
114
+ () =>
115
+ environments.find((e) => /^prod/i.test(e)) ?? environments[0] ?? "",
116
+ );
117
+ const deployedCount = (env: string) =>
118
+ servicesDeployedIn(catalog, new Set([env])).size;
99
119
  const reach = widestFlows(catalog);
120
+ const roads = bridges(catalog);
100
121
  const services = catalog.contexts.reduce(
101
122
  (count, context) => count + context.services.length,
102
123
  0,
103
124
  );
104
- const issues = allProblems(catalog, index);
125
+ const issues = useProblems();
105
126
  const issueCount = issues.length;
106
127
  const errorCount = issues.filter((p) => p.severity === "error").length;
107
128
 
@@ -189,6 +210,44 @@ export function Overview() {
189
210
  />
190
211
  </section>
191
212
 
213
+ {/* Where the estate runs, from the deployer's snapshot: the services
214
+ standing in one environment, in their clusters and namespaces, and
215
+ between them what the model declares. Absent entirely when no
216
+ snapshot places anything - a heading over an empty canvas would
217
+ claim the estate runs nowhere, and nobody made that claim. */}
218
+ {environments.length > 0 ? (
219
+ <section id={OVERVIEW_ANCHOR.deployed} className="mt-section">
220
+ <SectionTitle
221
+ anchor={OVERVIEW_ANCHOR.deployed}
222
+ right={
223
+ <div className="seg" role="group" aria-label="Environment">
224
+ {environments.map((env) => (
225
+ <button
226
+ key={env}
227
+ type="button"
228
+ onClick={() => setEnvironment(env)}
229
+ aria-pressed={env === environment}
230
+ className="flex items-center gap-1.5"
231
+ title={`${plural(deployedCount(env), "service")} of the estate in ${env}`}
232
+ >
233
+ {env}
234
+ <span className="tnum">{deployedCount(env)}</span>
235
+ </button>
236
+ ))}
237
+ </div>
238
+ }
239
+ >
240
+ Deployed
241
+ </SectionTitle>
242
+ <C4View
243
+ viewId={deploymentViewId(environment)}
244
+ height={phone ? 320 : 480}
245
+ controls={phone}
246
+ fitViewPadding={{ x: 8, y: 8 }}
247
+ />
248
+ </section>
249
+ ) : null}
250
+
192
251
  <section id={OVERVIEW_ANCHOR.contexts} className="mt-section">
193
252
  <SectionTitle anchor={OVERVIEW_ANCHOR.contexts}>Contexts</SectionTitle>
194
253
  <div
@@ -281,6 +340,85 @@ export function Overview() {
281
340
  </div>
282
341
  </section>
283
342
 
343
+ {/* The services the estate's paths run through: the share of shortest
344
+ paths between every other pair of services that pass through this
345
+ one, and the pairs of contexts it is the main road between. Degree
346
+ is not shown - a hub is visible on the graph, a bridge is not: the
347
+ service with three neighbours that is the only way from auth into
348
+ payments looks like nothing there. Nothing is shown for an estate
349
+ with no such service; a heading over "none" is a dead end. */}
350
+ {roads.length > 0 ? (
351
+ <section id={OVERVIEW_ANCHOR.bridges} className="mt-section">
352
+ <SectionTitle
353
+ anchor={OVERVIEW_ANCHOR.bridges}
354
+ right={
355
+ <span className="flex items-center gap-2">
356
+ {roads.length > OVERVIEW_BRIDGES ? (
357
+ <span className="section-aside">
358
+ {OVERVIEW_BRIDGES} of {roads.length}
359
+ </span>
360
+ ) : null}
361
+ <Link
362
+ to={paths.graph()}
363
+ className="rounded-control px-1 text-accent hover:underline"
364
+ >
365
+ the graph →
366
+ </Link>
367
+ </span>
368
+ }
369
+ >
370
+ Bridges
371
+ </SectionTitle>
372
+ <div className="flex flex-col gap-2" data-nav-list>
373
+ {roads.slice(0, OVERVIEW_BRIDGES).map((road, i) => {
374
+ const to = servicePath(road.id);
375
+ const percent = Math.round(road.betweenness * 100);
376
+ return (
377
+ <Link
378
+ key={road.id}
379
+ to={to ?? paths.graph()}
380
+ data-nav-item
381
+ className="row stagger-in flex-wrap"
382
+ style={staggerStyle(i)}
383
+ title={`${percent}% of the shortest paths between other services run through ${road.id}`}
384
+ >
385
+ {/* The share, as a bar the width of the number, so the
386
+ list reads as a ranking before any number is read. */}
387
+ <span className="flex w-16 shrink-0 items-center gap-1.5">
388
+ <span className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface">
389
+ <span
390
+ className="block h-full bg-accent"
391
+ style={{ width: `${Math.max(percent, 2)}%` }}
392
+ />
393
+ </span>
394
+ <span className="mono tnum w-8 text-right text-muted">
395
+ {percent}%
396
+ </span>
397
+ </span>
398
+ <span className="font-semibold" title={road.id}>
399
+ {road.id}
400
+ </span>
401
+ <span className="mono text-muted">between</span>
402
+ <div className="flex flex-wrap items-center gap-1">
403
+ {road.between.map((pair) => (
404
+ <span
405
+ key={`${pair.a}~${pair.b}`}
406
+ className="flex items-center gap-1"
407
+ title={`${Math.round(pair.share * 100)}% of the shortest paths from ${pair.a} to ${pair.b} run through ${road.id}`}
408
+ >
409
+ <ContextPill id={pair.a} />
410
+ <span className="text-muted">↔</span>
411
+ <ContextPill id={pair.b} />
412
+ </span>
413
+ ))}
414
+ </div>
415
+ </Link>
416
+ );
417
+ })}
418
+ </div>
419
+ </section>
420
+ ) : null}
421
+
284
422
  <section id={OVERVIEW_ANCHOR.flows} className="mt-section">
285
423
  <SectionTitle
286
424
  anchor={OVERVIEW_ANCHOR.flows}
@@ -381,13 +519,15 @@ export function Overview() {
381
519
  Problems
382
520
  </SectionTitle>
383
521
  <div className="flex max-w-table flex-col gap-2" data-nav-list>
384
- {issues.slice(0, OVERVIEW_PROBLEMS).map((problem, i) => (
385
- <ProblemRow
386
- key={`${problem.kind}:${problem.id}:${problem.peer}`}
387
- problem={problem}
388
- index={i}
389
- />
390
- ))}
522
+ <div className="divide-y divide-line overflow-hidden rounded-card border border-line shadow-xs">
523
+ {issues.slice(0, OVERVIEW_PROBLEMS).map((problem, i) => (
524
+ <ProblemRow
525
+ key={`${problem.rule}:${problem.id}:${problem.peer}`}
526
+ problem={problem}
527
+ index={i}
528
+ />
529
+ ))}
530
+ </div>
391
531
  {issueCount > OVERVIEW_PROBLEMS ? (
392
532
  <Link
393
533
  to={paths.problems()}
@@ -0,0 +1,190 @@
1
+ import { useDocumentTitle } from "../app/title";
2
+ import { useEffect, useState } from "react";
3
+ import { Link, useLocation } from "react-router";
4
+ import { ArrowUpRight, ShieldCheck, Terminal } from "lucide-react";
5
+ import { CatIllustration } from "../components/CatIllustration";
6
+ import { SectionTitle } from "../components/PageHeader";
7
+ import { PluginIcon } from "../components/PluginIcon";
8
+ import {
9
+ pluginsByCategory,
10
+ pluginIcon,
11
+ pluginLabel,
12
+ pluginSourceHref,
13
+ runtimeLabel,
14
+ } from "../lib/plugins";
15
+ import type { PluginEntry } from "../lib/plugins";
16
+ import { PRODUCT_REPOSITORY } from "../lib/product";
17
+ import { paths } from "../routes";
18
+
19
+ const PHASE_LABEL = { extract: "extract", verify: "verify", generate: "generate" } as const;
20
+
21
+ /** Every cat the site has drawn; any of them suits a page that is a list. */
22
+ const CAT_SCENES = ["about", "clear", "unchanged", "onboarding", "search", "trial"] as const;
23
+
24
+ /**
25
+ * Every plugin the package ships, grouped by what it reads or what it makes.
26
+ *
27
+ * Rendered from the index `npm run schema` writes, so this page says what the
28
+ * plugins say about themselves and nothing else: the summary each answers to
29
+ * `describe`, the category it sorts itself under, the options it can be told.
30
+ * The Settings page shows which of these a build ran; this one shows what a
31
+ * build could run.
32
+ */
33
+ export function PluginIndex() {
34
+ useDocumentTitle("Plugins");
35
+ const groups = pluginsByCategory();
36
+ const total = groups.reduce((n, group) => n + group.plugins.length, 0);
37
+ const { hash } = useLocation();
38
+ const target = hash.startsWith("#plugin-") ? hash.slice("#plugin-".length) : null;
39
+ // A different cat each visit, the same one for the visit: picked once, not
40
+ // on every render, or a hash change would swap it mid-scroll.
41
+ const [cat] = useState(() => CAT_SCENES[Math.floor(Math.random() * CAT_SCENES.length)] ?? "about");
42
+
43
+ // A link from Settings names one plugin. The card grid settles a frame after
44
+ // the route commits, so the scroll waits for it rather than landing where
45
+ // the card was in a layout that no longer exists.
46
+ useEffect(() => {
47
+ if (!hash) return;
48
+ const id = hash.slice(1);
49
+ const frame = requestAnimationFrame(() => {
50
+ document.getElementById(id)?.scrollIntoView({ block: "start" });
51
+ });
52
+ return () => cancelAnimationFrame(frame);
53
+ }, [hash]);
54
+
55
+ return (
56
+ <div className="h-full overflow-y-auto p-gutter">
57
+ {/* The intro is a paragraph wide; on a wide screen the rest of the row
58
+ was empty, and a cat is a better use of it than nothing. */}
59
+ <div className="grid gap-grid xl:grid-cols-[minmax(0,1fr)_auto] xl:items-center">
60
+ <div>
61
+ <div className="flex flex-wrap items-center gap-3">
62
+ <h1 className="text-lg font-semibold">Plugins</h1>
63
+ <span className="mono text-muted">{total} shipped</span>
64
+ </div>
65
+ <p className="mt-2 max-w-prose text-muted">
66
+ Every fact in the catalog was read by one of these. An extractor reads a
67
+ tree and answers with a fragment; a verifier checks the catalog against
68
+ evidence; a generator turns it into something else. Each describes
69
+ itself, and this page is that description. Which of them a build ran is
70
+ on the{" "}
71
+ <Link to={paths.settingsPipeline()} className="text-accent hover:underline">
72
+ pipeline settings
73
+ </Link>
74
+ ; how to write one is in the{" "}
75
+ <a
76
+ href={`${PRODUCT_REPOSITORY}/tree/main/plugins#readme`}
77
+ target="_blank"
78
+ rel="noreferrer"
79
+ className="text-accent hover:underline"
80
+ >
81
+ plugin protocol
82
+ </a>
83
+ .
84
+ </p>
85
+ </div>
86
+ <CatIllustration scene={cat} className="hidden xl:block h-[126px] w-[180px] justify-self-end" />
87
+ </div>
88
+
89
+ <nav aria-label="Categories" className="mt-4 flex flex-wrap gap-1.5">
90
+ {groups.map((group) => (
91
+ <a key={group.category} href={`#${group.category}`} className="chip inline-flex items-center gap-1.5 border-line-strong hover:border-accent hover:text-accent">
92
+ <PluginIcon icon={{ lucide: group.icon }} size={12} />
93
+ {group.title} <span className="text-muted/70">{group.plugins.length}</span>
94
+ </a>
95
+ ))}
96
+ </nav>
97
+
98
+ <div className="mt-section space-y-section">
99
+ {groups.map((group) => (
100
+ <section key={group.category} id={group.category} className="scroll-mt-4">
101
+ <SectionTitle anchor={group.category} right={<span className="mono text-muted">{group.plugins.length}</span>}>
102
+ <span className="inline-flex items-center gap-2">
103
+ <PluginIcon icon={{ lucide: group.icon }} className="text-muted" />
104
+ {group.title}
105
+ </span>
106
+ </SectionTitle>
107
+ <p className="mb-3 max-w-prose text-muted">{group.what}</p>
108
+ <div className="grid gap-grid grid-cols-[repeat(auto-fill,minmax(340px,1fr))]">
109
+ {group.plugins.map((entry) => (
110
+ <PluginCard key={entry.name} entry={entry} highlighted={entry.name === target} />
111
+ ))}
112
+ </div>
113
+ </section>
114
+ ))}
115
+ </div>
116
+ </div>
117
+ );
118
+ }
119
+
120
+ function PluginCard({ entry, highlighted }: { entry: PluginEntry; highlighted: boolean }) {
121
+ const options = Object.entries(entry.options.properties ?? {});
122
+ const required = new Set(entry.options.required ?? []);
123
+ const RuntimeIcon = entry.runtime === "wasm" ? ShieldCheck : Terminal;
124
+ const runtimeTone = entry.runtime === "wasm" ? "text-verified" : "text-declared";
125
+
126
+ return (
127
+ <article
128
+ id={`plugin-${entry.name}`}
129
+ // The margin clears the section head, which stays stuck to the top
130
+ // while its cards scroll under it.
131
+ className={`card card-static flex scroll-mt-14 flex-col gap-3 ${highlighted ? "border-accent" : ""}`}
132
+ >
133
+ <header className="flex flex-wrap items-center gap-x-2 gap-y-1">
134
+ <PluginIcon icon={pluginIcon(entry.name, entry.category)} size={16} className="text-ink" />
135
+ <h3 className="font-semibold text-ink">{pluginLabel(entry.name)}</h3>
136
+ <span className="mono text-muted" title="the name a manifest step uses">{entry.name}</span>
137
+ {entry.plugin !== entry.name ? (
138
+ <span className="mono text-faint" title="the plugin's own name">{entry.plugin}</span>
139
+ ) : null}
140
+ </header>
141
+
142
+ <p className="text-muted">{entry.summary}</p>
143
+
144
+ <div className="flex flex-wrap items-center gap-1.5">
145
+ {entry.phases.map((phase) => (
146
+ <span key={phase} className="chip border-line-strong">{PHASE_LABEL[phase]}</span>
147
+ ))}
148
+ <span className={`chip inline-flex items-center gap-1 border-line-strong ${runtimeTone}`}>
149
+ <RuntimeIcon size={12} aria-hidden /> {runtimeLabel(entry)}
150
+ </span>
151
+ {entry.needs?.map((need) => (
152
+ <span key={need} className="chip border-line-strong" title="asked of the host beyond the tree">
153
+ needs {need}
154
+ </span>
155
+ ))}
156
+ </div>
157
+
158
+ {options.length > 0 ? (
159
+ <details className="group">
160
+ <summary className="mono cursor-pointer list-none text-muted hover:text-ink">
161
+ {options.length} option{options.length === 1 ? "" : "s"}
162
+ <span className="ml-1 inline-block transition-transform group-open:rotate-90">›</span>
163
+ </summary>
164
+ <dl className="mt-2 space-y-2 border-l border-line pl-3">
165
+ {options.map(([key, option]) => (
166
+ <div key={key}>
167
+ <dt className="mono text-ink">
168
+ {key}
169
+ {required.has(key) ? <span className="ml-1 text-faint" title="required">*</span> : null}
170
+ </dt>
171
+ {option.description ? <dd className="text-muted">{option.description}</dd> : null}
172
+ </div>
173
+ ))}
174
+ </dl>
175
+ </details>
176
+ ) : (
177
+ <p className="mono text-faint">takes no options</p>
178
+ )}
179
+
180
+ <a
181
+ href={pluginSourceHref(entry)}
182
+ target="_blank"
183
+ rel="noreferrer"
184
+ className="mono mt-auto inline-flex items-center gap-1 text-accent hover:underline"
185
+ >
186
+ {entry.source} <ArrowUpRight size={12} aria-hidden />
187
+ </a>
188
+ </article>
189
+ );
190
+ }