@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.
- package/README.md +23 -0
- package/catalog/enum_test.go +46 -0
- package/catalog/evidence_test.go +35 -0
- package/catalog/model.go +1066 -0
- package/catalog/roundtrip_test.go +203 -0
- package/catalog/via_test.go +38 -0
- package/cli/init.test.mjs +6 -1
- package/cli/portolan.mjs +8 -0
- package/cli/portolan.test.mjs +49 -0
- package/go.mod +14 -0
- package/go.sum +20 -0
- package/internal/gocall/README.md +19 -0
- package/internal/gocall/analyze.go +189 -0
- package/internal/gocall/analyze_test.go +107 -0
- package/internal/gohttp/analyze.go +2562 -0
- package/internal/gohttp/destination.go +373 -0
- package/internal/gohttp/endpoints.go +1067 -0
- package/internal/gohttp/roots.go +320 -0
- package/internal/gohttp/typed.go +96 -0
- package/internal/goscan/constants.go +85 -0
- package/internal/goscan/goscan_test.go +227 -0
- package/internal/goscan/index.go +629 -0
- package/internal/goscan/index_test.go +66 -0
- package/internal/goscan/names.go +52 -0
- package/internal/goscan/parse_test.go +11 -0
- package/internal/goscan/source.go +37 -0
- package/internal/goscan/tree.go +284 -0
- package/internal/goscan/types.go +99 -0
- package/internal/wsdl/ids.go +127 -0
- package/internal/wsdl/ids_test.go +21 -0
- package/internal/wsdl/model.go +70 -0
- package/internal/wsdl/parse.go +949 -0
- package/internal/wsdl/parse_test.go +170 -0
- package/package.json +22 -10
- package/plugin/describe.go +118 -0
- package/plugin/describe_test.go +114 -0
- package/plugin/protocol.go +141 -0
- package/plugin/schematest/schematest.go +126 -0
- package/plugins/README.md +114 -46
- package/plugins/cmd/portolan-http-clients/main.go +19 -0
- package/plugins/extract-celery/extract.py +0 -2
- package/plugins/extract-celery/extract_test.py +1 -1
- package/plugins/extract-django/README.md +39 -17
- package/plugins/extract-django/domain.py +28 -17
- package/plugins/extract-django/extract.py +21 -7
- package/plugins/extract-django/extract_test.py +55 -2
- package/plugins/extract-django/lifecycle.py +2 -0
- package/plugins/extract-django/operations.py +1 -1
- package/plugins/extract-django/routing_test.py +109 -1
- package/plugins/extract-django/store.py +1 -1
- package/plugins/extract-django/transport.py +101 -55
- package/plugins/extract-django/verbs.py +241 -0
- package/plugins/extract-go/README.md +47 -0
- package/plugins/extract-http-clients/describe.go +19 -0
- package/plugins/extract-http-clients/describe_test.go +11 -0
- package/plugins/extract-http-clients/extract.go +740 -0
- package/plugins/extract-http-clients/extract_test.go +1561 -0
- package/plugins/extract-http-clients/main.go +41 -0
- package/plugins/extract-java/build/org/portolan/extract/Extract.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Builder.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Input.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Options.class +0 -0
- package/plugins/extract-python-kafka/README.md +6 -0
- package/plugins/extract-python-kafka/extract.py +2 -4
- package/plugins/extract-python-kafka/extract_test.py +18 -2
- package/plugins/extract-ts/extract.test.ts +2 -2
- package/plugins/extract-ts/extract.ts +4 -5
- package/plugins/extract-ts/graphql.test.ts +1 -1
- package/plugins/openapi/ids.go +261 -0
- package/plugins/openapi/ids_test.go +98 -0
- package/plugins/portolan-go.wasm +0 -0
- package/plugins/pyplugin/catalog.py +12 -1
- package/plugins/pyplugin/kafka.py +74 -3
- package/plugins/pyplugin/protocol.py +1 -5
- package/portolan.json +3 -2
- package/schema/portolan.schema.json +34 -0
- package/scripts/README.md +18 -12
- package/scripts/catalog-sources.mjs +6 -0
- package/scripts/delivery-presets.mjs +21 -11
- package/scripts/diff.mjs +5 -1
- package/scripts/django-aggregates.test.mjs +58 -0
- package/scripts/gen-likec4.mjs +79 -21
- package/scripts/gen-likec4.test.mjs +25 -2
- package/scripts/gen.mjs +118 -115
- package/scripts/go-discovery.test.mjs +30 -0
- package/scripts/history.mjs +186 -3
- package/scripts/history.test.mjs +1 -1
- package/scripts/host-plugins/fetch-git.mjs +77 -21
- package/scripts/host-plugins/fetch-git.test.mjs +62 -8
- package/scripts/local-api.mjs +71 -4
- package/scripts/local-api.test.mjs +63 -4
- package/scripts/local-discovery.mjs +82 -9
- package/scripts/manifest.mjs +5 -3
- package/scripts/manifest.test.mjs +24 -0
- package/scripts/output-diff.mjs +94 -0
- package/scripts/output-diff.test.mjs +36 -0
- package/scripts/package-smoke.mjs +62 -4
- package/scripts/plugin-host.mjs +22 -2
- package/scripts/plugin-host.test.mjs +9 -0
- package/scripts/plugin-wasm-worker.mjs +4 -1
- package/scripts/provenance.mjs +72 -0
- package/scripts/provenance.test.mjs +149 -0
- package/scripts/run-builtin.mjs +39 -5
- package/scripts/schema.mjs +29 -0
- package/scripts/warning-policy.mjs +161 -0
- package/scripts/warning-policy.test.mjs +56 -0
- package/src/app/Breadcrumbs.test.ts +4 -0
- package/src/app/Breadcrumbs.tsx +1 -0
- package/src/app/Sidebar.tsx +3 -3
- package/src/catalog-docs.test.ts +64 -0
- package/src/catalog-docs.ts +35 -0
- package/src/catalog-error.test.ts +15 -0
- package/src/catalog-model.ts +70 -6
- package/src/catalog-stores.test.ts +17 -0
- package/src/catalog-validation.ts +52 -2
- package/src/catalog.test.ts +13 -2
- package/src/chat/Starter.tsx +5 -11
- package/src/chat/tools.test.ts +27 -0
- package/src/chat/tools.ts +5 -9
- package/src/components/CatalogStamp.tsx +10 -8
- package/src/components/ChannelRows.messagepack.test.tsx +28 -0
- package/src/components/ChannelRows.test.tsx +54 -0
- package/src/components/ChannelRows.tsx +57 -10
- package/src/components/HTTPDestinationEvidence.test.tsx +23 -0
- package/src/components/HTTPDestinationEvidence.tsx +31 -0
- package/src/components/Integrations.tsx +1 -1
- package/src/components/LifecycleDiagram.tsx +28 -12
- package/src/components/MachineDocs.tsx +6 -5
- package/src/components/MethodRows.tsx +9 -2
- package/src/components/ProblemRow.tsx +4 -0
- package/src/components/RelationEvidence.test.tsx +14 -0
- package/src/components/RelationEvidence.tsx +53 -0
- package/src/components/WhatLinksHere.tsx +6 -4
- package/src/data.ts +25 -7
- package/src/enrich.test.ts +336 -6
- package/src/enrich.ts +206 -3
- package/src/flow/StepDetail.tsx +104 -54
- package/src/flow/answers.test.ts +18 -1
- package/src/flow/answers.ts +37 -8
- package/src/flow/evidence.test.ts +16 -0
- package/src/flow/evidence.ts +34 -0
- package/src/index.css +44 -0
- package/src/landing/DraggableReveal.tsx +3 -2
- package/src/landing/EvidencePipeline.tsx +105 -0
- package/src/landing/LandingPage.tsx +2 -59
- package/src/lib/backlinks.test.ts +16 -1
- package/src/lib/backlinks.ts +20 -0
- package/src/lib/catalog-diff.test.ts +18 -0
- package/src/lib/catalog-diff.ts +20 -2
- package/src/lib/derive.ts +1 -0
- package/src/lib/django-aggregates.d.mts +9 -0
- package/src/lib/django-aggregates.mjs +36 -0
- package/src/lib/django-aggregates.test.ts +29 -0
- package/src/lib/django-aggregates.ts +5 -0
- package/src/lib/kafka-ui.test.ts +87 -0
- package/src/lib/kafka-ui.ts +105 -0
- package/src/lib/local-api.ts +20 -2
- package/src/lib/setup-info.test.ts +17 -0
- package/src/lib/setup-info.ts +58 -0
- package/src/lib/warnings.test.ts +54 -0
- package/src/lib/warnings.ts +260 -0
- package/src/lib/wire-problems.test.ts +21 -0
- package/src/lib/wire-problems.ts +62 -1
- package/src/likec4/FlowView.tsx +2 -6
- package/src/likec4/flow-edges.test.ts +64 -1
- package/src/likec4/flow-edges.ts +43 -7
- package/src/likec4/view-index.ts +8 -2
- package/src/map/ContextMapGraph.tsx +76 -32
- package/src/merge.test.ts +23 -0
- package/src/merge.ts +33 -10
- package/src/pages/AggregatePage.tsx +8 -7
- package/src/pages/CatalogFailure.tsx +2 -2
- package/src/pages/ContextPage.tsx +6 -5
- package/src/pages/ServicePage.tsx +4 -3
- package/src/pages/Settings.tsx +200 -44
- package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
- package/src/pages/settings/IntegrationsSettings.tsx +117 -0
- package/src/routes.test.ts +2 -0
- package/src/routes.ts +2 -1
- package/src/selection/DetailPanel.tsx +61 -1
- package/src/virtual-provenance.d.ts +11 -0
- package/vite.config.ts +5 -0
- package/scripts/vendor-lock.mjs +0 -58
- package/scripts/vendor-lock.test.mjs +0 -69
package/src/pages/Settings.tsx
CHANGED
|
@@ -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="
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
<div className="
|
|
268
|
-
<
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
<
|
|
273
|
-
|
|
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
|
-
|
|
277
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
<
|
|
290
|
-
<
|
|
291
|
-
{
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
<
|
|
296
|
-
|
|
297
|
-
</
|
|
298
|
-
|
|
299
|
-
</
|
|
300
|
-
|
|
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(", ")}>{
|
|
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:
|
|
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
|
-
{
|
|
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
|
-
<
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
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
|
+
}
|
package/src/routes.test.ts
CHANGED
|
@@ -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\/[^/]+$/,
|