@shortlink-org/portolan 0.2.3 → 0.3.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 (184) hide show
  1. package/README.md +23 -0
  2. package/catalog/enum_test.go +46 -0
  3. package/catalog/evidence_test.go +35 -0
  4. package/catalog/model.go +1066 -0
  5. package/catalog/roundtrip_test.go +203 -0
  6. package/catalog/via_test.go +38 -0
  7. package/cli/init.test.mjs +6 -1
  8. package/cli/portolan.mjs +8 -0
  9. package/cli/portolan.test.mjs +49 -0
  10. package/go.mod +14 -0
  11. package/go.sum +20 -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 +22 -10
  35. package/plugin/describe.go +118 -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 +114 -46
  40. package/plugins/cmd/portolan-http-clients/main.go +19 -0
  41. package/plugins/extract-celery/extract.py +0 -2
  42. package/plugins/extract-celery/extract_test.py +1 -1
  43. package/plugins/extract-django/README.md +39 -17
  44. package/plugins/extract-django/domain.py +28 -17
  45. package/plugins/extract-django/extract.py +21 -7
  46. package/plugins/extract-django/extract_test.py +55 -2
  47. package/plugins/extract-django/lifecycle.py +2 -0
  48. package/plugins/extract-django/operations.py +1 -1
  49. package/plugins/extract-django/routing_test.py +109 -1
  50. package/plugins/extract-django/store.py +1 -1
  51. package/plugins/extract-django/transport.py +101 -55
  52. package/plugins/extract-django/verbs.py +241 -0
  53. package/plugins/extract-go/README.md +47 -0
  54. package/plugins/extract-http-clients/describe.go +19 -0
  55. package/plugins/extract-http-clients/describe_test.go +11 -0
  56. package/plugins/extract-http-clients/extract.go +740 -0
  57. package/plugins/extract-http-clients/extract_test.go +1561 -0
  58. package/plugins/extract-http-clients/main.go +41 -0
  59. package/plugins/extract-java/build/org/portolan/extract/Extract.class +0 -0
  60. package/plugins/extract-java/build/org/portolan/extract/Protocol$Builder.class +0 -0
  61. package/plugins/extract-java/build/org/portolan/extract/Protocol$Input.class +0 -0
  62. package/plugins/extract-java/build/org/portolan/extract/Protocol$Options.class +0 -0
  63. package/plugins/extract-python-kafka/README.md +6 -0
  64. package/plugins/extract-python-kafka/extract.py +2 -4
  65. package/plugins/extract-python-kafka/extract_test.py +18 -2
  66. package/plugins/extract-ts/extract.test.ts +2 -2
  67. package/plugins/extract-ts/extract.ts +4 -5
  68. package/plugins/extract-ts/graphql.test.ts +1 -1
  69. package/plugins/openapi/ids.go +261 -0
  70. package/plugins/openapi/ids_test.go +98 -0
  71. package/plugins/portolan-go.wasm +0 -0
  72. package/plugins/pyplugin/catalog.py +12 -1
  73. package/plugins/pyplugin/kafka.py +74 -3
  74. package/plugins/pyplugin/protocol.py +1 -5
  75. package/portolan.json +3 -2
  76. package/schema/portolan.schema.json +34 -0
  77. package/scripts/README.md +18 -12
  78. package/scripts/catalog-sources.mjs +6 -0
  79. package/scripts/delivery-presets.mjs +21 -11
  80. package/scripts/diff.mjs +5 -1
  81. package/scripts/django-aggregates.test.mjs +58 -0
  82. package/scripts/gen-likec4.mjs +79 -21
  83. package/scripts/gen-likec4.test.mjs +25 -2
  84. package/scripts/gen.mjs +118 -115
  85. package/scripts/go-discovery.test.mjs +30 -0
  86. package/scripts/history.mjs +186 -3
  87. package/scripts/history.test.mjs +1 -1
  88. package/scripts/host-plugins/fetch-git.mjs +77 -21
  89. package/scripts/host-plugins/fetch-git.test.mjs +62 -8
  90. package/scripts/local-api.mjs +71 -4
  91. package/scripts/local-api.test.mjs +63 -4
  92. package/scripts/local-discovery.mjs +82 -9
  93. package/scripts/manifest.mjs +5 -3
  94. package/scripts/manifest.test.mjs +24 -0
  95. package/scripts/output-diff.mjs +94 -0
  96. package/scripts/output-diff.test.mjs +36 -0
  97. package/scripts/package-smoke.mjs +62 -4
  98. package/scripts/plugin-host.mjs +22 -2
  99. package/scripts/plugin-host.test.mjs +9 -0
  100. package/scripts/plugin-wasm-worker.mjs +4 -1
  101. package/scripts/provenance.mjs +72 -0
  102. package/scripts/provenance.test.mjs +149 -0
  103. package/scripts/run-builtin.mjs +39 -5
  104. package/scripts/schema.mjs +29 -0
  105. package/scripts/warning-policy.mjs +161 -0
  106. package/scripts/warning-policy.test.mjs +56 -0
  107. package/src/app/Breadcrumbs.test.ts +4 -0
  108. package/src/app/Breadcrumbs.tsx +1 -0
  109. package/src/app/Sidebar.tsx +3 -3
  110. package/src/catalog-docs.test.ts +64 -0
  111. package/src/catalog-docs.ts +35 -0
  112. package/src/catalog-error.test.ts +15 -0
  113. package/src/catalog-model.ts +70 -6
  114. package/src/catalog-stores.test.ts +17 -0
  115. package/src/catalog-validation.ts +52 -2
  116. package/src/catalog.test.ts +13 -2
  117. package/src/chat/Starter.tsx +5 -11
  118. package/src/chat/tools.test.ts +27 -0
  119. package/src/chat/tools.ts +5 -9
  120. package/src/components/CatalogStamp.tsx +10 -8
  121. package/src/components/ChannelRows.messagepack.test.tsx +28 -0
  122. package/src/components/ChannelRows.test.tsx +54 -0
  123. package/src/components/ChannelRows.tsx +57 -10
  124. package/src/components/HTTPDestinationEvidence.test.tsx +23 -0
  125. package/src/components/HTTPDestinationEvidence.tsx +31 -0
  126. package/src/components/Integrations.tsx +1 -1
  127. package/src/components/LifecycleDiagram.tsx +28 -12
  128. package/src/components/MachineDocs.tsx +6 -5
  129. package/src/components/MethodRows.tsx +9 -2
  130. package/src/components/ProblemRow.tsx +4 -0
  131. package/src/components/RelationEvidence.test.tsx +14 -0
  132. package/src/components/RelationEvidence.tsx +53 -0
  133. package/src/components/WhatLinksHere.tsx +6 -4
  134. package/src/data.ts +25 -7
  135. package/src/enrich.test.ts +336 -6
  136. package/src/enrich.ts +206 -3
  137. package/src/flow/StepDetail.tsx +104 -54
  138. package/src/flow/answers.test.ts +18 -1
  139. package/src/flow/answers.ts +37 -8
  140. package/src/flow/evidence.test.ts +16 -0
  141. package/src/flow/evidence.ts +34 -0
  142. package/src/index.css +44 -0
  143. package/src/landing/DraggableReveal.tsx +3 -2
  144. package/src/landing/EvidencePipeline.tsx +105 -0
  145. package/src/landing/LandingPage.tsx +2 -59
  146. package/src/lib/backlinks.test.ts +16 -1
  147. package/src/lib/backlinks.ts +20 -0
  148. package/src/lib/catalog-diff.test.ts +18 -0
  149. package/src/lib/catalog-diff.ts +20 -2
  150. package/src/lib/derive.ts +1 -0
  151. package/src/lib/django-aggregates.d.mts +9 -0
  152. package/src/lib/django-aggregates.mjs +36 -0
  153. package/src/lib/django-aggregates.test.ts +29 -0
  154. package/src/lib/django-aggregates.ts +5 -0
  155. package/src/lib/kafka-ui.test.ts +87 -0
  156. package/src/lib/kafka-ui.ts +105 -0
  157. package/src/lib/local-api.ts +20 -2
  158. package/src/lib/setup-info.test.ts +17 -0
  159. package/src/lib/setup-info.ts +58 -0
  160. package/src/lib/warnings.test.ts +54 -0
  161. package/src/lib/warnings.ts +260 -0
  162. package/src/lib/wire-problems.test.ts +21 -0
  163. package/src/lib/wire-problems.ts +62 -1
  164. package/src/likec4/FlowView.tsx +2 -6
  165. package/src/likec4/flow-edges.test.ts +64 -1
  166. package/src/likec4/flow-edges.ts +43 -7
  167. package/src/likec4/view-index.ts +8 -2
  168. package/src/map/ContextMapGraph.tsx +76 -32
  169. package/src/merge.test.ts +23 -0
  170. package/src/merge.ts +33 -10
  171. package/src/pages/AggregatePage.tsx +8 -7
  172. package/src/pages/CatalogFailure.tsx +2 -2
  173. package/src/pages/ContextPage.tsx +6 -5
  174. package/src/pages/ServicePage.tsx +4 -3
  175. package/src/pages/Settings.tsx +200 -44
  176. package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
  177. package/src/pages/settings/IntegrationsSettings.tsx +117 -0
  178. package/src/routes.test.ts +2 -0
  179. package/src/routes.ts +2 -1
  180. package/src/selection/DetailPanel.tsx +61 -1
  181. package/src/virtual-provenance.d.ts +11 -0
  182. package/vite.config.ts +5 -0
  183. package/scripts/vendor-lock.mjs +0 -58
  184. package/scripts/vendor-lock.test.mjs +0 -69
