@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.
Files changed (188) hide show
  1. package/README.md +151 -6
  2. package/catalog/model.go +147 -0
  3. package/catalog/roundtrip_test.go +7 -0
  4. package/cli/portolan.mjs +6 -1
  5. package/go.mod +7 -0
  6. package/go.sum +20 -6
  7. package/package.json +10 -9
  8. package/plugin/describe.go +38 -0
  9. package/plugins/README.md +296 -5
  10. package/plugins/extract-argocd/options.schema.json +44 -0
  11. package/plugins/extract-celery/main.py +1 -0
  12. package/plugins/extract-csharp-ddd/README.md +213 -0
  13. package/plugins/extract-csharp-ddd/options.schema.json +55 -0
  14. package/plugins/extract-django/README.md +13 -2
  15. package/plugins/extract-django/choices.py +47 -0
  16. package/plugins/extract-django/domain.py +2 -1
  17. package/plugins/extract-django/extract_test.py +13 -0
  18. package/plugins/extract-django/lifecycle.py +2 -28
  19. package/plugins/extract-django/main.py +1 -0
  20. package/plugins/extract-django/rules.py +154 -0
  21. package/plugins/extract-django/rules_test.py +158 -0
  22. package/plugins/extract-go-sqs/options.schema.json +21 -0
  23. package/plugins/extract-http-clients/describe.go +5 -4
  24. package/plugins/extract-java/build/org/portolan/extract/Main.class +0 -0
  25. package/plugins/extract-k8s/options.schema.json +32 -0
  26. package/plugins/extract-laravel/Cargo.lock +962 -0
  27. package/plugins/extract-laravel/Cargo.toml +20 -0
  28. package/plugins/extract-laravel/README.md +200 -0
  29. package/plugins/extract-laravel/options.schema.json +68 -0
  30. package/plugins/extract-laravel/rustfmt.toml +1 -0
  31. package/plugins/extract-php-ddd/Cargo.lock +972 -0
  32. package/plugins/extract-php-ddd/Cargo.toml +22 -0
  33. package/plugins/extract-php-ddd/README.md +141 -0
  34. package/plugins/extract-php-ddd/options.schema.json +50 -0
  35. package/plugins/extract-php-ddd/rustfmt.toml +1 -0
  36. package/plugins/extract-python-kafka/main.py +1 -0
  37. package/plugins/extract-terraform/options.schema.json +26 -0
  38. package/plugins/extract-ts/main.ts +1 -0
  39. package/plugins/phpscan/Cargo.lock +953 -0
  40. package/plugins/phpscan/Cargo.toml +19 -0
  41. package/plugins/phpscan/rustfmt.toml +1 -0
  42. package/plugins/portolan-go.wasm +0 -0
  43. package/plugins/pyplugin/catalog.py +24 -1
  44. package/plugins/verify-otel/options.schema.json +12 -0
  45. package/portolan.json +130 -3
  46. package/rules/builtin.json +218 -0
  47. package/schema/portolan.schema.json +880 -13
  48. package/scripts/README.md +3 -1
  49. package/scripts/delivery-presets.mjs +36 -3
  50. package/scripts/gen-likec4.mjs +149 -16
  51. package/scripts/gen-likec4.test.mjs +96 -0
  52. package/scripts/gen.mjs +30 -3
  53. package/scripts/gitops-example.test.mjs +108 -0
  54. package/scripts/host-plugins/fetch-argocd.mjs +338 -0
  55. package/scripts/host-plugins/fetch-argocd.options.json +49 -0
  56. package/scripts/host-plugins/fetch-argocd.test.mjs +274 -0
  57. package/scripts/host-plugins/fetch-bsr.mjs +1 -0
  58. package/scripts/host-plugins/fetch-csr.mjs +1 -0
  59. package/scripts/host-plugins/fetch-git.mjs +1 -0
  60. package/scripts/host-plugins/fetch-k8s.mjs +263 -0
  61. package/scripts/host-plugins/fetch-k8s.options.json +50 -0
  62. package/scripts/host-plugins/fetch-k8s.test.mjs +259 -0
  63. package/scripts/host-plugins/k8s-topology.mjs +183 -0
  64. package/scripts/json-format.mjs +192 -0
  65. package/scripts/json-format.test.mjs +97 -0
  66. package/scripts/local-api.mjs +216 -8
  67. package/scripts/local-api.test.mjs +88 -2
  68. package/scripts/local-discovery.mjs +12 -0
  69. package/scripts/manifest.mjs +10 -1
  70. package/scripts/plugin-host.mjs +23 -1
  71. package/scripts/plugins-fresh.mjs +97 -0
  72. package/scripts/plugins-fresh.test.mjs +64 -0
  73. package/scripts/run-builtin.mjs +26 -5
  74. package/scripts/schema.mjs +138 -19
  75. package/scripts/trace-trials.mjs +176 -0
  76. package/scripts/trace-trials.test.mjs +142 -0
  77. package/scripts/warning-policy.mjs +12 -6
  78. package/scripts/warning-policy.test.mjs +40 -3
  79. package/src/app/Breadcrumbs.test.ts +3 -0
  80. package/src/app/Breadcrumbs.tsx +3 -0
  81. package/src/app/CatalogApp.tsx +2 -0
  82. package/src/app/SidebarFooter.tsx +20 -4
  83. package/src/catalog-index.ts +25 -0
  84. package/src/catalog-model.ts +246 -1
  85. package/src/catalog-validation.ts +104 -2
  86. package/src/catalog.test.ts +40 -1
  87. package/src/components/ChannelRows.test.tsx +76 -1
  88. package/src/components/ChannelRows.tsx +223 -60
  89. package/src/components/DeploymentRows.tsx +131 -0
  90. package/src/components/DocsLinks.test.tsx +27 -0
  91. package/src/components/DocsLinks.tsx +56 -0
  92. package/src/components/FieldTree.tsx +5 -2
  93. package/src/components/HTTPDestinationEvidence.tsx +1 -1
  94. package/src/components/PluginIcon.tsx +77 -0
  95. package/src/components/ProblemRow.tsx +173 -153
  96. package/src/components/RuleMarks.tsx +22 -0
  97. package/src/components/ShapeRows.tsx +24 -20
  98. package/src/data.ts +21 -0
  99. package/src/enrich.test.ts +127 -3
  100. package/src/enrich.ts +104 -6
  101. package/src/er/ErCanvas.tsx +218 -12
  102. package/src/er/GroupNode.tsx +57 -0
  103. package/src/er/StoreHeader.tsx +1 -0
  104. package/src/er/layout.test.ts +85 -2
  105. package/src/er/layout.ts +140 -5
  106. package/src/er/spec.test.ts +17 -0
  107. package/src/er/spec.ts +23 -10
  108. package/src/flow/Recordings.test.tsx +52 -0
  109. package/src/flow/Recordings.tsx +236 -0
  110. package/src/flow/StepDetail.tsx +53 -0
  111. package/src/flow/TraceTrial.tsx +419 -0
  112. package/src/flow/examples.test.ts +33 -0
  113. package/src/flow/examples.ts +37 -0
  114. package/src/flow/outline.test.ts +28 -0
  115. package/src/flow/outline.ts +7 -2
  116. package/src/flow/trace-trial-resume.test.ts +42 -0
  117. package/src/flow/trace-trial-resume.ts +74 -0
  118. package/src/graph/elk.ts +78 -0
  119. package/src/index.css +24 -0
  120. package/src/landing/LandingPage.tsx +15 -11
  121. package/src/landing/ProductTour.tsx +6 -6
  122. package/src/lib/all-problems.ts +27 -17
  123. package/src/lib/centrality.test.ts +251 -0
  124. package/src/lib/centrality.ts +232 -0
  125. package/src/lib/confluence.test.ts +37 -0
  126. package/src/lib/confluence.ts +41 -0
  127. package/src/lib/context-color.ts +1 -1
  128. package/src/lib/deployment-drift.ts +22 -0
  129. package/src/lib/derive.ts +43 -69
  130. package/src/lib/environments.test.ts +83 -0
  131. package/src/lib/environments.ts +57 -0
  132. package/src/lib/integration-url.test.ts +30 -0
  133. package/src/lib/integration-url.ts +63 -0
  134. package/src/lib/kafka-ui.ts +3 -45
  135. package/src/lib/local-api.ts +96 -2
  136. package/src/lib/notion.ts +13 -0
  137. package/src/lib/plugin-index.json +2765 -0
  138. package/src/lib/plugins.test.ts +68 -0
  139. package/src/lib/plugins.ts +264 -0
  140. package/src/lib/problem-flows.test.ts +61 -0
  141. package/src/lib/problem-flows.ts +78 -0
  142. package/src/lib/problem-rules-cel.d.mts +43 -0
  143. package/src/lib/problem-rules-cel.mjs +407 -0
  144. package/src/lib/problem-rules.test.ts +287 -0
  145. package/src/lib/problem-rules.ts +271 -0
  146. package/src/lib/problem-subjects.ts +737 -0
  147. package/src/lib/rule-entries.ts +39 -0
  148. package/src/lib/{data-problems.test.ts → rules-data.test.ts} +18 -16
  149. package/src/lib/rules-deploy.test.ts +133 -0
  150. package/src/lib/{problems.test.ts → rules-edges.test.ts} +8 -3
  151. package/src/lib/{proto-problems.test.ts → rules-proto.test.ts} +9 -25
  152. package/src/lib/{wire-problems.test.ts → rules-wire.test.ts} +13 -11
  153. package/src/lib/rules.test.ts +51 -0
  154. package/src/lib/rules.ts +86 -0
  155. package/src/lib/shape.test.ts +32 -0
  156. package/src/lib/shape.ts +30 -6
  157. package/src/lib/tech.ts +16 -0
  158. package/src/lib/trace-project.test.ts +34 -0
  159. package/src/lib/trace-project.ts +50 -0
  160. package/src/lib/use-problems.ts +23 -0
  161. package/src/lib/warnings.test.ts +9 -0
  162. package/src/lib/warnings.ts +10 -10
  163. package/src/likec4/ids.test.ts +6 -2
  164. package/src/likec4/ids.ts +43 -0
  165. package/src/main.tsx +23 -0
  166. package/src/merge-deployments.test.ts +127 -0
  167. package/src/merge.test.ts +82 -0
  168. package/src/merge.ts +173 -9
  169. package/src/pages/AggregatePage.tsx +57 -7
  170. package/src/pages/ContextMap.tsx +45 -3
  171. package/src/pages/ContextPage.tsx +2 -0
  172. package/src/pages/EventPage.tsx +15 -5
  173. package/src/pages/FlowDetail.tsx +23 -2
  174. package/src/pages/GraphPage.tsx +40 -3
  175. package/src/pages/Overview.tsx +152 -12
  176. package/src/pages/PluginIndex.tsx +190 -0
  177. package/src/pages/Problems.tsx +396 -128
  178. package/src/pages/ServicePage.tsx +58 -2
  179. package/src/pages/Settings.tsx +28 -2
  180. package/src/pages/settings/AboutSettings.tsx +8 -1
  181. package/src/pages/settings/IntegrationsSettings.tsx +63 -17
  182. package/src/pages/settings/RecordingSettings.tsx +138 -0
  183. package/src/pages/settings/RulesSettings.tsx +825 -0
  184. package/src/routes.test.ts +9 -0
  185. package/src/routes.ts +22 -1
  186. package/src/lib/data-problems.ts +0 -314
  187. package/src/lib/proto-problems.ts +0 -237
  188. package/src/lib/wire-problems.ts +0 -342
