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