@@ -48,7 +48,7 @@ import {
48
48
  import type { Discovery, ProjectDraft, ProjectPlan, RunEvent } from "../lib/local-api";
49
49
  import { useQuery, useQueryClient } from "@tanstack/react-query";
50
50
  import { localKeys, localStatusQuery } from "../lib/queries";
51
- import { sourceHref, treeHref } from "../lib/source-link";
51
+ import { bare, sourceHref, treeHref } from "../lib/source-link";
52
52
  import { paths } from "../routes";
53
53
  import { CapabilityEmpty, Empty, SectionTitle } from "../components/PageHeader";
54
54
  import { Modal } from "../components/Overlay";
@@ -56,8 +56,14 @@ 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";
60
+ import { DjangoAggregateChoices } from "./settings/DjangoAggregateChoices";
61
+ import { djangoAggregateCandidates, djangoAggregateMessage } from "../lib/django-aggregates";
59
62
  import { CatEmptyState, CatIllustration } from "../components/CatIllustration";
60
63
  import { CommitLink } from "../components/CommitLink";
64
+ import { groupDiagnostics, warningDiagnostic } from "../lib/warnings";
65
+ import type { WarningDiagnostic, WarningSeverity } from "../lib/warnings";
66
+ import { AnimatePresence, m, rise } from "../lib/motion";
61
67
 
62
68
  type Health = "healthy" | "changed" | "failed" | "unchecked";
63
69
  type ProjectSource = ProjectDraft["source"];
@@ -260,44 +266,89 @@ function PipelineSteps({ steps }: { steps: SetupRunStep[] }) {
260
266
  if (steps.length === 0) {
261
267
  return <p className="mono text-muted">No result recorded for these steps.</p>;
262
268
  }
269
+ const warnings = steps.flatMap((step) => step.warnings.map((message) => ({ plugin: step.plugin, message })));
270
+ const diagnostics = steps.flatMap((step) => step.diagnostics.length
271
+ ? step.diagnostics
272
+ : step.warnings.map((message) => warningDiagnostic({ plugin: step.plugin, message })));
263
273
  return (
264
- <div className="divide-y divide-line rounded-control border border-line">
265
- {steps.map((step) => (
266
- <div key={step.ordinal} className="px-3 py-2">
267
- <div className="grid gap-2 sm:grid-cols-[5rem_minmax(7rem,1fr)_auto_auto] sm:items-center">
268
- <span className="mono text-muted">{step.phase}</span>
269
- <span className="mono truncate text-ink" title={step.plugin}>{step.plugin}</span>
270
- <span className="mono text-muted">{step.fileCount} {plural(step.fileCount, "file")}</span>
271
- <div className="flex items-center justify-between gap-2 sm:justify-end">
272
- <span className="mono text-muted">{duration(step.durationMs)}</span>
273
- <StepStatus status={step.status} />
274
+ <div className="space-y-2">
275
+ <div className="divide-y divide-line rounded-control border border-line">
276
+ {steps.map((step) => (
277
+ <div key={step.ordinal} className="px-3 py-2">
278
+ <div className="grid gap-2 sm:grid-cols-[5rem_minmax(7rem,1fr)_auto_auto] sm:items-center">
279
+ <span className="mono text-muted">{step.phase}</span>
280
+ <span className="mono truncate text-ink" title={step.plugin}>{step.plugin}</span>
281
+ <span className="mono text-muted">{step.fileCount} {plural(step.fileCount, "file")}</span>
282
+ <div className="flex items-center justify-between gap-2 sm:justify-end">
283
+ <span className="mono text-muted">{duration(step.durationMs)}</span>
284
+ <StepStatus status={step.status} />
285
+ </div>
274
286
  </div>
275
287
  </div>
276
- <StepWarnings warnings={step.warnings} />
277
- </div>
278
- ))}
288
+ ))}
289
+ </div>
290
+ {warnings.length ? <details open={warnings.length <= 5}>
291
+ <summary className="mono cursor-pointer text-muted">{warnings.length} {plural(warnings.length, "warning")}</summary>
292
+ <div className="mt-2"><WarningPanel diagnostics={diagnostics} compact /></div>
293
+ </details> : null}
279
294
  </div>
280
295
  );
281
296
  }
282
297
 
283
- // What a plugin could not read, in its own words. For a tree the extractor
284
- // only half understood this is the list of what to fix, so it is shown where
285
- // the step is rather than left on the terminal it scrolled off.
286
- function StepWarnings({ warnings }: { warnings: string[] }) {
287
- if (warnings.length === 0) return null;
298
+ function WarningPanel({ diagnostics, compact = false }: { diagnostics: WarningDiagnostic[]; compact?: boolean }) {
299
+ const groups = groupDiagnostics(diagnostics);
300
+ const plugins = [...new Set(groups.map((group) => group.plugin))].sort();
301
+ const rules = [...new Set(groups.map((group) => group.rule))].sort();
302
+ const [plugin, setPlugin] = useState("all");
303
+ const [rule, setRule] = useState("all");
304
+ const [severity, setSeverity] = useState<WarningSeverity | "all">("all");
305
+ const [visibility, setVisibility] = useState<"active" | "suppressed" | "all">("active");
306
+ const shown = groups.filter((group) =>
307
+ (plugin === "all" || group.plugin === plugin)
308
+ && (rule === "all" || group.rule === rule)
309
+ && (severity === "all" || group.severity === severity)
310
+ && (visibility === "all" || (visibility === "suppressed") === group.suppressed),
311
+ );
312
+ const activeCount = groups.filter((group) => !group.suppressed).reduce((sum, group) => sum + group.count, 0);
313
+ const suppressedCount = diagnostics.length - activeCount;
314
+
288
315
  return (
289
- <details className="mt-2" open={warnings.length <= 5}>
290
- <summary className="mono cursor-pointer text-muted">
291
- {warnings.length} {plural(warnings.length, "warning")}
292
- </summary>
293
- <ul className="mono mt-1 space-y-1 text-muted">
294
- {warnings.map((warning, index) => (
295
- <li key={`${index}:${warning}`} className="break-words border-l-2 border-line pl-2">
296
- {warning}
297
- </li>
298
- ))}
299
- </ul>
300
- </details>
316
+ <div className="space-y-2">
317
+ <div className="flex flex-wrap items-center gap-2">
318
+ <span className="mono text-muted">{activeCount} active · {groups.length} {plural(groups.length, "rule")}</span>
319
+ {suppressedCount ? <span className="chip status-verified">{suppressedCount} suppressed</span> : null}
320
+ <div className="flex flex-wrap gap-2 sm:ml-auto">
321
+ {plugins.length > 1 ? <select aria-label="Filter warnings by plugin" className={`${FIELD} w-auto py-1`} value={plugin} onChange={(event) => setPlugin(event.target.value)}><option value="all">all plugins</option>{plugins.map((name) => <option key={name} value={name}>{name}</option>)}</select> : null}
322
+ {rules.length > 1 ? <select aria-label="Filter warnings by rule" className={`${FIELD} w-auto py-1`} value={rule} onChange={(event) => setRule(event.target.value)}><option value="all">all rules</option>{rules.map((name) => <option key={name} value={name}>{name}</option>)}</select> : null}
323
+ <select aria-label="Filter warnings by severity" className={`${FIELD} w-auto py-1`} value={severity} onChange={(event) => setSeverity(event.target.value as WarningSeverity | "all")}><option value="all">all severities</option><option value="error">error</option><option value="warning">warning</option><option value="info">info</option></select>
324
+ <select aria-label="Filter active or suppressed warnings" className={`${FIELD} w-auto py-1`} value={visibility} onChange={(event) => setVisibility(event.target.value as "active" | "suppressed" | "all")}><option value="active">active</option><option value="suppressed">suppressed</option><option value="all">all</option></select>
325
+ </div>
326
+ </div>
327
+ {shown.length ? <div className="space-y-2">{shown.map((group) => {
328
+ const color = group.severity === "error" ? "text-unresolved" : group.severity === "warning" ? "text-declared" : "text-muted";
329
+ const candidates = group.messages.filter((warning) => warning.aggregateCandidates || djangoAggregateCandidates(warning.message));
330
+ const examples = group.messages.filter((warning) => !warning.aggregateCandidates && !djangoAggregateCandidates(warning.message)).slice(0, compact ? 3 : 12);
331
+ return <details key={`${group.plugin}:${group.rule}:${group.suppressed}`} className={`rounded-control border px-3 py-2 ${group.suppressed ? "border-line opacity-75" : group.severity === "error" ? "border-unresolved" : "border-line"}`} open={!compact && shown.length <= 4}>
332
+ <summary className="cursor-pointer list-none">
333
+ <span className="flex flex-wrap items-center gap-2">
334
+ <CircleAlert size={15} className={`shrink-0 ${color}`} />
335
+ <span className="mono text-ink">{group.plugin}</span>
336
+ <span className={`chip ${group.severity === "error" ? "status-unresolved" : group.severity === "warning" ? "status-declared" : ""}`}>{group.severity}</span>
337
+ <span className="mono text-muted">{group.rule}</span>
338
+ <span className="chip ml-auto">×{group.count}</span>
339
+ </span>
340
+ <span className="mt-1 block text-muted"><span className="font-medium text-ink">Next:</span> {group.action}</span>
341
+ {group.suppressionReason ? <span className="mt-1 block text-muted"><span className="font-medium text-ink">Suppressed:</span> {group.suppressionReason}</span> : null}
342
+ </summary>
343
+ <ul className="mono mt-2 space-y-1 text-muted">
344
+ {examples.map((warning, index) => <li key={`${index}:${warning.message}`} className="break-words border-l-2 border-line pl-2">{djangoAggregateMessage(warning.message)}</li>)}
345
+ </ul>
346
+ {candidates.length ? <DjangoAggregateChoices warnings={candidates} /> : null}
347
+ {group.count > examples.length + candidates.length ? <p className="mono mt-2 text-faint">{group.count - examples.length - candidates.length} more occurrences</p> : null}
348
+ </details>;
349
+ })}</div> : <p className="rounded-control border border-line px-3 py-2 text-muted">No warnings match these filters.</p>}
350
+ {!compact && groups.some((group) => !group.suppressed) ? <p className="text-faint">Suppress a reviewed limitation with a typed CEL entry in <span className="mono">portolan.json → warningPolicies</span>. The reason is required and remains visible here after regeneration.</p> : null}
351
+ </div>
301
352
  );
302
353
  }
303
354
 
@@ -306,11 +357,22 @@ function ProjectCard({ project, onRemove }: { project: SetupProject; onRemove?:
306
357
  const declared = setupInfo.steps.filter((step) => step.projectId === project.id);
307
358
  const runSteps = setupInfo.run?.steps.filter((step) => step.projectId === project.id) ?? [];
308
359
  const pluginNames = [...new Set(declared.map((step) => step.plugin))];
360
+ // A project rooted at the repository itself owns every source in it.
361
+ const rootedAtRepository = project.root === "." || project.root === "";
309
362
  const sources = catalogSources.filter(
310
- (source) => source.path === project.root || source.path.startsWith(`${project.root}/`),
363
+ (source) => rootedAtRepository || source.path === project.root || source.path.startsWith(`${project.root}/`),
311
364
  );
312
365
  const outputs = [...new Set(runSteps.flatMap((step) => step.files))];
366
+ // A vendored project's provenance is its pin: the upstream commit the copy
367
+ // was fetched at, which is also what its source links open. The history of
368
+ // THIS checkout says when the copy landed here, which is a different fact.
369
+ const pin = project.repository
370
+ ? catalog.repos?.find((held) => bare(held.repo) === bare(project.repository!))
371
+ : undefined;
313
372
  const commits = [...new Set(sources.map((source) => source.commit).filter(Boolean))];
373
+ // "uncommitted" is what the history says of a file it does not hold yet;
374
+ // it is not a commit, and a link built from it would open nothing.
375
+ const committed = commits.filter((commit) => commit !== "uncommitted");
314
376
  const href = projectHref(project);
315
377
  const sourceLink = forge(project);
316
378
  const health = healthFor(runSteps, declared.length, setupInfo);
@@ -341,7 +403,7 @@ function ProjectCard({ project, onRemove }: { project: SetupProject; onRemove?:
341
403
  <dt>scope</dt><dd className="truncate text-ink">{project.components?.length ? `${project.group ?? project.context} · ${project.components.length} components` : [project.group ?? project.context, project.component ?? project.service].filter(Boolean).join(" · ") || "estate"}</dd>
342
404
  <dt>pipeline</dt><dd className="text-ink">{declared.length} {plural(declared.length, "step")}</dd>
343
405
  <dt>fragments</dt><dd className="text-ink">{sources.length}</dd>
344
- <dt>commit</dt><dd className="truncate text-ink" title={commits.join(", ")}>{commits.length === 0 ? "not stamped" : commits.length === 1 ? <CommitLink commit={commits[0]!} repository={project.repository} length={12} /> : `${commits.length} source commits`}</dd>
406
+ <dt>commit</dt><dd className="truncate text-ink" title={pin ? pin.commit : commits.join(", ")}>{pin ? <CommitLink commit={pin.commit} repository={project.repository} length={12} /> : committed.length === 1 ? <CommitLink commit={committed[0]!} repository={project.repository} length={12} /> : committed.length > 1 ? `${committed.length} source commits` : commits.length > 0 ? "uncommitted" : "not stamped"}</dd>
345
407
  </dl>
346
408
 
347
409
  <div className="mt-4 flex flex-wrap gap-1.5" aria-label="Active plugins">
@@ -828,11 +890,15 @@ function Wizard({ open, initialSource, onClose, onAdded, onRunStarted }: { open:
828
890
  const trialRunning = stage === "trial" && !finished;
829
891
  const confirmedDeployables = discovery?.deployables.filter((candidate) => candidate.confidence === "high") ?? [];
830
892
  const splitsDeployables = confirmedDeployables.length > 1 && draft?.component === discovery?.defaults.component;
893
+ const trialDiagnostics = trial?.diagnostics ?? [];
894
+ const trialActiveWarnings = trial
895
+ ? groupDiagnostics(trialDiagnostics).filter((group) => !group.suppressed).reduce((sum, group) => sum + group.count, 0)
896
+ : 0;
831
897
  const quality = trial && draft ? [
832
898
  { label: "Stable project identity", done: Boolean(draft.id.trim() && draft.name.trim()) },
833
899
  { label: "Architecture placement", done: Boolean(draft.group.trim() && (splitsDeployables || draft.component.trim())) },
834
900
  { label: "Catalog facts extracted", done: trial.facts.some((fact) => fact.count > 0) },
835
- { label: "Extraction without warnings", done: trial.warnings.length === 0 },
901
+ { label: "Extraction without active warnings", done: trialActiveWarnings === 0 },
836
902
  ] : [];
837
903
  const totalSteps = pipeline?.stepCount ?? plan?.steps.length ?? 0;
838
904
  const percent = totalSteps ? Math.round((completedSteps.length / totalSteps) * 100) : 0;
@@ -876,7 +942,7 @@ function Wizard({ open, initialSource, onClose, onAdded, onRunStarted }: { open:
876
942
  {discovery.detections.length ? <div className="grid gap-2">{discovery.detections.map((item) => {
877
943
  const checked = draft.plugins.includes(item.plugin);
878
944
  const capability = CAPABILITIES[item.plugin] ?? { title: item.plugin, summary: "Catalog facts extracted from project files." };
879
- return <label key={item.plugin} className={`flex cursor-pointer items-start gap-3 rounded-control border px-3 py-3 transition-colors ${checked ? "border-accent bg-surface" : "border-line hover:bg-surface"}`}><input className="mt-1" type="checkbox" checked={checked} onChange={() => setDraft({ ...draft, plugins: checked ? draft.plugins.filter((name) => name !== item.plugin) : [...draft.plugins, item.plugin] })} /><span className="min-w-0 flex-1"><span className="flex flex-wrap items-center gap-2"><span className="font-medium text-ink">{capability.title}</span><span className={`chip ${item.confidence === "high" ? "status-verified" : "status-declared"}`}>{item.confidence} confidence</span>{item.candidates.length > 1 ? <span className="chip status-declared">{item.candidates.length} candidates</span> : null}</span><span className="mt-0.5 block text-muted">{capability.summary}</span><span className="mono mt-1 block truncate text-faint" title={item.evidence}>evidence · {item.evidence}</span><span className="mono mt-0.5 block text-faint">extractor · {item.plugin}</span></span></label>;
945
+ return <label key={item.plugin} className={`flex cursor-pointer items-start gap-3 rounded-control border px-3 py-3 transition-colors ${checked ? "border-accent bg-surface" : "border-line hover:bg-surface"}`}><input aria-label={`Toggle ${capability.title}`} className="mt-1" type="checkbox" checked={checked} onChange={() => setDraft({ ...draft, plugins: checked ? draft.plugins.filter((name) => name !== item.plugin) : [...draft.plugins, item.plugin] })} /><span className="min-w-0 flex-1"><span className="flex flex-wrap items-center gap-2"><span className="font-medium text-ink">{capability.title}</span><span className={`chip ${item.confidence === "high" ? "status-verified" : "status-declared"}`}>{item.confidence} confidence</span>{item.candidates.length > 1 ? <span className="chip status-declared">{item.candidates.length} candidates</span> : null}</span><span className="mt-0.5 block text-muted">{capability.summary}</span><span className="mono mt-1 block truncate text-faint" title={item.evidence}>evidence · {item.evidence}</span>{item.preview?.length ? <span className="mt-2 grid gap-1.5">{item.preview.slice(0, 3).map((preview) => <span key={preview.file} className="block rounded-control border border-line bg-canvas px-2 py-1.5"><span className="mono block truncate text-faint" title={preview.file}>{preview.file}</span><span className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-muted">{Object.entries(preview.fields).map(([field, value]) => <span key={field}><span className="text-faint">{field}</span> · <span className="text-ink">{value}</span></span>)}</span></span>)}{item.preview.length > 3 ? <span className="text-faint">+ {item.preview.length - 3} more recognized records</span> : null}</span> : null}<span className="mono mt-0.5 block text-faint">extractor · {item.plugin}</span></span></label>;
880
946
  })}</div> : <Empty>no supported project signals found</Empty>}
881
947
  </div>
882
948
  </div>
@@ -894,7 +960,7 @@ function Wizard({ open, initialSource, onClose, onAdded, onRunStarted }: { open:
894
960
  {trial ? <section><div className="label mb-2">what Portolan found</div>{trial.facts.length ? <div className="grid grid-cols-2 gap-2 sm:grid-cols-3">{trial.facts.map((fact) => <div key={fact.key} className="rounded-control border border-line bg-canvas px-3 py-2"><div className="tnum text-lg font-semibold text-ink">{fact.count}</div><div className="text-muted">{fact.label}</div></div>)}</div> : <div className="rounded-control border border-line bg-surface px-3 py-3 text-muted">Only component metadata was found. You can add APIs, domain models and stores later without re-adding the project.</div>}</section> : null}
895
961
  {trial ? <section><div className="label mb-2">catalog readiness</div><div className="grid gap-2 sm:grid-cols-2">{quality.map((item) => <div key={item.label} className="flex items-center gap-2 rounded-control border border-line px-3 py-2"><span className={`flex size-5 items-center justify-center rounded-full ${item.done ? "bg-verified text-canvas" : "bg-surface text-declared"}`}>{item.done ? <Check size={12} aria-hidden /> : <CircleAlert size={12} aria-hidden />}</span><span className={item.done ? "text-ink" : "text-muted"}>{item.label}</span></div>)}</div></section> : null}
896
962
  <section><div className="label mb-2">extractor results</div><div className="divide-y divide-line rounded-control border border-line">{(trial?.steps ?? completedSteps).map((step) => <div key={`${step.plugin}:${"ordinal" in step ? step.ordinal : "trial"}`} className="grid gap-1 px-3 py-2 sm:grid-cols-[1fr_auto_auto]"><span><span className="font-medium text-ink">{CAPABILITIES[step.plugin]?.title ?? step.plugin}</span><span className="mono ml-2 text-faint">{step.plugin}</span>{step.message ? <span className="mt-1 block text-unresolved">{step.message}</span> : null}</span><span className="mono text-muted">{step.fileCount} {plural(step.fileCount, "file")}</span><span className={`chip ${step.status === "failed" ? "status-unresolved" : "status-verified"}`}>{step.status === "failed" ? "failed" : "read"}</span></div>)}</div></section>
897
- {trial?.warnings.length ? <section><div className="label mb-2">warnings · {trial.warnings.length}</div><div className="space-y-2">{trial.warnings.map((warning, index) => <div key={`${warning.plugin}:${index}`} className="flex gap-2 rounded-control border border-declared px-3 py-2 text-muted"><CircleAlert size={15} className="mt-0.5 shrink-0 text-declared" /><span><span className="mono text-ink">{warning.plugin}</span> · {warning.message}</span></div>)}</div></section> : null}
963
+ {trialDiagnostics.length ? <section><div className="label mb-2">diagnostics · {trialDiagnostics.length}</div><WarningPanel diagnostics={trialDiagnostics} /></section> : null}
898
964
  {finished?.status === "failed" && logs.length ? <details><summary className="cursor-pointer text-muted">Generator log · {logs.length} lines</summary><pre className="mono mt-2 max-h-48 overflow-auto whitespace-pre-wrap rounded-control bg-surface p-3 text-muted">{logs.map((event) => event.message).join("\n")}</pre></details> : null}
899
965
  {trial ? <div><div className="label mb-2">changes after apply</div><dl className="mono grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-2 text-muted"><dt>project</dt><dd className="text-ink">{plan.project.id}</dd>{plan.fetch ? <><dt>pin</dt><dd className="truncate text-ink"><CommitLink commit={plan.fetch.commit} repository={plan.fetch.repo} length={12} /></dd></> : null}<dt>source</dt><dd className="truncate text-ink">{plan.source}</dd><dt>pipeline</dt><dd className="text-ink">{plan.steps.length + (plan.fetch ? 1 : 0)} {plural(plan.steps.length + (plan.fetch ? 1 : 0), "extract step")}</dd><dt>fragments</dt><dd className="text-ink">{trial.generatedFiles} generated</dd></dl></div> : null}
900
966
  </div>
@@ -954,6 +1020,7 @@ const SETTINGS_LINKS = [
954
1020
  ["Projects", paths.settingsProjects()],
955
1021
  ["Pipeline", paths.settingsPipeline()],
956
1022
  ["Delivery", paths.settingsDelivery()],
1023
+ ["Integrations", paths.settingsIntegrations()],
957
1024
  ["Preferences", paths.settingsPreferences()],
958
1025
  ["About", paths.settingsAbout()],
959
1026
  ] as const;
@@ -1046,6 +1113,10 @@ function OverviewSettings({ local, onGenerate }: { local: boolean; onGenerate: (
1046
1113
  <div className="font-semibold text-ink">Delivery</div>
1047
1114
  <p className="mt-1 text-muted">Install review checks and static catalog publishing for GitHub or GitLab.</p>
1048
1115
  </Link>
1116
+ <Link to={paths.settingsIntegrations()} className="card">
1117
+ <div className="font-semibold text-ink">Integrations</div>
1118
+ <p className="mt-1 text-muted">Connect operational tools such as Kafka UI to the catalog.</p>
1119
+ </Link>
1049
1120
  <Link to={paths.settingsPreferences()} className="card">
1050
1121
  <div className="font-semibold text-ink">Preferences</div>
1051
1122
  <p className="mt-1 text-muted">Theme, row density, source editor and Ask the catalog.</p>
@@ -1112,15 +1183,16 @@ function SettingsContent({ local, onAdd, onRemove, onGenerate }: { local: boolea
1112
1183
  const pathname = useLocation().pathname.replace(/\/$/, "");
1113
1184
  const overview = pathname === paths.settings();
1114
1185
  const about = pathname === paths.settingsAbout();
1186
+ const browserIntegration = pathname === paths.settingsIntegrations();
1115
1187
  return (
1116
1188
  <div className="h-full overflow-y-auto p-gutter">
1117
1189
  <div className="max-w-table">
1118
1190
  <div className="flex flex-wrap items-start justify-between gap-3">
1119
1191
  <div>
1120
1192
  <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>
1193
+ <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
1194
  </div>
1123
- {local && !overview && !about ? <button type="button" className="product-primary" onClick={onGenerate}><Play size={15} /> Preview generated diff</button> : null}
1195
+ {local && !overview && !about && !browserIntegration ? <button type="button" className="product-primary" onClick={onGenerate}><Play size={15} /> Preview generated diff</button> : null}
1124
1196
  </div>
1125
1197
  <SettingsNav />
1126
1198
  <div className="mt-section">
@@ -1129,17 +1201,100 @@ function SettingsContent({ local, onAdd, onRemove, onGenerate }: { local: boolea
1129
1201
  <Route path="projects" element={<ProjectsSettings local={local} onAdd={onAdd} onRemove={onRemove} />} />
1130
1202
  <Route path="pipeline" element={<PipelineSettings />} />
1131
1203
  <Route path="delivery" element={<section><SectionTitle right={local ? "preview before writing" : "local mode required"}>Delivery presets</SectionTitle><DeliverySettings local={local} /></section>} />
1204
+ <Route path="integrations" element={<IntegrationsSettings />} />
1132
1205
  <Route path="preferences" element={<PreferencesSettings />} />
1133
1206
  <Route path="about" element={<AboutSettings />} />
1134
1207
  <Route path="*" element={<Navigate to={paths.settings()} replace />} />
1135
1208
  </Routes>
1136
1209
  </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}
1210
+ {!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
1211
  </div>
1139
1212
  </div>
1140
1213
  );
1141
1214
  }
1142
1215
 
1216
+ function RemoveProjectDialog({
1217
+ project,
1218
+ busy,
1219
+ onClose,
1220
+ onConfirm,
1221
+ }: {
1222
+ project: SetupProject | null;
1223
+ busy: boolean;
1224
+ onClose: () => void;
1225
+ onConfirm: () => void;
1226
+ }) {
1227
+ const name = project?.name ?? "project";
1228
+ return (
1229
+ <Modal
1230
+ open={project !== null}
1231
+ onClose={busy ? () => {} : onClose}
1232
+ label={`Remove ${name}`}
1233
+ width="min(520px,92vw)"
1234
+ >
1235
+ <div className="flex items-center gap-3 border-b border-line px-5 py-4">
1236
+ <m.span
1237
+ animate={busy ? { rotate: 360 } : { rotate: 0 }}
1238
+ transition={busy ? { duration: 0.9, ease: "linear", repeat: Infinity } : undefined}
1239
+ className="flex size-8 shrink-0 items-center justify-center rounded-full bg-unresolved/10 text-unresolved"
1240
+ >
1241
+ {busy ? <LoaderCircle size={17} aria-hidden /> : <Trash2 size={16} aria-hidden />}
1242
+ </m.span>
1243
+ <div className="min-w-0">
1244
+ <div className="font-semibold text-ink">{busy ? `Removing ${name}` : `Remove ${name}?`}</div>
1245
+ <div className="mono mt-0.5 truncate text-faint">{project?.id}</div>
1246
+ </div>
1247
+ </div>
1248
+
1249
+ <div className="min-h-40 p-5">
1250
+ <AnimatePresence mode="wait" initial={false}>
1251
+ {busy ? (
1252
+ <m.div key="removing" {...rise} aria-live="polite">
1253
+ <p className="font-medium text-ink">Updating project configuration…</p>
1254
+ <p className="mt-2 text-muted">
1255
+ Portolan is removing the project and its extraction steps. The source tree remains untouched.
1256
+ </p>
1257
+ <div
1258
+ role="progressbar"
1259
+ aria-label={`Removing ${name}`}
1260
+ className="mt-5 h-1.5 overflow-hidden rounded-full bg-surface"
1261
+ >
1262
+ <m.div
1263
+ className="h-full w-2/5 rounded-full bg-unresolved"
1264
+ initial={{ x: "-100%" }}
1265
+ animate={{ x: ["-100%", "250%"] }}
1266
+ transition={{ duration: 1.1, ease: "easeInOut", repeat: Infinity }}
1267
+ />
1268
+ </div>
1269
+ </m.div>
1270
+ ) : (
1271
+ <m.div key="confirm" {...rise}>
1272
+ <p className="text-muted">
1273
+ This removes the project, its extraction steps and its dedicated catalog configuration from <span className="mono text-ink">portolan.json</span>. Your source code stays untouched.
1274
+ </p>
1275
+ <p className="mt-3 text-muted">
1276
+ You can undo immediately. Generated documentation changes remain pending until you preview and apply them.
1277
+ </p>
1278
+ </m.div>
1279
+ )}
1280
+ </AnimatePresence>
1281
+ </div>
1282
+
1283
+ <div className="flex justify-end gap-2 border-t border-line px-5 py-4">
1284
+ <button type="button" className="tbtn" onClick={onClose} disabled={busy}>Cancel</button>
1285
+ <button type="button" className="product-primary product-danger min-w-36" onClick={onConfirm} disabled={busy}>
1286
+ <AnimatePresence mode="wait" initial={false}>
1287
+ <m.span key={busy ? "busy" : "ready"} {...rise} className="flex items-center justify-center gap-2">
1288
+ {busy ? <LoaderCircle size={15} aria-hidden /> : <Trash2 size={15} aria-hidden />}
1289
+ {busy ? "Removing…" : "Remove project"}
1290
+ </m.span>
1291
+ </AnimatePresence>
1292
+ </button>
1293
+ </div>
1294
+ </Modal>
1295
+ );
1296
+ }
1297
+
1143
1298
  export function Settings() {
1144
1299
  useDocumentTitle("Settings");
1145
1300
  const queryClient = useQueryClient();
@@ -1202,11 +1357,12 @@ export function Settings() {
1202
1357
  <SetupContext.Provider value={setup}>
1203
1358
  <SettingsContent local={local} onAdd={setWizardSource} onRemove={setRemoveTarget} onGenerate={() => void generate()} />
1204
1359
  <Wizard open={wizardSource !== null} initialSource={wizardSource ?? "local"} onClose={() => setWizardSource(null)} onAdded={setSetup} onRunStarted={(id) => { setRunId(id); setRunOpen(true); }} />
1205
- <Modal open={removeTarget !== null} onClose={removeBusy ? () => {} : () => setRemoveTarget(null)} label={`Remove ${removeTarget?.name ?? "project"}`} width="min(520px,92vw)">
1206
- <div className="border-b border-line px-5 py-4"><div className="font-semibold text-ink">Remove {removeTarget?.name}?</div></div>
1207
- <div className="p-5"><p className="text-muted">This removes the project, its extraction steps and its dedicated catalog configuration from <span className="mono text-ink">portolan.json</span>. Your source code stays untouched.</p><p className="mt-3 text-muted">You can undo immediately. Generated documentation changes remain pending until you preview and apply them.</p></div>
1208
- <div className="flex justify-end gap-2 border-t border-line px-5 py-4"><button type="button" className="tbtn" onClick={() => setRemoveTarget(null)} disabled={removeBusy}>Cancel</button><button type="button" className="product-primary" onClick={() => void confirmRemove()} disabled={removeBusy}>{removeBusy ? <LoaderCircle size={15} className="animate-spin" /> : <Trash2 size={15} />} Remove project</button></div>
1209
- </Modal>
1360
+ <RemoveProjectDialog
1361
+ project={removeTarget}
1362
+ busy={removeBusy}
1363
+ onClose={() => setRemoveTarget(null)}
1364
+ onConfirm={() => void confirmRemove()}
1365
+ />
1210
1366
  <RunDialog runId={runId} open={runOpen} onClose={() => setRunOpen(false)} onFinished={refresh} onApply={(preview) => void generate(preview)} />
1211
1367
  </SetupContext.Provider>
1212
1368
  );
@@ -0,0 +1,79 @@
1
+ import { useState } from "react";
2
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
3
+ import { djangoAggregateCandidates, djangoAggregateMessage } from "../../lib/django-aggregates";
4
+ import { djangoAggregateProposals, saveDjangoAggregates } from "../../lib/local-api";
5
+ import { localKeys, localStatusQuery } from "../../lib/queries";
6
+ import type { WarningDiagnostic } from "../../lib/warnings";
7
+ import { useToastStore } from "../../app/toast";
8
+
9
+ const CANDIDATE_KEY = ["local", "django-aggregates"];
10
+
11
+ export function DjangoAggregateChoices({ warnings }: { warnings: WarningDiagnostic[] }) {
12
+ const status = useQuery(localStatusQuery());
13
+ const queryClient = useQueryClient();
14
+ const say = useToastStore((state) => state.say);
15
+ const proposals = useQuery({
16
+ queryKey: [...CANDIDATE_KEY, status.data?.setup.run?.finishedAt],
17
+ queryFn: djangoAggregateProposals,
18
+ enabled: status.isSuccess,
19
+ retry: false,
20
+ });
21
+ const [choices, setChoices] = useState<Record<string, string>>({});
22
+ const [busy, setBusy] = useState(false);
23
+ const [error, setError] = useState("");
24
+ const [saved, setSaved] = useState(false);
25
+ const rows = warnings.flatMap((warning) => {
26
+ const candidates = warning.aggregateCandidates ?? djangoAggregateCandidates(warning.message);
27
+ if (!candidates) return [];
28
+ const matches = proposals.data?.proposals.filter((proposal) => proposal.plugin === warning.plugin && proposal.app === candidates.app
29
+ && djangoAggregateMessage(proposal.message).slice(0, 500) === djangoAggregateMessage(warning.message).slice(0, 500)) ?? [];
30
+ return [{ ...candidates, key: `${warning.plugin}:${warning.message}`, proposal: matches.length === 1 ? matches[0] : undefined }];
31
+ });
32
+ const selections = rows.flatMap((row) => row.proposal && choices[row.key] ? [{ id: row.proposal.id, model: choices[row.key]! }] : []);
33
+ const canSave = Boolean(proposals.data && !proposals.data.stale && !status.data?.activeRun && selections.length && !busy);
34
+
35
+ async function save() {
36
+ if (!canSave || !proposals.data) return;
37
+ setBusy(true); setError(""); setSaved(false);
38
+ try {
39
+ await saveDjangoAggregates(proposals.data.revision, selections);
40
+ setSaved(true);
41
+ // Refreshing the manifest hides the now-stale report and unmounts this
42
+ // picker; keep the next step visible outside that report.
43
+ say("Aggregate roots saved to portolan.json. Preview generated diff to update the catalog.");
44
+ await Promise.all([
45
+ queryClient.invalidateQueries({ queryKey: CANDIDATE_KEY }),
46
+ queryClient.invalidateQueries({ queryKey: localKeys.status }),
47
+ ]);
48
+ } catch (cause) {
49
+ setError(cause instanceof Error ? cause.message : String(cause));
50
+ } finally { setBusy(false); }
51
+ }
52
+
53
+ return <div className="mt-3 space-y-3">
54
+ <p className="text-muted">Choose the model that owns each application’s aggregate. Other concrete models become its entities.</p>
55
+ {rows.map((row) => <div key={row.key} className="rounded-control border border-line p-3">
56
+ <label className="block font-medium text-ink">
57
+ Aggregate root for <span className="mono">{row.app}</span>
58
+ <select aria-label={`Aggregate root for ${row.app}`} className="mt-2 block w-full rounded-control border border-line bg-canvas px-3 py-2 text-ink" value={choices[row.key] ?? ""} onChange={(event) => { setChoices({ ...choices, [row.key]: event.target.value }); setSaved(false); }} disabled={busy}>
59
+ <option value="">Choose a model…</option>
60
+ {row.models.map((model) => <option key={model.name} value={model.name}>{model.name}</option>)}
61
+ </select>
62
+ </label>
63
+ <details className="mt-2 text-muted">
64
+ <summary className="cursor-pointer">{row.models.length} candidates · source locations</summary>
65
+ <ul className="mono mt-2 space-y-1">{row.models.map((model) => <li key={model.name} className="break-words">{model.name} · {model.path}:{model.line}</li>)}</ul>
66
+ </details>
67
+ {choices[row.key] ? <pre className="mono mt-2 overflow-auto rounded-control bg-canvas p-2 text-ink">{JSON.stringify({ aggregates: { [row.app]: choices[row.key] } }, null, 2)}</pre> : null}
68
+ </div>)}
69
+ {status.isSuccess ? <>
70
+ <button className="rounded-control border border-line bg-canvas px-3 py-2 font-medium text-accent hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50" onClick={() => void save()} disabled={!canSave}>{busy ? "Saving…" : "Save selected roots"}</button>
71
+ <p className="text-muted">Saves to the matching extraction steps in portolan.json. Regenerate afterward to update the catalog and warnings.</p>
72
+ {proposals.data?.stale && !saved ? <p className="text-declared">Regenerate before saving: these candidates belong to an older manifest.</p> : null}
73
+ {proposals.isError ? <p role="alert" className="text-unresolved">Could not load the saved extraction report. Regenerate and reopen Settings.</p> : null}
74
+ {rows.some((row) => !row.proposal) && proposals.isSuccess ? <p className="text-muted">Candidates from a trial can be saved after applying the project and generating its catalog.</p> : null}
75
+ </> : <p className="text-muted">To save, open Portolan locally or copy the selected aggregates option into the matching extraction step in portolan.json.</p>}
76
+ {saved ? <p role="status" className="text-verified">Aggregate roots saved. Regenerate to apply the choices to the catalog.</p> : null}
77
+ {error ? <p role="alert" className="text-unresolved">{error}</p> : null}
78
+ </div>;
79
+ }
@@ -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\/[^/]+$/,