@@ -1,175 +1,443 @@
1
1
  // Everywhere the chart draws an arrow into open water.
2
2
  //
3
- // There is no score and no severity column. An edge either lands somewhere the
4
- // catalog knows about or it does not, and the only useful ordering is the one
5
- // the reader can act on: by the service that owns the near end.
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 { allProblems } from "../lib/all-problems";
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 { SectionTitle } from "../components/PageHeader";
16
- import { ProblemRow } from "../components/ProblemRow";
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
- // `?context=` is how the sidebar's unresolved-edge count arrives here: the
22
- // reader clicked a number against one context, so that context is what the
23
- // page opens filtered to. It seeds the chips rather than replacing them -
24
- // once here, the filter is theirs to widen.
25
- const [params] = useSearchParams();
26
- const [active, setActive] = useState<Set<string>>(
27
- () =>
28
- new Set(
29
- params
30
- .getAll("context")
31
- .filter((id) => catalog.contexts.some((c) => c.id === id)),
32
- ),
33
- );
34
- // Unresolved edges first, then everything the schema disagrees with. Within
35
- // each, errors before warnings: a boundary leak is not the same kind of news
36
- // as a column whose type has drifted, and mixing them buries the first.
37
- const all = useMemo(() => allProblems(catalog, index), []);
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
- () => (active.size === 0 ? all : all.filter((p) => active.has(p.context))),
45
- [all, active],
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 toggle = (id: string) =>
49
- setActive((prev) => {
50
- const next = new Set(prev);
51
- if (next.has(id)) next.delete(id);
52
- else next.add(id);
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
- <div className="flex flex-wrap items-center gap-3">
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
- className="seg ml-auto"
98
- role="group"
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
- <div className="mt-section max-w-table">
137
- <SectionTitle
138
- right={
139
- <span title={absoluteTime(catalog.generatedAt)}>
140
- last checked {relativeTime(catalog.generatedAt)}
141
- </span>
142
- }
143
- >
144
- Everything that does not line up
145
- </SectionTitle>
146
- <div className="flex flex-col gap-1" data-nav-list>
147
- {rows.map((problem, i) => (
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
- </div>
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={<span title={absoluteTime(catalog.generatedAt)}>last checked {relativeTime(catalog.generatedAt)}</span>}
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
  }