@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/pages/Problems.tsx
CHANGED
|
@@ -1,175 +1,443 @@
|
|
|
1
1
|
// Everywhere the chart draws an arrow into open water.
|
|
2
2
|
//
|
|
3
|
-
// There is no score
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// There is no score. An edge either lands somewhere the catalog knows about
|
|
4
|
+
// or it does not, and the page is ordered the way a reader can act on it:
|
|
5
|
+
// errors before warnings, and under each rule its rows, so the words for
|
|
6
|
+
// what is wrong and what to do are said once and the rows are the edges.
|
|
7
|
+
// Every filter is in the URL, so a page a reader has narrowed is a link.
|
|
6
8
|
|
|
7
9
|
import { useDocumentTitle } from "../app/title";
|
|
8
10
|
import { useMemo, useState } from "react";
|
|
9
|
-
import { useSearchParams } from "react-router";
|
|
11
|
+
import { Link, useSearchParams } from "react-router";
|
|
12
|
+
import { ChevronDown, Search, X } from "lucide-react";
|
|
13
|
+
import { useQuery } from "@tanstack/react-query";
|
|
10
14
|
import { catalog, index } from "../data";
|
|
15
|
+
import { useToastStore } from "../app/toast";
|
|
11
16
|
import { edgeCount } from "../lib/derive";
|
|
12
|
-
import {
|
|
13
|
-
import { contextVar } from "../lib/context-color";
|
|
17
|
+
import type { Problem } from "../lib/derive";
|
|
18
|
+
import { contextVar, ctxStyle } from "../lib/context-color";
|
|
14
19
|
import { absoluteTime, plural, relativeTime } from "../lib/format";
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
20
|
+
import { problemRules as readProblemRules, saveProblemRules } from "../lib/local-api";
|
|
21
|
+
import { SUBJECTS, useProblemRules, useRuleEntries } from "../lib/problem-rules";
|
|
22
|
+
import type { ProblemRule, RuleSeverity, RuleSubject } from "../lib/problem-rules";
|
|
23
|
+
import { localStatusQuery } from "../lib/queries";
|
|
24
|
+
import { switched } from "../lib/rule-entries";
|
|
25
|
+
import { useProblemEvaluation } from "../lib/use-problems";
|
|
26
|
+
import { paths, servicePath } from "../routes";
|
|
17
27
|
import { CatEmptyState } from "../components/CatIllustration";
|
|
28
|
+
import { KindIcon } from "../components/kind";
|
|
29
|
+
import { ICON_OF, ProblemRow } from "../components/ProblemRow";
|
|
30
|
+
|
|
31
|
+
const CONTROL = "mono rounded-control border border-line bg-canvas px-2.5 py-1 text-ink outline-none focus:border-accent";
|
|
32
|
+
const FIELD = "mono w-full rounded-control border border-line bg-canvas px-3 py-2 text-ink outline-none focus:border-accent";
|
|
33
|
+
const SUBJECT_NAMES = Object.keys(SUBJECTS) as RuleSubject[];
|
|
34
|
+
const SEVERITY_CHIP: Record<RuleSeverity, string> = {
|
|
35
|
+
error: "chip status-unresolved",
|
|
36
|
+
warning: "chip status-declared",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type By = "rule" | "service";
|
|
18
40
|
|
|
19
41
|
export function Problems() {
|
|
20
42
|
useDocumentTitle("Problems");
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
43
|
+
// Every filter lives in the URL: the sidebar arrives with `?context=`, the
|
|
44
|
+
// Rules page with `?rule=`, and a reader who has narrowed the page hands
|
|
45
|
+
// the address to a colleague and they see the same rows.
|
|
46
|
+
const [params, setParams] = useSearchParams();
|
|
47
|
+
const rules = useProblemRules();
|
|
48
|
+
const { problems: all, failures } = useProblemEvaluation();
|
|
49
|
+
const query = params.get("q") ?? "";
|
|
50
|
+
const rule = params.get("rule") ?? "all";
|
|
51
|
+
const over = (params.get("subject") ?? "all") as RuleSubject | "all";
|
|
52
|
+
const severity = (params.get("severity") ?? "all") as RuleSeverity | "all";
|
|
53
|
+
const by = (params.get("by") === "service" ? "service" : "rule") as By;
|
|
54
|
+
const contexts = useMemo(() => new Set(params.getAll("context").filter((id) => catalog.contexts.some((c) => c.id === id))), [params]);
|
|
55
|
+
|
|
56
|
+
const set = (key: string, value: string | string[]) => {
|
|
57
|
+
const next = new URLSearchParams(params);
|
|
58
|
+
next.delete(key);
|
|
59
|
+
const values = Array.isArray(value) ? value : [value];
|
|
60
|
+
for (const item of values) if (item && item !== "all") next.append(key, item);
|
|
61
|
+
setParams(next, { replace: true });
|
|
62
|
+
};
|
|
63
|
+
const toggleContext = (id: string) => {
|
|
64
|
+
const next = new Set(contexts);
|
|
65
|
+
if (next.has(id)) next.delete(id);
|
|
66
|
+
else next.add(id);
|
|
67
|
+
set("context", [...next]);
|
|
68
|
+
};
|
|
69
|
+
const filtered = Boolean(query || rule !== "all" || over !== "all" || severity !== "all" || contexts.size > 0);
|
|
70
|
+
const clear = () => setParams(new URLSearchParams(params.has("catalog") ? { catalog: params.get("catalog")! } : {}), { replace: true });
|
|
71
|
+
|
|
38
72
|
// How many edges there were to resolve at all. Zero problems out of zero
|
|
39
73
|
// edges is not a clean bill of health - nothing crossed a boundary, so
|
|
40
74
|
// nothing was checked, and saying "every edge resolved" there is a green
|
|
41
75
|
// tick the catalog has not earned.
|
|
42
76
|
const edges = useMemo(() => edgeCount(catalog), []);
|
|
77
|
+
const ruleOf = useMemo(() => new Map(rules.map((candidate) => [candidate.id, candidate])), [rules]);
|
|
78
|
+
const needle = query.trim().toLowerCase();
|
|
43
79
|
const rows = useMemo(
|
|
44
|
-
() =>
|
|
45
|
-
|
|
80
|
+
() =>
|
|
81
|
+
all.filter((p) => {
|
|
82
|
+
const made = ruleOf.get(p.rule);
|
|
83
|
+
return (
|
|
84
|
+
(contexts.size === 0 || contexts.has(p.context)) &&
|
|
85
|
+
(rule === "all" || p.rule === rule) &&
|
|
86
|
+
(over === "all" || made?.over === over) &&
|
|
87
|
+
(severity === "all" || p.severity === severity) &&
|
|
88
|
+
(!needle || `${p.id} ${p.peer} ${p.note ?? ""} ${p.service} ${p.rule}`.toLowerCase().includes(needle))
|
|
89
|
+
);
|
|
90
|
+
}),
|
|
91
|
+
[all, contexts, rule, over, severity, needle, ruleOf],
|
|
46
92
|
);
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
return next;
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
const countIn = (contextId: string) =>
|
|
57
|
-
all.filter((p) => p.context === contextId).length;
|
|
93
|
+
const errors = all.filter((p) => p.severity === "error").length;
|
|
94
|
+
const warnings = all.length - errors;
|
|
95
|
+
const firing = new Set(all.map((p) => p.rule)).size;
|
|
96
|
+
const off = rules.filter((candidate) => !candidate.enabled).length;
|
|
97
|
+
const rulesShown = rules.filter((candidate) => all.some((p) => p.rule === candidate.id));
|
|
98
|
+
const countIn = (contextId: string) => all.filter((p) => p.context === contextId).length;
|
|
58
99
|
|
|
59
100
|
return (
|
|
60
101
|
<div className="h-full overflow-y-auto p-gutter">
|
|
61
|
-
|
|
102
|
+
{/* One line: the name, the two counts as the filters they are, and
|
|
103
|
+
how much of this is the rules' doing. Two counts, not a score. */}
|
|
104
|
+
<div className="flex max-w-table flex-wrap items-center gap-x-3 gap-y-2">
|
|
62
105
|
<h1 className="text-lg font-semibold">Problems</h1>
|
|
63
|
-
{/* "0 of 0" is a ratio of nothing to nothing; the line below says it
|
|
64
|
-
in words. */}
|
|
65
|
-
{all.length > 0 ? (
|
|
66
|
-
<span className="mono text-muted">
|
|
67
|
-
{rows.length} of {all.length}
|
|
68
|
-
</span>
|
|
69
|
-
) : null}
|
|
70
|
-
{/* Two counts, not a score. A reader triaging this page decides what
|
|
71
|
-
to open by which half it is in. */}
|
|
72
|
-
{all.length > 0 ? (
|
|
73
|
-
<span className="mono flex items-center gap-3">
|
|
74
|
-
<span className="text-unresolved">
|
|
75
|
-
<span className="tnum">
|
|
76
|
-
{all.filter((p) => p.severity === "error").length}
|
|
77
|
-
</span>{" "}
|
|
78
|
-
{plural(
|
|
79
|
-
all.filter((p) => p.severity === "error").length,
|
|
80
|
-
"error",
|
|
81
|
-
)}
|
|
82
|
-
</span>
|
|
83
|
-
<span className="text-declared">
|
|
84
|
-
<span className="tnum">
|
|
85
|
-
{all.filter((p) => p.severity === "warning").length}
|
|
86
|
-
</span>{" "}
|
|
87
|
-
{plural(
|
|
88
|
-
all.filter((p) => p.severity === "warning").length,
|
|
89
|
-
"warning",
|
|
90
|
-
)}
|
|
91
|
-
</span>
|
|
92
|
-
</span>
|
|
93
|
-
) : null}
|
|
94
|
-
|
|
95
106
|
{all.length > 0 ? (
|
|
96
|
-
<div
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
aria-label="Filter by context"
|
|
100
|
-
>
|
|
101
|
-
{catalog.contexts
|
|
102
|
-
.filter((c) => countIn(c.id) > 0)
|
|
103
|
-
.map((c) => {
|
|
104
|
-
const on = active.has(c.id);
|
|
105
|
-
return (
|
|
106
|
-
<button
|
|
107
|
-
key={c.id}
|
|
108
|
-
type="button"
|
|
109
|
-
onClick={() => toggle(c.id)}
|
|
110
|
-
aria-pressed={on}
|
|
111
|
-
className="flex items-center gap-1.5"
|
|
112
|
-
style={{
|
|
113
|
-
color: on ? contextVar(c.id) : "var(--fg-muted)",
|
|
114
|
-
background: on
|
|
115
|
-
? `color-mix(in srgb, ${contextVar(c.id)} 12%, transparent)`
|
|
116
|
-
: undefined,
|
|
117
|
-
}}
|
|
118
|
-
>
|
|
119
|
-
<span
|
|
120
|
-
aria-hidden
|
|
121
|
-
className="size-1.5 rounded-[1px]"
|
|
122
|
-
style={{ background: contextVar(c.id) }}
|
|
123
|
-
/>
|
|
124
|
-
{c.id}
|
|
125
|
-
<span className="tnum">{countIn(c.id)}</span>
|
|
126
|
-
</button>
|
|
127
|
-
);
|
|
128
|
-
})}
|
|
107
|
+
<div className="seg inline-flex" role="group" aria-label="Filter by severity">
|
|
108
|
+
<SeverityPill count={errors} label="error" on={severity === "error"} onClick={() => set("severity", severity === "error" ? "all" : "error")} />
|
|
109
|
+
<SeverityPill count={warnings} label="warning" on={severity === "warning"} onClick={() => set("severity", severity === "warning" ? "all" : "warning")} />
|
|
129
110
|
</div>
|
|
130
111
|
) : null}
|
|
112
|
+
<Link to={paths.settingsRules()} className="mono rounded-control text-muted hover:text-ink hover:underline" title="The rules on the Settings page">
|
|
113
|
+
{firing} of {rules.length} {plural(rules.length, "rule")} firing{off > 0 ? ` · ${off} off` : ""}
|
|
114
|
+
</Link>
|
|
115
|
+
<span className="mono ml-auto text-faint" title={absoluteTime(catalog.generatedAt)}>
|
|
116
|
+
checked {relativeTime(catalog.generatedAt)}
|
|
117
|
+
</span>
|
|
131
118
|
</div>
|
|
132
119
|
|
|
120
|
+
{failures.length > 0 ? (
|
|
121
|
+
<div className="mt-4 max-w-table rounded-control border border-unresolved px-3 py-2">
|
|
122
|
+
<div className="text-ink">
|
|
123
|
+
{failures.length} {plural(failures.length, "rule")} could not run, so the rows {failures.length === 1 ? "it" : "they"} would produce are missing
|
|
124
|
+
</div>
|
|
125
|
+
<ul className="mono mt-1 space-y-0.5 text-muted">
|
|
126
|
+
{failures.map((failure) => (
|
|
127
|
+
<li key={failure.rule}>
|
|
128
|
+
<Link to={`${paths.settingsRules()}#rule-${failure.rule}`} className="text-accent hover:underline">
|
|
129
|
+
{failure.rule}
|
|
130
|
+
</Link>{" "}
|
|
131
|
+
— {failure.message}
|
|
132
|
+
</li>
|
|
133
|
+
))}
|
|
134
|
+
</ul>
|
|
135
|
+
</div>
|
|
136
|
+
) : null}
|
|
137
|
+
|
|
133
138
|
{all.length === 0 ? (
|
|
134
|
-
<ClearSkies checked={edges} />
|
|
139
|
+
<ClearSkies checked={edges} off={off} />
|
|
135
140
|
) : (
|
|
136
|
-
|
|
137
|
-
<
|
|
138
|
-
|
|
139
|
-
<
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
<ProblemRow
|
|
149
|
-
key={`${problem.kind}:${problem.id}:${problem.peer}`}
|
|
150
|
-
problem={problem}
|
|
151
|
-
index={i}
|
|
141
|
+
<>
|
|
142
|
+
<div className="mt-section flex max-w-table flex-wrap items-center gap-2">
|
|
143
|
+
<label className="relative">
|
|
144
|
+
<Search size={13} aria-hidden className="pointer-events-none absolute top-1/2 left-2.5 -translate-y-1/2 text-faint" />
|
|
145
|
+
<input
|
|
146
|
+
type="search"
|
|
147
|
+
aria-label="Find a problem"
|
|
148
|
+
className={`${CONTROL} min-w-52 pl-7`}
|
|
149
|
+
value={query}
|
|
150
|
+
onChange={(event) => set("q", event.target.value)}
|
|
151
|
+
placeholder="find by id, peer or note"
|
|
152
|
+
spellCheck={false}
|
|
152
153
|
/>
|
|
153
|
-
|
|
154
|
+
</label>
|
|
155
|
+
{filtered ? (
|
|
156
|
+
<span className="mono flex items-center gap-1.5 text-muted">
|
|
157
|
+
<span>
|
|
158
|
+
<span className="tnum text-ink">{rows.length}</span> of {all.length}
|
|
159
|
+
</span>
|
|
160
|
+
<button type="button" className="tbtn py-0.5" onClick={clear} title="Clear every filter">
|
|
161
|
+
<X size={12} aria-hidden /> clear
|
|
162
|
+
</button>
|
|
163
|
+
</span>
|
|
164
|
+
) : null}
|
|
165
|
+
{rulesShown.length > 1 ? (
|
|
166
|
+
<select aria-label="Filter by rule" className={CONTROL} value={rulesShown.some((candidate) => candidate.id === rule) ? rule : "all"} onChange={(event) => set("rule", event.target.value)}>
|
|
167
|
+
<option value="all">all rules</option>
|
|
168
|
+
{rulesShown.map((candidate) => (
|
|
169
|
+
<option key={candidate.id} value={candidate.id}>
|
|
170
|
+
{candidate.title}
|
|
171
|
+
</option>
|
|
172
|
+
))}
|
|
173
|
+
</select>
|
|
174
|
+
) : null}
|
|
175
|
+
<select aria-label="Filter by subject" className={CONTROL} value={over} onChange={(event) => set("subject", event.target.value)}>
|
|
176
|
+
<option value="all">all subjects</option>
|
|
177
|
+
{SUBJECT_NAMES.filter((name) => rulesShown.some((candidate) => candidate.over === name)).map((name) => (
|
|
178
|
+
<option key={name} value={name}>
|
|
179
|
+
{name}
|
|
180
|
+
</option>
|
|
181
|
+
))}
|
|
182
|
+
</select>
|
|
183
|
+
<div className="seg inline-flex" role="group" aria-label="Group by">
|
|
184
|
+
<button type="button" aria-pressed={by === "rule"} className={by === "rule" ? "is-on" : ""} onClick={() => set("by", "rule")}>
|
|
185
|
+
by rule
|
|
186
|
+
</button>
|
|
187
|
+
<button type="button" aria-pressed={by === "service"} className={by === "service" ? "is-on" : ""} onClick={() => set("by", "service")}>
|
|
188
|
+
by service
|
|
189
|
+
</button>
|
|
190
|
+
</div>
|
|
191
|
+
<div className="seg ml-auto" role="group" aria-label="Filter by context">
|
|
192
|
+
{catalog.contexts
|
|
193
|
+
.filter((c) => countIn(c.id) > 0)
|
|
194
|
+
.map((c) => {
|
|
195
|
+
const on = contexts.has(c.id);
|
|
196
|
+
return (
|
|
197
|
+
<button
|
|
198
|
+
key={c.id}
|
|
199
|
+
type="button"
|
|
200
|
+
onClick={() => toggleContext(c.id)}
|
|
201
|
+
aria-pressed={on}
|
|
202
|
+
className="flex items-center gap-1.5"
|
|
203
|
+
style={{
|
|
204
|
+
color: on ? contextVar(c.id) : "var(--fg-muted)",
|
|
205
|
+
background: on ? `color-mix(in srgb, ${contextVar(c.id)} 12%, transparent)` : undefined,
|
|
206
|
+
}}
|
|
207
|
+
>
|
|
208
|
+
<span aria-hidden className="size-1.5 rounded-[1px]" style={{ background: contextVar(c.id) }} />
|
|
209
|
+
{c.id}
|
|
210
|
+
<span className="tnum">{countIn(c.id)}</span>
|
|
211
|
+
</button>
|
|
212
|
+
);
|
|
213
|
+
})}
|
|
214
|
+
</div>
|
|
154
215
|
</div>
|
|
155
|
-
|
|
216
|
+
|
|
217
|
+
{rows.length === 0 ? (
|
|
218
|
+
<div className="mt-section max-w-table rounded-card border border-line px-4 py-6 text-center text-muted">
|
|
219
|
+
Nothing matches. <button type="button" className="text-accent hover:underline" onClick={clear}>Clear the filters</button> to see all {all.length}.
|
|
220
|
+
</div>
|
|
221
|
+
) : by === "rule" ? (
|
|
222
|
+
<RuleGroups rows={rows} ruleOf={ruleOf} />
|
|
223
|
+
) : (
|
|
224
|
+
<ServiceGroups rows={rows} />
|
|
225
|
+
)}
|
|
226
|
+
</>
|
|
156
227
|
)}
|
|
157
228
|
</div>
|
|
158
229
|
);
|
|
159
230
|
}
|
|
160
231
|
|
|
232
|
+
/** A count that is also the filter for what it counts: a member of the severity segment. */
|
|
233
|
+
function SeverityPill({ count, label, on, onClick }: { count: number; label: "error" | "warning"; on: boolean; onClick: () => void }) {
|
|
234
|
+
const colour = label === "error" ? "var(--status-unresolved)" : "var(--status-declared)";
|
|
235
|
+
return (
|
|
236
|
+
<button
|
|
237
|
+
type="button"
|
|
238
|
+
aria-pressed={on}
|
|
239
|
+
className={`flex items-center gap-1.5 ${on ? "is-on" : ""}`}
|
|
240
|
+
onClick={onClick}
|
|
241
|
+
disabled={count === 0 && !on}
|
|
242
|
+
title={count === 0 ? `no ${plural(count, label)}` : on ? "Show all" : `Show only ${plural(count, label)}`}
|
|
243
|
+
>
|
|
244
|
+
<span aria-hidden className="size-1.5 rounded-[1px]" style={{ background: count > 0 ? colour : "var(--fg-faint)" }} />
|
|
245
|
+
<span className="tnum">{count}</span> {plural(count, label)}
|
|
246
|
+
</button>
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
// The rows, under the rule that made them.
|
|
252
|
+
|
|
253
|
+
function RuleGroups({ rows, ruleOf }: { rows: Problem[]; ruleOf: Map<string, ProblemRule> }) {
|
|
254
|
+
// Rule order is the rules' own, errors first: a group of errors is not the
|
|
255
|
+
// same news as a group of warnings, and mixing them buries the first.
|
|
256
|
+
const groups = useMemo(() => {
|
|
257
|
+
const byRule = new Map<string, Problem[]>();
|
|
258
|
+
for (const row of rows) byRule.set(row.rule, [...(byRule.get(row.rule) ?? []), row]);
|
|
259
|
+
const list = [...byRule.entries()].map(([id, members]) => ({ rule: ruleOf.get(id), id, members }));
|
|
260
|
+
return [...list.filter((group) => group.members[0]?.severity === "error"), ...list.filter((group) => group.members[0]?.severity !== "error")];
|
|
261
|
+
}, [rows, ruleOf]);
|
|
262
|
+
|
|
263
|
+
return (
|
|
264
|
+
<div className="mt-section max-w-table space-y-grid" data-nav-list>
|
|
265
|
+
{groups.map((group) => (
|
|
266
|
+
<RuleGroup key={group.id} id={group.id} rule={group.rule} rows={group.members} />
|
|
267
|
+
))}
|
|
268
|
+
</div>
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function RuleGroup({ id, rule, rows }: { id: string; rule: ProblemRule | undefined; rows: Problem[] }) {
|
|
273
|
+
const severity = rows[0]?.severity ?? rule?.severity ?? "warning";
|
|
274
|
+
const [asking, setAsking] = useState(false);
|
|
275
|
+
return (
|
|
276
|
+
<section className="overflow-hidden rounded-card border border-line shadow-xs" id={`rule-${id}`}>
|
|
277
|
+
<header className="bg-surface px-3 py-2">
|
|
278
|
+
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
|
279
|
+
<KindIcon kind={ICON_OF[rule?.over ?? "service"]} />
|
|
280
|
+
<span className="font-medium text-ink">{rule?.title ?? id}</span>
|
|
281
|
+
<span className={SEVERITY_CHIP[severity]}>{severity}</span>
|
|
282
|
+
<span className="mono text-muted">
|
|
283
|
+
<span className="tnum">{rows.length}</span> {plural(rows.length, "row")}
|
|
284
|
+
</span>
|
|
285
|
+
<span className="ml-auto flex items-center gap-2">
|
|
286
|
+
<SwitchOff rule={rule} asking={asking} onAsk={setAsking} />
|
|
287
|
+
<Link to={`${paths.settingsRules()}#rule-${id}`} className="mono rounded-control text-muted hover:text-ink hover:underline" title="The rule on the Settings page">
|
|
288
|
+
{id} →
|
|
289
|
+
</Link>
|
|
290
|
+
</span>
|
|
291
|
+
</div>
|
|
292
|
+
{/* One line each, the whole text on hover: the header says what the
|
|
293
|
+
rule is and what to do, and the rows below are what it found. */}
|
|
294
|
+
{rule?.description ? (
|
|
295
|
+
<p className="mt-1 truncate text-muted" title={rule.description}>
|
|
296
|
+
{rule.description}
|
|
297
|
+
</p>
|
|
298
|
+
) : null}
|
|
299
|
+
{rule?.action ? (
|
|
300
|
+
<p className="truncate text-muted" title={rule.action}>
|
|
301
|
+
<span className="text-ink">What to do:</span> {rule.action}
|
|
302
|
+
</p>
|
|
303
|
+
) : null}
|
|
304
|
+
{asking && rule ? <SwitchOffForm rule={rule} onDone={() => setAsking(false)} /> : null}
|
|
305
|
+
</header>
|
|
306
|
+
<div className="divide-y divide-line">
|
|
307
|
+
{rows.map((problem, i) => (
|
|
308
|
+
<ProblemRow key={`${problem.rule}:${problem.id}:${problem.peer}`} problem={problem} index={i} showRule={false} />
|
|
309
|
+
))}
|
|
310
|
+
</div>
|
|
311
|
+
</section>
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** The switch-off action, shown only where the page can write. */
|
|
316
|
+
function SwitchOff({ rule, asking, onAsk }: { rule: ProblemRule | undefined; asking: boolean; onAsk: (asking: boolean) => void }) {
|
|
317
|
+
const status = useQuery(localStatusQuery());
|
|
318
|
+
if (!status.isSuccess || !rule || !rule.enabled) return null;
|
|
319
|
+
return (
|
|
320
|
+
<button type="button" className="tbtn py-0.5" aria-expanded={asking} onClick={() => onAsk(!asking)}>
|
|
321
|
+
switch off <ChevronDown size={12} aria-hidden className={`transition-transform ${asking ? "rotate-180" : ""}`} />
|
|
322
|
+
</button>
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Off, with a reason, without leaving the page: the entry is the one the
|
|
328
|
+
* Rules page would write, through the same revision-checked save.
|
|
329
|
+
*/
|
|
330
|
+
function SwitchOffForm({ rule, onDone }: { rule: ProblemRule; onDone: () => void }) {
|
|
331
|
+
const say = useToastStore((s) => s.say);
|
|
332
|
+
const entries = useRuleEntries((s) => s.entries);
|
|
333
|
+
const setEntries = useRuleEntries((s) => s.setEntries);
|
|
334
|
+
const [reason, setReason] = useState("");
|
|
335
|
+
const [busy, setBusy] = useState(false);
|
|
336
|
+
|
|
337
|
+
async function submit() {
|
|
338
|
+
if (!reason.trim()) return;
|
|
339
|
+
setBusy(true);
|
|
340
|
+
try {
|
|
341
|
+
const current = await readProblemRules();
|
|
342
|
+
const saved = await saveProblemRules(current.revision, switched(current.rules.length ? current.rules : entries, rule, false, reason.trim()));
|
|
343
|
+
setEntries(saved.rules);
|
|
344
|
+
say(`${rule.id} is off`);
|
|
345
|
+
onDone();
|
|
346
|
+
} catch (cause) {
|
|
347
|
+
say(cause instanceof Error ? cause.message : String(cause));
|
|
348
|
+
} finally {
|
|
349
|
+
setBusy(false);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return (
|
|
354
|
+
<form
|
|
355
|
+
className="mt-3 flex flex-wrap items-end gap-2 rounded-control border border-accent bg-canvas p-3"
|
|
356
|
+
onSubmit={(event) => {
|
|
357
|
+
event.preventDefault();
|
|
358
|
+
void submit();
|
|
359
|
+
}}
|
|
360
|
+
>
|
|
361
|
+
<label className="block min-w-64 flex-1">
|
|
362
|
+
<span className="label mb-1.5 block">why is {rule.id} off here</span>
|
|
363
|
+
<input className={FIELD} value={reason} onChange={(event) => setReason(event.target.value)} placeholder="the estate shares one database by design" autoFocus />
|
|
364
|
+
</label>
|
|
365
|
+
<button type="submit" className="product-primary" disabled={!reason.trim() || busy}>
|
|
366
|
+
Switch off
|
|
367
|
+
</button>
|
|
368
|
+
<button type="button" className="tbtn py-1.5" onClick={onDone} disabled={busy}>
|
|
369
|
+
Keep on
|
|
370
|
+
</button>
|
|
371
|
+
</form>
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ---------------------------------------------------------------------------
|
|
376
|
+
// The rows, under the service whose end they are.
|
|
377
|
+
|
|
378
|
+
function ServiceGroups({ rows }: { rows: Problem[] }) {
|
|
379
|
+
const groups = useMemo(() => {
|
|
380
|
+
const byService = new Map<string, Problem[]>();
|
|
381
|
+
for (const row of rows) byService.set(row.service, [...(byService.get(row.service) ?? []), row]);
|
|
382
|
+
return [...byService.entries()].map(([service, members]) => ({ service, members }));
|
|
383
|
+
}, [rows]);
|
|
384
|
+
|
|
385
|
+
return (
|
|
386
|
+
<div className="mt-section max-w-table space-y-grid" data-nav-list>
|
|
387
|
+
{groups.map(({ service, members }) => {
|
|
388
|
+
const owner = service ? index.serviceById.get(service) : undefined;
|
|
389
|
+
const context = members[0]?.context ?? "";
|
|
390
|
+
const errors = members.filter((p) => p.severity === "error").length;
|
|
391
|
+
return (
|
|
392
|
+
<section key={service || "nobody"} className="overflow-hidden rounded-card border border-line shadow-xs">
|
|
393
|
+
<header className="flex flex-wrap items-center gap-x-3 gap-y-1 bg-surface px-3 py-2">
|
|
394
|
+
<KindIcon kind="service" />
|
|
395
|
+
{owner ? (
|
|
396
|
+
<Link to={servicePath(service) ?? paths.problems()} className="font-medium text-ink hover:underline">
|
|
397
|
+
{owner.name}
|
|
398
|
+
</Link>
|
|
399
|
+
) : (
|
|
400
|
+
<span className="font-medium text-ink">nobody in the catalog</span>
|
|
401
|
+
)}
|
|
402
|
+
{context ? (
|
|
403
|
+
<span className="chip ctx" style={ctxStyle(context)}>
|
|
404
|
+
<span aria-hidden className="dot" />
|
|
405
|
+
{context}
|
|
406
|
+
</span>
|
|
407
|
+
) : null}
|
|
408
|
+
<span className="mono ml-auto text-muted">
|
|
409
|
+
{errors > 0 ? <span className="text-unresolved">{errors} {plural(errors, "error")} · </span> : null}
|
|
410
|
+
<span className="tnum">{members.length}</span> {plural(members.length, "row")}
|
|
411
|
+
</span>
|
|
412
|
+
</header>
|
|
413
|
+
<div className="divide-y divide-line">
|
|
414
|
+
{members.map((problem, i) => (
|
|
415
|
+
<ProblemRow key={`${problem.rule}:${problem.id}:${problem.peer}`} problem={problem} index={i} />
|
|
416
|
+
))}
|
|
417
|
+
</div>
|
|
418
|
+
</section>
|
|
419
|
+
);
|
|
420
|
+
})}
|
|
421
|
+
</div>
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
161
425
|
/** The one line the reader wants to see. Nothing else earns the space. */
|
|
162
|
-
function ClearSkies({ checked }: { checked: number }) {
|
|
426
|
+
function ClearSkies({ checked, off }: { checked: number; off: number }) {
|
|
163
427
|
return (
|
|
164
428
|
<CatEmptyState
|
|
165
429
|
scene="clear"
|
|
166
430
|
title={checked === 0 ? "Nothing to resolve yet" : "Clear skies"}
|
|
167
431
|
className="mt-section max-w-prose"
|
|
168
|
-
meta={
|
|
432
|
+
meta={
|
|
433
|
+
off > 0 ? (
|
|
434
|
+
<Link to={paths.settingsRules()} className="hover:underline">
|
|
435
|
+
{off} {plural(off, "rule")} switched off
|
|
436
|
+
</Link>
|
|
437
|
+
) : undefined
|
|
438
|
+
}
|
|
169
439
|
>
|
|
170
|
-
{checked === 0
|
|
171
|
-
? "No service calls another, and no event has a consumer."
|
|
172
|
-
: `All ${checked} ${plural(checked, "edge")} resolved.`}
|
|
440
|
+
{checked === 0 ? "No service calls another, and no event has a consumer." : `All ${checked} ${plural(checked, "edge")} resolved.`}
|
|
173
441
|
</CatEmptyState>
|
|
174
442
|
);
|
|
175
443
|
}
|