@shortlink-org/portolan 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +151 -6
- package/catalog/model.go +147 -0
- package/catalog/roundtrip_test.go +7 -0
- package/cli/portolan.mjs +6 -1
- package/go.mod +7 -0
- package/go.sum +20 -6
- package/package.json +10 -9
- package/plugin/describe.go +38 -0
- package/plugins/README.md +296 -5
- package/plugins/extract-argocd/options.schema.json +44 -0
- package/plugins/extract-celery/main.py +1 -0
- package/plugins/extract-csharp-ddd/README.md +213 -0
- package/plugins/extract-csharp-ddd/options.schema.json +55 -0
- package/plugins/extract-django/README.md +13 -2
- package/plugins/extract-django/choices.py +47 -0
- package/plugins/extract-django/domain.py +2 -1
- package/plugins/extract-django/extract_test.py +13 -0
- package/plugins/extract-django/lifecycle.py +2 -28
- package/plugins/extract-django/main.py +1 -0
- package/plugins/extract-django/rules.py +154 -0
- package/plugins/extract-django/rules_test.py +158 -0
- package/plugins/extract-go-sqs/options.schema.json +21 -0
- package/plugins/extract-http-clients/describe.go +5 -4
- package/plugins/extract-java/build/org/portolan/extract/Main.class +0 -0
- package/plugins/extract-k8s/options.schema.json +32 -0
- package/plugins/extract-laravel/Cargo.lock +962 -0
- package/plugins/extract-laravel/Cargo.toml +20 -0
- package/plugins/extract-laravel/README.md +200 -0
- package/plugins/extract-laravel/options.schema.json +68 -0
- package/plugins/extract-laravel/rustfmt.toml +1 -0
- package/plugins/extract-php-ddd/Cargo.lock +972 -0
- package/plugins/extract-php-ddd/Cargo.toml +22 -0
- package/plugins/extract-php-ddd/README.md +141 -0
- package/plugins/extract-php-ddd/options.schema.json +50 -0
- package/plugins/extract-php-ddd/rustfmt.toml +1 -0
- package/plugins/extract-python-kafka/main.py +1 -0
- package/plugins/extract-terraform/options.schema.json +26 -0
- package/plugins/extract-ts/main.ts +1 -0
- package/plugins/phpscan/Cargo.lock +953 -0
- package/plugins/phpscan/Cargo.toml +19 -0
- package/plugins/phpscan/rustfmt.toml +1 -0
- package/plugins/portolan-go.wasm +0 -0
- package/plugins/pyplugin/catalog.py +24 -1
- package/plugins/verify-otel/options.schema.json +12 -0
- package/portolan.json +130 -3
- package/rules/builtin.json +218 -0
- package/schema/portolan.schema.json +880 -13
- package/scripts/README.md +3 -1
- package/scripts/delivery-presets.mjs +36 -3
- package/scripts/gen-likec4.mjs +149 -16
- package/scripts/gen-likec4.test.mjs +96 -0
- package/scripts/gen.mjs +30 -3
- package/scripts/gitops-example.test.mjs +108 -0
- package/scripts/host-plugins/fetch-argocd.mjs +338 -0
- package/scripts/host-plugins/fetch-argocd.options.json +49 -0
- package/scripts/host-plugins/fetch-argocd.test.mjs +274 -0
- package/scripts/host-plugins/fetch-bsr.mjs +1 -0
- package/scripts/host-plugins/fetch-csr.mjs +1 -0
- package/scripts/host-plugins/fetch-git.mjs +1 -0
- package/scripts/host-plugins/fetch-k8s.mjs +263 -0
- package/scripts/host-plugins/fetch-k8s.options.json +50 -0
- package/scripts/host-plugins/fetch-k8s.test.mjs +259 -0
- package/scripts/host-plugins/k8s-topology.mjs +183 -0
- package/scripts/json-format.mjs +192 -0
- package/scripts/json-format.test.mjs +97 -0
- package/scripts/local-api.mjs +216 -8
- package/scripts/local-api.test.mjs +88 -2
- package/scripts/local-discovery.mjs +12 -0
- package/scripts/manifest.mjs +10 -1
- package/scripts/plugin-host.mjs +23 -1
- package/scripts/plugins-fresh.mjs +97 -0
- package/scripts/plugins-fresh.test.mjs +64 -0
- package/scripts/run-builtin.mjs +26 -5
- package/scripts/schema.mjs +138 -19
- package/scripts/trace-trials.mjs +176 -0
- package/scripts/trace-trials.test.mjs +142 -0
- package/scripts/warning-policy.mjs +12 -6
- package/scripts/warning-policy.test.mjs +40 -3
- package/src/app/Breadcrumbs.test.ts +3 -0
- package/src/app/Breadcrumbs.tsx +3 -0
- package/src/app/CatalogApp.tsx +2 -0
- package/src/app/SidebarFooter.tsx +20 -4
- package/src/catalog-index.ts +25 -0
- package/src/catalog-model.ts +246 -1
- package/src/catalog-validation.ts +104 -2
- package/src/catalog.test.ts +40 -1
- package/src/components/ChannelRows.test.tsx +76 -1
- package/src/components/ChannelRows.tsx +223 -60
- package/src/components/DeploymentRows.tsx +131 -0
- package/src/components/DocsLinks.test.tsx +27 -0
- package/src/components/DocsLinks.tsx +56 -0
- package/src/components/FieldTree.tsx +5 -2
- package/src/components/HTTPDestinationEvidence.tsx +1 -1
- package/src/components/PluginIcon.tsx +77 -0
- package/src/components/ProblemRow.tsx +173 -153
- package/src/components/RuleMarks.tsx +22 -0
- package/src/components/ShapeRows.tsx +24 -20
- package/src/data.ts +21 -0
- package/src/enrich.test.ts +127 -3
- package/src/enrich.ts +104 -6
- package/src/er/ErCanvas.tsx +218 -12
- package/src/er/GroupNode.tsx +57 -0
- package/src/er/StoreHeader.tsx +1 -0
- package/src/er/layout.test.ts +85 -2
- package/src/er/layout.ts +140 -5
- package/src/er/spec.test.ts +17 -0
- package/src/er/spec.ts +23 -10
- package/src/flow/Recordings.test.tsx +52 -0
- package/src/flow/Recordings.tsx +236 -0
- package/src/flow/StepDetail.tsx +53 -0
- package/src/flow/TraceTrial.tsx +419 -0
- package/src/flow/examples.test.ts +33 -0
- package/src/flow/examples.ts +37 -0
- package/src/flow/outline.test.ts +28 -0
- package/src/flow/outline.ts +7 -2
- package/src/flow/trace-trial-resume.test.ts +42 -0
- package/src/flow/trace-trial-resume.ts +74 -0
- package/src/graph/elk.ts +78 -0
- package/src/index.css +24 -0
- package/src/landing/LandingPage.tsx +15 -11
- package/src/landing/ProductTour.tsx +6 -6
- package/src/lib/all-problems.ts +27 -17
- package/src/lib/centrality.test.ts +251 -0
- package/src/lib/centrality.ts +232 -0
- package/src/lib/confluence.test.ts +37 -0
- package/src/lib/confluence.ts +41 -0
- package/src/lib/context-color.ts +1 -1
- package/src/lib/deployment-drift.ts +22 -0
- package/src/lib/derive.ts +43 -69
- package/src/lib/environments.test.ts +83 -0
- package/src/lib/environments.ts +57 -0
- package/src/lib/integration-url.test.ts +30 -0
- package/src/lib/integration-url.ts +63 -0
- package/src/lib/kafka-ui.ts +3 -45
- package/src/lib/local-api.ts +96 -2
- package/src/lib/notion.ts +13 -0
- package/src/lib/plugin-index.json +2765 -0
- package/src/lib/plugins.test.ts +68 -0
- package/src/lib/plugins.ts +264 -0
- package/src/lib/problem-flows.test.ts +61 -0
- package/src/lib/problem-flows.ts +78 -0
- package/src/lib/problem-rules-cel.d.mts +43 -0
- package/src/lib/problem-rules-cel.mjs +407 -0
- package/src/lib/problem-rules.test.ts +287 -0
- package/src/lib/problem-rules.ts +271 -0
- package/src/lib/problem-subjects.ts +737 -0
- package/src/lib/rule-entries.ts +39 -0
- package/src/lib/{data-problems.test.ts → rules-data.test.ts} +18 -16
- package/src/lib/rules-deploy.test.ts +133 -0
- package/src/lib/{problems.test.ts → rules-edges.test.ts} +8 -3
- package/src/lib/{proto-problems.test.ts → rules-proto.test.ts} +9 -25
- package/src/lib/{wire-problems.test.ts → rules-wire.test.ts} +13 -11
- package/src/lib/rules.test.ts +51 -0
- package/src/lib/rules.ts +86 -0
- package/src/lib/shape.test.ts +32 -0
- package/src/lib/shape.ts +30 -6
- package/src/lib/tech.ts +16 -0
- package/src/lib/trace-project.test.ts +34 -0
- package/src/lib/trace-project.ts +50 -0
- package/src/lib/use-problems.ts +23 -0
- package/src/lib/warnings.test.ts +9 -0
- package/src/lib/warnings.ts +10 -10
- package/src/likec4/ids.test.ts +6 -2
- package/src/likec4/ids.ts +43 -0
- package/src/main.tsx +23 -0
- package/src/merge-deployments.test.ts +127 -0
- package/src/merge.test.ts +82 -0
- package/src/merge.ts +173 -9
- package/src/pages/AggregatePage.tsx +57 -7
- package/src/pages/ContextMap.tsx +45 -3
- package/src/pages/ContextPage.tsx +2 -0
- package/src/pages/EventPage.tsx +15 -5
- package/src/pages/FlowDetail.tsx +23 -2
- package/src/pages/GraphPage.tsx +40 -3
- package/src/pages/Overview.tsx +152 -12
- package/src/pages/PluginIndex.tsx +190 -0
- package/src/pages/Problems.tsx +396 -128
- package/src/pages/ServicePage.tsx +58 -2
- package/src/pages/Settings.tsx +28 -2
- package/src/pages/settings/AboutSettings.tsx +8 -1
- package/src/pages/settings/IntegrationsSettings.tsx +63 -17
- package/src/pages/settings/RecordingSettings.tsx +138 -0
- package/src/pages/settings/RulesSettings.tsx +825 -0
- package/src/routes.test.ts +9 -0
- package/src/routes.ts +22 -1
- package/src/lib/data-problems.ts +0 -314
- package/src/lib/proto-problems.ts +0 -237
- package/src/lib/wire-problems.ts +0 -342
|
@@ -10,11 +10,18 @@ import {
|
|
|
10
10
|
technologiesOf,
|
|
11
11
|
} from "../catalog";
|
|
12
12
|
import { flowRoles } from "../lib/derive";
|
|
13
|
+
import { bridges } from "../lib/centrality";
|
|
13
14
|
import { treeHref } from "../lib/source-link";
|
|
14
15
|
import { flowHealth } from "../lib/flow-tree";
|
|
15
16
|
import { adrsForService, isCurrent } from "../lib/adr";
|
|
16
17
|
import { ADR_ROW_COLUMNS, AdrRow } from "../components/AdrRow";
|
|
17
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
EVENT_ANCHOR,
|
|
20
|
+
OVERVIEW_ANCHOR,
|
|
21
|
+
SERVICE_ANCHOR,
|
|
22
|
+
aggregatePath,
|
|
23
|
+
paths,
|
|
24
|
+
} from "../routes";
|
|
18
25
|
import { Markdown } from "../components/Markdown";
|
|
19
26
|
import { middleTruncate, plural } from "../lib/format";
|
|
20
27
|
import { Empty, PageHeader, SectionTitle } from "../components/PageHeader";
|
|
@@ -31,6 +38,8 @@ import {
|
|
|
31
38
|
hasAsyncSpec,
|
|
32
39
|
} from "../components/AsyncApiReference";
|
|
33
40
|
import { ChannelRows } from "../components/ChannelRows";
|
|
41
|
+
import { DocsLinks } from "../components/DocsLinks";
|
|
42
|
+
import { DeploymentRows } from "../components/DeploymentRows";
|
|
34
43
|
import { CommandRows } from "../components/CommandRows";
|
|
35
44
|
import { ModuleSpec } from "../components/SourceDoc";
|
|
36
45
|
import { hasSchema, SchemaDocument } from "../components/SchemaDocument";
|
|
@@ -45,7 +54,11 @@ import { NotFound } from "./NotFound";
|
|
|
45
54
|
import { C4View } from "../likec4/C4View";
|
|
46
55
|
import { LevelSwitch } from "../likec4/levels";
|
|
47
56
|
import type { C4Level } from "../likec4/levels";
|
|
48
|
-
import {
|
|
57
|
+
import {
|
|
58
|
+
serviceDeployViewId,
|
|
59
|
+
serviceInsideViewId,
|
|
60
|
+
serviceViewId,
|
|
61
|
+
} from "../likec4/ids";
|
|
49
62
|
import { index } from "../data";
|
|
50
63
|
import { storesOfService } from "../lib/data-model";
|
|
51
64
|
import { ErCanvas } from "../er/ErCanvas";
|
|
@@ -112,6 +125,11 @@ export function ServicePage() {
|
|
|
112
125
|
const flows = flowRoles(catalog, service.id);
|
|
113
126
|
const tree = treeHref(service.path, service, allRepos(catalog));
|
|
114
127
|
const owners = ownersOf(service);
|
|
128
|
+
const deployments = index.deploymentsByService.get(service.id) ?? [];
|
|
129
|
+
// The service as a road: the pairs of contexts it stands between, if any.
|
|
130
|
+
// Derived from the whole estate, so it is computed here and not carried
|
|
131
|
+
// on the service, which knows only its own edges.
|
|
132
|
+
const road = bridges(catalog).find((b) => b.id === service.id) ?? null;
|
|
115
133
|
const adrs = adrsForService(catalog, service.id, context.id);
|
|
116
134
|
const current = adrs.filter(isCurrent);
|
|
117
135
|
const retired = adrs.filter((a) => !isCurrent(a));
|
|
@@ -168,6 +186,17 @@ export function ServicePage() {
|
|
|
168
186
|
right={
|
|
169
187
|
<>
|
|
170
188
|
<span className="chip-lg">{componentKind(service)}</span>
|
|
189
|
+
{road ? (
|
|
190
|
+
<Link
|
|
191
|
+
to={`${paths.overview()}#${OVERVIEW_ANCHOR.bridges}`}
|
|
192
|
+
className="chip-lg border-line text-muted hover:text-ink"
|
|
193
|
+
title={`${Math.round(road.betweenness * 100)}% of the shortest paths between other services run through this one — see the bridges on the overview`}
|
|
194
|
+
>
|
|
195
|
+
bridge
|
|
196
|
+
<span className="text-muted">·</span>
|
|
197
|
+
{road.between.map((pair) => `${pair.a} ↔ ${pair.b}`).join(", ")}
|
|
198
|
+
</Link>
|
|
199
|
+
) : null}
|
|
171
200
|
<ContextPill id={context.id} name={context.name} />
|
|
172
201
|
</>
|
|
173
202
|
}
|
|
@@ -186,6 +215,7 @@ export function ServicePage() {
|
|
|
186
215
|
open ↗
|
|
187
216
|
</a>
|
|
188
217
|
) : null}
|
|
218
|
+
<DocsLinks name={service.name} />
|
|
189
219
|
{/* Who to ask, read out of CODEOWNERS. A handle is copied rather
|
|
190
220
|
than linked: it is what a reviewer types into a request, and a
|
|
191
221
|
link to a team page is a page most readers cannot open. */}
|
|
@@ -307,6 +337,32 @@ export function ServicePage() {
|
|
|
307
337
|
/>
|
|
308
338
|
</section>
|
|
309
339
|
) : null}
|
|
340
|
+
{/* Where it runs, read from the deployer's snapshot. Beside the
|
|
341
|
+
commands because it answers the next question after "how do
|
|
342
|
+
I build it": which commit stands where. Absent entirely when
|
|
343
|
+
no snapshot places it - a heading over an empty list would
|
|
344
|
+
claim the service runs nowhere, and nobody made that claim. */}
|
|
345
|
+
{deployments.length > 0 ? (
|
|
346
|
+
<section
|
|
347
|
+
id={SERVICE_ANCHOR.deployments}
|
|
348
|
+
className="mt-section max-w-table"
|
|
349
|
+
>
|
|
350
|
+
<SectionTitle anchor={SERVICE_ANCHOR.deployments}>
|
|
351
|
+
Where it runs
|
|
352
|
+
</SectionTitle>
|
|
353
|
+
<DeploymentRows deployments={deployments} />
|
|
354
|
+
{/* The same rows as a picture: the service's instances with
|
|
355
|
+
the environment, cluster and namespace drawn as the
|
|
356
|
+
frames they are. Under the rows, because the rows carry
|
|
357
|
+
the commit and the images and the picture carries where. */}
|
|
358
|
+
<div className="mt-3">
|
|
359
|
+
<C4View
|
|
360
|
+
viewId={serviceDeployViewId(service)}
|
|
361
|
+
height={220}
|
|
362
|
+
/>
|
|
363
|
+
</div>
|
|
364
|
+
</section>
|
|
365
|
+
) : null}
|
|
310
366
|
{showDomain ? (
|
|
311
367
|
<section
|
|
312
368
|
id={SERVICE_ANCHOR.aggregates}
|
package/src/pages/Settings.tsx
CHANGED
|
@@ -48,12 +48,16 @@ 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 { pluginByName, pluginIcon } from "../lib/plugins";
|
|
52
|
+
import { PluginIcon } from "../components/PluginIcon";
|
|
51
53
|
import { bare, sourceHref, treeHref } from "../lib/source-link";
|
|
52
54
|
import { paths } from "../routes";
|
|
53
55
|
import { CapabilityEmpty, Empty, SectionTitle } from "../components/PageHeader";
|
|
54
56
|
import { Modal } from "../components/Overlay";
|
|
55
57
|
import { MachineDocs } from "../components/MachineDocs";
|
|
56
58
|
import { DeliverySettings } from "./settings/DeliverySettings";
|
|
59
|
+
import { RecordingSettings } from "./settings/RecordingSettings";
|
|
60
|
+
import { RulesSettings } from "./settings/RulesSettings";
|
|
57
61
|
import { PreferencesSettings } from "./settings/PreferencesSettings";
|
|
58
62
|
import { AboutSettings } from "./settings/AboutSettings";
|
|
59
63
|
import { IntegrationsSettings } from "./settings/IntegrationsSettings";
|
|
@@ -518,10 +522,15 @@ function PluginsList() {
|
|
|
518
522
|
const runSteps = setupInfo.run?.steps.filter((step) => step.plugin === plugin.name) ?? [];
|
|
519
523
|
const health = plugin.stepCount === 0 ? "unchecked" : healthFor(runSteps, declared.length, setupInfo);
|
|
520
524
|
const outputs = [...new Set(runSteps.flatMap((step) => step.files))];
|
|
525
|
+
// What the plugin says of itself, when it is one the package ships.
|
|
526
|
+
// A plugin the manifest declares by its own `process` is not on the
|
|
527
|
+
// index, and the row says nothing rather than something made up.
|
|
528
|
+
const shipped = pluginByName(plugin.name);
|
|
521
529
|
return (
|
|
522
530
|
<details key={plugin.name} id={`plugin-${plugin.name}`} className="group scroll-mt-4 border-t border-line">
|
|
523
531
|
<summary className="flex cursor-pointer list-none items-center gap-2.5 px-3 py-1.5 hover:bg-surface/60">
|
|
524
532
|
{health === "healthy" ? <HealthDot health={health} /> : null}
|
|
533
|
+
<PluginIcon icon={pluginIcon(plugin.name, shipped?.category)} className="text-muted" />
|
|
525
534
|
<span className="mono truncate text-ink" title={plugin.name}>{plugin.name}</span>
|
|
526
535
|
<Runtime plugin={plugin} icon />
|
|
527
536
|
{health === "healthy" ? null : <HealthBadge health={health} />}
|
|
@@ -538,6 +547,7 @@ function PluginsList() {
|
|
|
538
547
|
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.4fr)_minmax(14rem,1fr)]">
|
|
539
548
|
<div><div className="label mb-2">last run</div><PipelineSteps steps={runSteps} /></div>
|
|
540
549
|
<div className="space-y-4">
|
|
550
|
+
{shipped ? <div><div className="label mb-2">what it reads</div><p className="text-muted">{shipped.summary}</p><Link to={paths.plugin(plugin.name)} className="mono mt-1 inline-block text-accent hover:underline">options and source →</Link></div> : null}
|
|
541
551
|
<div><div className="label mb-2">runtime</div><p className="mono"><Runtime plugin={plugin} /></p></div>
|
|
542
552
|
<div><div className="label mb-2">used by</div>{plugin.projectIds.length > 0 ? <div className="flex flex-wrap gap-1.5">{plugin.projectIds.map((id) => <Link key={id} to={`${paths.settingsProjects()}#project-${id}`} className="chip border-line-strong hover:border-accent hover:text-accent">{projectNames.get(id) ?? id}</Link>)}</div> : <p className="mono text-muted">{plugin.stepCount > 0 ? "Estate-wide catalog" : "No pipeline step uses this plugin."}</p>}</div>
|
|
543
553
|
{outputs.length > 0 ? <div><div className="label mb-2">outputs</div><ul className="mono space-y-1 text-muted">{outputs.map((output) => <li key={output} className="truncate"><FileLink path={output} /></li>)}</ul></div> : null}
|
|
@@ -642,6 +652,8 @@ const CAPABILITIES: Record<string, { title: string; summary: string }> = {
|
|
|
642
652
|
"rust-domain": { title: "Rust domain model", summary: "Aggregates, entities, value objects and domain events." },
|
|
643
653
|
"java-domain": { title: "Java domain model", summary: "Aggregates, entities, value objects and domain events." },
|
|
644
654
|
"django-domain": { title: "Django data model", summary: "Models and their relationships." },
|
|
655
|
+
"laravel-domain": { title: "Laravel data model", summary: "Eloquent models, events and listeners, routes and controllers." },
|
|
656
|
+
"php-ddd": { title: "PHP DDD model", summary: "Bounded contexts laid out by module and hexagonal layer: aggregates, value objects, domain events, commands, queries, subscribers, Doctrine mappings." },
|
|
645
657
|
openapi: { title: "HTTP API contract", summary: "Operations and messages declared by OpenAPI or Swagger." },
|
|
646
658
|
wsdl: { title: "SOAP contract", summary: "Services, operations and messages declared by WSDL." },
|
|
647
659
|
"http-clients": { title: "Outbound integrations", summary: "HTTP and SOAP calls, provider branches and code flows." },
|
|
@@ -773,7 +785,7 @@ function Wizard({ open, initialSource, onClose, onAdded, onRunStarted }: { open:
|
|
|
773
785
|
function configure(found: Discovery, commit: string, selectedSourcePath: string, projectId?: string) {
|
|
774
786
|
setDiscovery(found);
|
|
775
787
|
const plugins = found.detections.filter((item) => item.selected).map((item) => item.plugin);
|
|
776
|
-
const domainModel = plugins.some((plugin) => ["go-domain", "ts-domain", "rust-domain", "java-domain", "django-domain"].includes(plugin));
|
|
788
|
+
const domainModel = plugins.some((plugin) => ["go-domain", "ts-domain", "rust-domain", "java-domain", "django-domain", "laravel-domain", "php-ddd"].includes(plugin));
|
|
777
789
|
setDraft({ source, root: found.root, repository, ref, commit, sourcePath: selectedSourcePath, ...found.defaults, ...(projectId ? { id: projectId } : {}), contextName: found.defaults.group ? found.defaults.group.replace(/(^|-)([a-z])/g, (_, gap, letter) => `${gap ? " " : ""}${letter.toUpperCase()}`) : "", contextSummary: "", classification: "supporting", groupKind: domainModel ? "bounded-context" : "system", componentKind: domainModel ? "service" : "application", replaceStarter: Boolean(starter), plugins });
|
|
778
790
|
setStage("configure");
|
|
779
791
|
}
|
|
@@ -1020,11 +1032,23 @@ const SETTINGS_LINKS = [
|
|
|
1020
1032
|
["Projects", paths.settingsProjects()],
|
|
1021
1033
|
["Pipeline", paths.settingsPipeline()],
|
|
1022
1034
|
["Delivery", paths.settingsDelivery()],
|
|
1035
|
+
["Recordings", paths.settingsRecordings()],
|
|
1036
|
+
["Rules", paths.settingsRules()],
|
|
1023
1037
|
["Integrations", paths.settingsIntegrations()],
|
|
1024
1038
|
["Preferences", paths.settingsPreferences()],
|
|
1025
1039
|
["About", paths.settingsAbout()],
|
|
1026
1040
|
] as const;
|
|
1027
1041
|
|
|
1042
|
+
function RecordingsRoute({ local }: { local: boolean }) {
|
|
1043
|
+
const setupInfo = useSetup();
|
|
1044
|
+
return (
|
|
1045
|
+
<section>
|
|
1046
|
+
<SectionTitle right={local ? "reviewed before writing" : "local mode required"}>Recordings</SectionTitle>
|
|
1047
|
+
<RecordingSettings local={local} projects={setupInfo.projects} />
|
|
1048
|
+
</section>
|
|
1049
|
+
);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1028
1052
|
function SettingsNav() {
|
|
1029
1053
|
return (
|
|
1030
1054
|
<nav className="mt-5 border-b border-line" aria-label="Settings sections">
|
|
@@ -1159,7 +1183,7 @@ function PipelineSettings() {
|
|
|
1159
1183
|
) : (
|
|
1160
1184
|
<>
|
|
1161
1185
|
<PluginsList />
|
|
1162
|
-
<p className="mono mt-2 text-muted">WASM runs without network or environment access, and a generator without a filesystem. A host process runs with the permissions of the build; a plugin in the host is Portolan's own code doing what needs a socket, such as fetching another repository
|
|
1186
|
+
<p className="mono mt-2 text-muted">WASM runs without network or environment access, and a generator without a filesystem. A host process runs with the permissions of the build; a plugin in the host is Portolan's own code doing what needs a socket, such as fetching another repository. Every plugin the package ships, with what it reads and the options it takes, is on the <Link to={paths.plugins()} className="text-accent hover:underline">plugin reference</Link>.</p>
|
|
1163
1187
|
</>
|
|
1164
1188
|
)}
|
|
1165
1189
|
</section>
|
|
@@ -1201,6 +1225,8 @@ function SettingsContent({ local, onAdd, onRemove, onGenerate }: { local: boolea
|
|
|
1201
1225
|
<Route path="projects" element={<ProjectsSettings local={local} onAdd={onAdd} onRemove={onRemove} />} />
|
|
1202
1226
|
<Route path="pipeline" element={<PipelineSettings />} />
|
|
1203
1227
|
<Route path="delivery" element={<section><SectionTitle right={local ? "preview before writing" : "local mode required"}>Delivery presets</SectionTitle><DeliverySettings local={local} /></section>} />
|
|
1228
|
+
<Route path="recordings" element={<RecordingsRoute local={local} />} />
|
|
1229
|
+
<Route path="rules" element={<RulesSettings local={local} />} />
|
|
1204
1230
|
<Route path="integrations" element={<IntegrationsSettings />} />
|
|
1205
1231
|
<Route path="preferences" element={<PreferencesSettings />} />
|
|
1206
1232
|
<Route path="about" element={<AboutSettings />} />
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ArrowRight, BookOpen, CircleDot, GitBranch, ShieldCheck } from "lucide-react";
|
|
1
|
+
import { ArrowRight, BookOpen, CircleDot, GitBranch, Puzzle, ShieldCheck } from "lucide-react";
|
|
2
2
|
import type { Variants } from "motion/react";
|
|
3
3
|
import { Link } from "react-router";
|
|
4
4
|
|
|
@@ -94,6 +94,13 @@ export function AboutSettings() {
|
|
|
94
94
|
>
|
|
95
95
|
<div className="label mb-2">resources</div>
|
|
96
96
|
<div className="divide-y divide-line">
|
|
97
|
+
<Link
|
|
98
|
+
to={paths.plugins()}
|
|
99
|
+
className="flex items-center gap-2 py-2.5 text-muted transition-colors hover:text-ink"
|
|
100
|
+
>
|
|
101
|
+
<Puzzle size={15} aria-hidden className="shrink-0" />
|
|
102
|
+
Plugin reference
|
|
103
|
+
</Link>
|
|
97
104
|
<a
|
|
98
105
|
href={PRODUCT_README}
|
|
99
106
|
target="_blank"
|
|
@@ -1,21 +1,45 @@
|
|
|
1
1
|
import { ExternalLink, Save, Trash2 } from "lucide-react";
|
|
2
2
|
import { useState } from "react";
|
|
3
|
+
import type { StoreApi, UseBoundStore } from "zustand";
|
|
3
4
|
import { SectionTitle } from "../../components/PageHeader";
|
|
4
5
|
import { TechIcon } from "../../components/TechIcon";
|
|
5
|
-
import {
|
|
6
|
+
import { useConfluence } from "../../lib/confluence";
|
|
7
|
+
import { normalizeIntegrationUrl } from "../../lib/integration-url";
|
|
8
|
+
import type { IntegrationState } from "../../lib/integration-url";
|
|
9
|
+
import { useKafkaUi } from "../../lib/kafka-ui";
|
|
10
|
+
import { useNotion } from "../../lib/notion";
|
|
6
11
|
import { techGlyph } from "../../lib/tech";
|
|
7
12
|
|
|
8
13
|
const FIELD =
|
|
9
14
|
"mono w-full rounded-control border border-line bg-canvas px-3 py-2 text-ink outline-none focus:border-accent";
|
|
10
15
|
|
|
11
|
-
function
|
|
12
|
-
|
|
13
|
-
|
|
16
|
+
function IntegrationCard({
|
|
17
|
+
name,
|
|
18
|
+
brand,
|
|
19
|
+
summary,
|
|
20
|
+
label,
|
|
21
|
+
placeholder,
|
|
22
|
+
hint,
|
|
23
|
+
store,
|
|
24
|
+
}: {
|
|
25
|
+
/** The heading, and what the open button says. */
|
|
26
|
+
name: string;
|
|
27
|
+
/** The brand whose mark sits beside the heading. */
|
|
28
|
+
brand: string;
|
|
29
|
+
summary: string;
|
|
30
|
+
label: string;
|
|
31
|
+
placeholder: string;
|
|
32
|
+
/** What a valid URL means: shown under the field. */
|
|
33
|
+
hint: string;
|
|
34
|
+
store: UseBoundStore<StoreApi<IntegrationState>>;
|
|
35
|
+
}) {
|
|
36
|
+
const configured = store((state) => state.url);
|
|
37
|
+
const setUrl = store((state) => state.setUrl);
|
|
14
38
|
const [draft, setDraft] = useState(configured);
|
|
15
|
-
const normalized =
|
|
39
|
+
const normalized = normalizeIntegrationUrl(draft);
|
|
16
40
|
const valid = normalized !== null;
|
|
17
41
|
const dirty = valid && normalized !== configured;
|
|
18
|
-
const glyph = techGlyph(
|
|
42
|
+
const glyph = techGlyph(brand);
|
|
19
43
|
|
|
20
44
|
const save = () => {
|
|
21
45
|
if (normalized === null) return;
|
|
@@ -36,19 +60,17 @@ function KafkaUiCard() {
|
|
|
36
60
|
</span>
|
|
37
61
|
<div className="min-w-0 flex-1">
|
|
38
62
|
<div className="flex flex-wrap items-center gap-2">
|
|
39
|
-
<h2 className="font-semibold text-ink">
|
|
63
|
+
<h2 className="font-semibold text-ink">{name}</h2>
|
|
40
64
|
<span className={`chip ${configured ? "status-verified" : "text-muted"}`}>
|
|
41
65
|
{configured ? "configured" : "not configured"}
|
|
42
66
|
</span>
|
|
43
67
|
</div>
|
|
44
|
-
<p className="mt-1 text-muted">
|
|
45
|
-
Opens catalogued Kafka topics in your Kafbat Kafka UI installation.
|
|
46
|
-
</p>
|
|
68
|
+
<p className="mt-1 text-muted">{summary}</p>
|
|
47
69
|
</div>
|
|
48
70
|
</div>
|
|
49
71
|
|
|
50
72
|
<label className="mt-4 block">
|
|
51
|
-
<span className="label mb-1.5 block">
|
|
73
|
+
<span className="label mb-1.5 block">{label}</span>
|
|
52
74
|
<input
|
|
53
75
|
type="url"
|
|
54
76
|
value={draft}
|
|
@@ -56,7 +78,7 @@ function KafkaUiCard() {
|
|
|
56
78
|
onKeyDown={(event) => {
|
|
57
79
|
if (event.key === "Enter" && dirty) save();
|
|
58
80
|
}}
|
|
59
|
-
placeholder=
|
|
81
|
+
placeholder={placeholder}
|
|
60
82
|
spellCheck={false}
|
|
61
83
|
autoComplete="off"
|
|
62
84
|
aria-invalid={!valid}
|
|
@@ -64,9 +86,7 @@ function KafkaUiCard() {
|
|
|
64
86
|
/>
|
|
65
87
|
</label>
|
|
66
88
|
<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."}
|
|
89
|
+
{valid ? hint : "Enter an http:// or https:// URL."}
|
|
70
90
|
</p>
|
|
71
91
|
|
|
72
92
|
<div className="mt-4 flex flex-wrap items-center gap-2 border-t border-line pt-4">
|
|
@@ -87,7 +107,7 @@ function KafkaUiCard() {
|
|
|
87
107
|
rel="noreferrer"
|
|
88
108
|
className="tbtn px-3 py-1.5 text-ink"
|
|
89
109
|
>
|
|
90
|
-
Open
|
|
110
|
+
Open {name}
|
|
91
111
|
<ExternalLink size={14} aria-hidden />
|
|
92
112
|
</a>
|
|
93
113
|
<button
|
|
@@ -110,7 +130,33 @@ export function IntegrationsSettings() {
|
|
|
110
130
|
<section>
|
|
111
131
|
<SectionTitle right="stored in this browser">Integrations</SectionTitle>
|
|
112
132
|
<div className="grid gap-grid lg:grid-cols-2">
|
|
113
|
-
<
|
|
133
|
+
<IntegrationCard
|
|
134
|
+
name="Kafka UI"
|
|
135
|
+
brand="Kafka"
|
|
136
|
+
summary="Opens catalogued Kafka topics in your Kafbat Kafka UI installation."
|
|
137
|
+
label="Kafka UI cluster URL"
|
|
138
|
+
placeholder="https://kafka.example/ui/clusters/production"
|
|
139
|
+
hint="Use a cluster or topics URL to open the exact topic. A plain installation URL opens Kafka UI itself."
|
|
140
|
+
store={useKafkaUi}
|
|
141
|
+
/>
|
|
142
|
+
<IntegrationCard
|
|
143
|
+
name="Confluence"
|
|
144
|
+
brand="Confluence"
|
|
145
|
+
summary="Searches your wiki for a service or context by name, from its page."
|
|
146
|
+
label="Confluence site or space URL"
|
|
147
|
+
placeholder="https://acme.atlassian.net/wiki/spaces/ARCH"
|
|
148
|
+
hint="A space URL searches that space only. A site URL searches the whole wiki. Cloud and Server are both understood."
|
|
149
|
+
store={useConfluence}
|
|
150
|
+
/>
|
|
151
|
+
<IntegrationCard
|
|
152
|
+
name="Notion"
|
|
153
|
+
brand="Notion"
|
|
154
|
+
summary="Opens the Notion workspace where the documentation lives, from a service or context page."
|
|
155
|
+
label="Notion workspace or teamspace URL"
|
|
156
|
+
placeholder="https://www.notion.so/acme"
|
|
157
|
+
hint="Notion has no search address, so every page opens this URL as it is."
|
|
158
|
+
store={useNotion}
|
|
159
|
+
/>
|
|
114
160
|
</div>
|
|
115
161
|
</section>
|
|
116
162
|
);
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Recordings, for the whole estate: what the catalog was verified against,
|
|
2
|
+
// and a place to add one without first finding the flow it shows.
|
|
3
|
+
//
|
|
4
|
+
// A recording added here goes to the project the reader names. The trial
|
|
5
|
+
// then says which flows it showed - which is how a reader with an export
|
|
6
|
+
// from the collector and no idea which flow it is finds out.
|
|
7
|
+
|
|
8
|
+
import { useMemo, useRef, useState } from "react";
|
|
9
|
+
import { Disc3, Upload } from "lucide-react";
|
|
10
|
+
import { Link } from "react-router";
|
|
11
|
+
import { catalog } from "../../data";
|
|
12
|
+
import { useToastStore } from "../../app/toast";
|
|
13
|
+
import { TraceTrialPanel } from "../../flow/TraceTrial";
|
|
14
|
+
import { forgetTraceTrial, recallTraceTrial, rememberTraceTrial, sessionStore } from "../../flow/trace-trial-resume";
|
|
15
|
+
import { plural } from "../../lib/format";
|
|
16
|
+
import { startTraceTrial } from "../../lib/local-api";
|
|
17
|
+
import type { SetupProject } from "../../lib/setup-info";
|
|
18
|
+
import { paths } from "../../routes";
|
|
19
|
+
|
|
20
|
+
const FIELD = "w-full rounded-control border border-line bg-canvas px-2.5 py-1.5 text-ink";
|
|
21
|
+
const SCOPE = "settings";
|
|
22
|
+
|
|
23
|
+
/** Every recording the catalog names, with the flows it is an example of. */
|
|
24
|
+
function recordingsInCatalog() {
|
|
25
|
+
const byFile = new Map<string, { traces: Set<string>; flows: Map<string, string> }>();
|
|
26
|
+
for (const flow of catalog.flows) {
|
|
27
|
+
for (const example of flow.examples ?? []) {
|
|
28
|
+
const entry = byFile.get(example.recording) ?? { traces: new Set(), flows: new Map() };
|
|
29
|
+
entry.traces.add(example.traceId);
|
|
30
|
+
entry.flows.set(flow.slug, flow.name);
|
|
31
|
+
byFile.set(example.recording, entry);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return [...byFile.entries()]
|
|
35
|
+
.map(([recording, entry]) => ({ recording, traces: entry.traces.size, flows: [...entry.flows.entries()] }))
|
|
36
|
+
.sort((a, b) => a.recording.localeCompare(b.recording));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function RecordingSettings({ local, projects }: { local: boolean; projects: readonly SetupProject[] }) {
|
|
40
|
+
const say = useToastStore((s) => s.say);
|
|
41
|
+
const input = useRef<HTMLInputElement | null>(null);
|
|
42
|
+
const [projectId, setProjectId] = useState(projects[0]?.id ?? "");
|
|
43
|
+
// Picked back up from before a reload: keeping a recording reloads the
|
|
44
|
+
// page twice, and the panel outlives it.
|
|
45
|
+
const [resumed] = useState(() => recallTraceTrial(sessionStore(), SCOPE));
|
|
46
|
+
const [runId, setRunId] = useState<string | null>(resumed?.runId ?? null);
|
|
47
|
+
const [busy, setBusy] = useState(false);
|
|
48
|
+
const known = useMemo(recordingsInCatalog, []);
|
|
49
|
+
|
|
50
|
+
async function upload(file: File) {
|
|
51
|
+
if (!projectId) return;
|
|
52
|
+
setBusy(true);
|
|
53
|
+
try {
|
|
54
|
+
const started = await startTraceTrial(file, projectId);
|
|
55
|
+
rememberTraceTrial(sessionStore(), SCOPE, { runId: started.runId, writeRunId: null });
|
|
56
|
+
setRunId(started.runId);
|
|
57
|
+
} catch (cause) {
|
|
58
|
+
say(cause instanceof Error ? cause.message : String(cause));
|
|
59
|
+
} finally {
|
|
60
|
+
setBusy(false);
|
|
61
|
+
if (input.current) input.current.value = "";
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div className="flex flex-col gap-5">
|
|
67
|
+
<div className="rounded-card border border-line p-card shadow-xs">
|
|
68
|
+
<div className="flex items-center gap-2 font-semibold text-ink">
|
|
69
|
+
<Disc3 size={16} className="text-accent" /> Add a recording
|
|
70
|
+
</div>
|
|
71
|
+
<p className="mt-1 max-w-prose text-muted">
|
|
72
|
+
An OTLP JSON export - one batch per file or one per line, as a collector's file exporter writes it. The generator runs over a copy of the workspace with it in place and shows which flows it verified, what it added to them and which names it could not place, before the recording is kept beside the project.
|
|
73
|
+
</p>
|
|
74
|
+
{local ? (
|
|
75
|
+
<div className="mt-3 flex flex-wrap items-end gap-3">
|
|
76
|
+
<label className="min-w-56">
|
|
77
|
+
<span className="label mb-1.5 block">project</span>
|
|
78
|
+
<select className={FIELD} value={projectId} onChange={(e) => setProjectId(e.target.value)}>
|
|
79
|
+
{projects.length ? null : <option value="">no projects in portolan.json</option>}
|
|
80
|
+
{projects.map((p) => <option key={p.id} value={p.id}>{p.name} · {p.root}</option>)}
|
|
81
|
+
</select>
|
|
82
|
+
</label>
|
|
83
|
+
<button type="button" className="product-primary" disabled={busy || !projectId || runId !== null} onClick={() => input.current?.click()}>
|
|
84
|
+
<Upload size={14} /> Choose a file
|
|
85
|
+
</button>
|
|
86
|
+
<input
|
|
87
|
+
ref={input}
|
|
88
|
+
type="file"
|
|
89
|
+
accept=".jsonl,.json,.ndjson,application/json"
|
|
90
|
+
className="hidden"
|
|
91
|
+
onChange={(e) => { const file = e.target.files?.[0]; if (file) void upload(file); }}
|
|
92
|
+
/>
|
|
93
|
+
</div>
|
|
94
|
+
) : (
|
|
95
|
+
<div className="empty mt-3">
|
|
96
|
+
Start <code>portolan dev</code> to add recordings in this repository.
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
99
|
+
{runId ? (
|
|
100
|
+
<div className="mt-4 border-t border-line pt-4">
|
|
101
|
+
<TraceTrialPanel
|
|
102
|
+
key={runId}
|
|
103
|
+
runId={runId}
|
|
104
|
+
initialWriteRunId={resumed?.runId === runId ? resumed.writeRunId : null}
|
|
105
|
+
onWriteStarted={(writeRunId) => rememberTraceTrial(sessionStore(), SCOPE, { runId, writeRunId })}
|
|
106
|
+
onDone={() => { forgetTraceTrial(sessionStore()); setRunId(null); }}
|
|
107
|
+
/>
|
|
108
|
+
</div>
|
|
109
|
+
) : null}
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<section>
|
|
113
|
+
<div className="label mb-2">recordings the catalog was verified against · {known.length}</div>
|
|
114
|
+
{known.length ? (
|
|
115
|
+
<div className="divide-y divide-line rounded-control border border-line">
|
|
116
|
+
{known.map((entry) => (
|
|
117
|
+
<div key={entry.recording} className="grid gap-1 px-3 py-2 sm:grid-cols-[1fr_auto]">
|
|
118
|
+
<div className="min-w-0">
|
|
119
|
+
<div className="mono truncate text-ink" title={entry.recording}>{entry.recording}</div>
|
|
120
|
+
<div className="mt-0.5 flex flex-wrap gap-x-2 text-muted">
|
|
121
|
+
{entry.flows.map(([slug, name]) => (
|
|
122
|
+
<Link key={slug} to={paths.flow(slug)} className="hover:underline">{name}</Link>
|
|
123
|
+
))}
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
<span className="mono text-muted sm:text-right">{entry.traces} {plural(entry.traces, "trace")} kept as {plural(entry.traces, "an example", "examples")}</span>
|
|
127
|
+
</div>
|
|
128
|
+
))}
|
|
129
|
+
</div>
|
|
130
|
+
) : (
|
|
131
|
+
<div className="rounded-control border border-line bg-surface px-3 py-3 text-muted">
|
|
132
|
+
No flow in this catalog carries a recording yet. A verify step with the otel plugin reads them; adding one above writes that step for you.
|
|
133
|
+
</div>
|
|
134
|
+
)}
|
|
135
|
+
</section>
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|