@shortlink-org/portolan 0.2.4 → 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 (291) hide show
  1. package/README.md +174 -6
  2. package/catalog/enum_test.go +46 -0
  3. package/catalog/evidence_test.go +35 -0
  4. package/catalog/model.go +1213 -0
  5. package/catalog/roundtrip_test.go +210 -0
  6. package/catalog/via_test.go +38 -0
  7. package/cli/init.test.mjs +6 -1
  8. package/cli/portolan.mjs +14 -1
  9. package/cli/portolan.test.mjs +49 -0
  10. package/go.mod +21 -0
  11. package/go.sum +34 -0
  12. package/internal/gocall/README.md +19 -0
  13. package/internal/gocall/analyze.go +189 -0
  14. package/internal/gocall/analyze_test.go +107 -0
  15. package/internal/gohttp/analyze.go +2562 -0
  16. package/internal/gohttp/destination.go +373 -0
  17. package/internal/gohttp/endpoints.go +1067 -0
  18. package/internal/gohttp/roots.go +320 -0
  19. package/internal/gohttp/typed.go +96 -0
  20. package/internal/goscan/constants.go +85 -0
  21. package/internal/goscan/goscan_test.go +227 -0
  22. package/internal/goscan/index.go +629 -0
  23. package/internal/goscan/index_test.go +66 -0
  24. package/internal/goscan/names.go +52 -0
  25. package/internal/goscan/parse_test.go +11 -0
  26. package/internal/goscan/source.go +37 -0
  27. package/internal/goscan/tree.go +284 -0
  28. package/internal/goscan/types.go +99 -0
  29. package/internal/wsdl/ids.go +127 -0
  30. package/internal/wsdl/ids_test.go +21 -0
  31. package/internal/wsdl/model.go +70 -0
  32. package/internal/wsdl/parse.go +949 -0
  33. package/internal/wsdl/parse_test.go +170 -0
  34. package/package.json +28 -15
  35. package/plugin/describe.go +156 -0
  36. package/plugin/describe_test.go +114 -0
  37. package/plugin/protocol.go +141 -0
  38. package/plugin/schematest/schematest.go +126 -0
  39. package/plugins/README.md +404 -50
  40. package/plugins/cmd/portolan-http-clients/main.go +19 -0
  41. package/plugins/extract-argocd/options.schema.json +44 -0
  42. package/plugins/extract-celery/extract.py +0 -2
  43. package/plugins/extract-celery/extract_test.py +1 -1
  44. package/plugins/extract-celery/main.py +1 -0
  45. package/plugins/extract-csharp-ddd/README.md +213 -0
  46. package/plugins/extract-csharp-ddd/options.schema.json +55 -0
  47. package/plugins/extract-django/README.md +52 -19
  48. package/plugins/extract-django/choices.py +47 -0
  49. package/plugins/extract-django/domain.py +30 -18
  50. package/plugins/extract-django/extract.py +21 -7
  51. package/plugins/extract-django/extract_test.py +68 -2
  52. package/plugins/extract-django/lifecycle.py +4 -28
  53. package/plugins/extract-django/main.py +1 -0
  54. package/plugins/extract-django/operations.py +1 -1
  55. package/plugins/extract-django/routing_test.py +109 -1
  56. package/plugins/extract-django/rules.py +154 -0
  57. package/plugins/extract-django/rules_test.py +158 -0
  58. package/plugins/extract-django/store.py +1 -1
  59. package/plugins/extract-django/transport.py +101 -55
  60. package/plugins/extract-django/verbs.py +241 -0
  61. package/plugins/extract-go/README.md +47 -0
  62. package/plugins/extract-go-sqs/options.schema.json +21 -0
  63. package/plugins/extract-http-clients/describe.go +20 -0
  64. package/plugins/extract-http-clients/describe_test.go +11 -0
  65. package/plugins/extract-http-clients/extract.go +740 -0
  66. package/plugins/extract-http-clients/extract_test.go +1561 -0
  67. package/plugins/extract-http-clients/main.go +41 -0
  68. package/plugins/extract-java/build/org/portolan/extract/Extract.class +0 -0
  69. package/plugins/extract-java/build/org/portolan/extract/Main.class +0 -0
  70. package/plugins/extract-java/build/org/portolan/extract/Protocol$Builder.class +0 -0
  71. package/plugins/extract-java/build/org/portolan/extract/Protocol$Input.class +0 -0
  72. package/plugins/extract-java/build/org/portolan/extract/Protocol$Options.class +0 -0
  73. package/plugins/extract-k8s/options.schema.json +32 -0
  74. package/plugins/extract-laravel/Cargo.lock +962 -0
  75. package/plugins/extract-laravel/Cargo.toml +20 -0
  76. package/plugins/extract-laravel/README.md +200 -0
  77. package/plugins/extract-laravel/options.schema.json +68 -0
  78. package/plugins/extract-laravel/rustfmt.toml +1 -0
  79. package/plugins/extract-php-ddd/Cargo.lock +972 -0
  80. package/plugins/extract-php-ddd/Cargo.toml +22 -0
  81. package/plugins/extract-php-ddd/README.md +141 -0
  82. package/plugins/extract-php-ddd/options.schema.json +50 -0
  83. package/plugins/extract-php-ddd/rustfmt.toml +1 -0
  84. package/plugins/extract-python-kafka/extract.py +0 -2
  85. package/plugins/extract-python-kafka/extract_test.py +1 -1
  86. package/plugins/extract-python-kafka/main.py +1 -0
  87. package/plugins/extract-terraform/options.schema.json +26 -0
  88. package/plugins/extract-ts/extract.test.ts +2 -2
  89. package/plugins/extract-ts/extract.ts +4 -5
  90. package/plugins/extract-ts/graphql.test.ts +1 -1
  91. package/plugins/extract-ts/main.ts +1 -0
  92. package/plugins/openapi/ids.go +261 -0
  93. package/plugins/openapi/ids_test.go +98 -0
  94. package/plugins/phpscan/Cargo.lock +953 -0
  95. package/plugins/phpscan/Cargo.toml +19 -0
  96. package/plugins/phpscan/rustfmt.toml +1 -0
  97. package/plugins/portolan-go.wasm +0 -0
  98. package/plugins/pyplugin/catalog.py +24 -1
  99. package/plugins/pyplugin/protocol.py +1 -5
  100. package/plugins/verify-otel/options.schema.json +12 -0
  101. package/portolan.json +132 -4
  102. package/rules/builtin.json +218 -0
  103. package/schema/portolan.schema.json +905 -4
  104. package/scripts/README.md +21 -13
  105. package/scripts/catalog-sources.mjs +6 -0
  106. package/scripts/delivery-presets.mjs +57 -14
  107. package/scripts/diff.mjs +5 -1
  108. package/scripts/django-aggregates.test.mjs +58 -0
  109. package/scripts/gen-likec4.mjs +150 -17
  110. package/scripts/gen-likec4.test.mjs +96 -0
  111. package/scripts/gen.mjs +148 -118
  112. package/scripts/gitops-example.test.mjs +108 -0
  113. package/scripts/go-discovery.test.mjs +30 -0
  114. package/scripts/history.mjs +186 -3
  115. package/scripts/history.test.mjs +1 -1
  116. package/scripts/host-plugins/fetch-argocd.mjs +338 -0
  117. package/scripts/host-plugins/fetch-argocd.options.json +49 -0
  118. package/scripts/host-plugins/fetch-argocd.test.mjs +274 -0
  119. package/scripts/host-plugins/fetch-bsr.mjs +1 -0
  120. package/scripts/host-plugins/fetch-csr.mjs +1 -0
  121. package/scripts/host-plugins/fetch-git.mjs +78 -21
  122. package/scripts/host-plugins/fetch-git.test.mjs +62 -8
  123. package/scripts/host-plugins/fetch-k8s.mjs +263 -0
  124. package/scripts/host-plugins/fetch-k8s.options.json +50 -0
  125. package/scripts/host-plugins/fetch-k8s.test.mjs +259 -0
  126. package/scripts/host-plugins/k8s-topology.mjs +183 -0
  127. package/scripts/json-format.mjs +192 -0
  128. package/scripts/json-format.test.mjs +97 -0
  129. package/scripts/local-api.mjs +287 -12
  130. package/scripts/local-api.test.mjs +150 -5
  131. package/scripts/local-discovery.mjs +94 -9
  132. package/scripts/manifest.mjs +14 -3
  133. package/scripts/manifest.test.mjs +24 -0
  134. package/scripts/output-diff.mjs +94 -0
  135. package/scripts/output-diff.test.mjs +36 -0
  136. package/scripts/package-smoke.mjs +62 -4
  137. package/scripts/plugin-host.mjs +45 -3
  138. package/scripts/plugin-host.test.mjs +9 -0
  139. package/scripts/plugin-wasm-worker.mjs +4 -1
  140. package/scripts/plugins-fresh.mjs +97 -0
  141. package/scripts/plugins-fresh.test.mjs +64 -0
  142. package/scripts/provenance.mjs +72 -0
  143. package/scripts/provenance.test.mjs +149 -0
  144. package/scripts/run-builtin.mjs +62 -7
  145. package/scripts/schema.mjs +167 -19
  146. package/scripts/trace-trials.mjs +176 -0
  147. package/scripts/trace-trials.test.mjs +142 -0
  148. package/scripts/warning-policy.mjs +167 -0
  149. package/scripts/warning-policy.test.mjs +93 -0
  150. package/src/app/Breadcrumbs.test.ts +3 -0
  151. package/src/app/Breadcrumbs.tsx +3 -0
  152. package/src/app/CatalogApp.tsx +2 -0
  153. package/src/app/Sidebar.tsx +3 -3
  154. package/src/app/SidebarFooter.tsx +20 -4
  155. package/src/catalog-docs.test.ts +64 -0
  156. package/src/catalog-docs.ts +35 -0
  157. package/src/catalog-error.test.ts +15 -0
  158. package/src/catalog-index.ts +25 -0
  159. package/src/catalog-model.ts +293 -5
  160. package/src/catalog-validation.ts +113 -2
  161. package/src/catalog.test.ts +40 -1
  162. package/src/chat/Starter.tsx +5 -11
  163. package/src/chat/tools.test.ts +27 -0
  164. package/src/chat/tools.ts +5 -9
  165. package/src/components/CatalogStamp.tsx +10 -8
  166. package/src/components/ChannelRows.test.tsx +76 -1
  167. package/src/components/ChannelRows.tsx +223 -60
  168. package/src/components/DeploymentRows.tsx +131 -0
  169. package/src/components/DocsLinks.test.tsx +27 -0
  170. package/src/components/DocsLinks.tsx +56 -0
  171. package/src/components/FieldTree.tsx +5 -2
  172. package/src/components/HTTPDestinationEvidence.test.tsx +23 -0
  173. package/src/components/HTTPDestinationEvidence.tsx +31 -0
  174. package/src/components/Integrations.tsx +1 -1
  175. package/src/components/MachineDocs.tsx +6 -5
  176. package/src/components/MethodRows.tsx +9 -2
  177. package/src/components/PluginIcon.tsx +77 -0
  178. package/src/components/ProblemRow.tsx +173 -153
  179. package/src/components/RelationEvidence.test.tsx +14 -0
  180. package/src/components/RelationEvidence.tsx +53 -0
  181. package/src/components/RuleMarks.tsx +22 -0
  182. package/src/components/ShapeRows.tsx +24 -20
  183. package/src/data.ts +46 -7
  184. package/src/enrich.test.ts +459 -4
  185. package/src/enrich.ts +308 -7
  186. package/src/er/ErCanvas.tsx +218 -12
  187. package/src/er/GroupNode.tsx +57 -0
  188. package/src/er/StoreHeader.tsx +1 -0
  189. package/src/er/layout.test.ts +85 -2
  190. package/src/er/layout.ts +140 -5
  191. package/src/er/spec.test.ts +17 -0
  192. package/src/er/spec.ts +23 -10
  193. package/src/flow/Recordings.test.tsx +52 -0
  194. package/src/flow/Recordings.tsx +236 -0
  195. package/src/flow/StepDetail.tsx +59 -0
  196. package/src/flow/TraceTrial.tsx +419 -0
  197. package/src/flow/evidence.test.ts +16 -0
  198. package/src/flow/evidence.ts +34 -0
  199. package/src/flow/examples.test.ts +33 -0
  200. package/src/flow/examples.ts +37 -0
  201. package/src/flow/outline.test.ts +28 -0
  202. package/src/flow/outline.ts +7 -2
  203. package/src/flow/trace-trial-resume.test.ts +42 -0
  204. package/src/flow/trace-trial-resume.ts +74 -0
  205. package/src/graph/elk.ts +78 -0
  206. package/src/index.css +68 -0
  207. package/src/landing/DraggableReveal.tsx +3 -2
  208. package/src/landing/EvidencePipeline.tsx +105 -0
  209. package/src/landing/LandingPage.tsx +17 -70
  210. package/src/landing/ProductTour.tsx +6 -6
  211. package/src/lib/all-problems.ts +27 -17
  212. package/src/lib/catalog-diff.ts +1 -1
  213. package/src/lib/centrality.test.ts +251 -0
  214. package/src/lib/centrality.ts +232 -0
  215. package/src/lib/confluence.test.ts +37 -0
  216. package/src/lib/confluence.ts +41 -0
  217. package/src/lib/context-color.ts +1 -1
  218. package/src/lib/deployment-drift.ts +22 -0
  219. package/src/lib/derive.ts +43 -69
  220. package/src/lib/django-aggregates.d.mts +9 -0
  221. package/src/lib/django-aggregates.mjs +36 -0
  222. package/src/lib/django-aggregates.test.ts +29 -0
  223. package/src/lib/django-aggregates.ts +5 -0
  224. package/src/lib/environments.test.ts +83 -0
  225. package/src/lib/environments.ts +57 -0
  226. package/src/lib/integration-url.test.ts +30 -0
  227. package/src/lib/integration-url.ts +63 -0
  228. package/src/lib/kafka-ui.ts +3 -45
  229. package/src/lib/local-api.ts +116 -4
  230. package/src/lib/notion.ts +13 -0
  231. package/src/lib/plugin-index.json +2765 -0
  232. package/src/lib/plugins.test.ts +68 -0
  233. package/src/lib/plugins.ts +264 -0
  234. package/src/lib/problem-flows.test.ts +61 -0
  235. package/src/lib/problem-flows.ts +78 -0
  236. package/src/lib/problem-rules-cel.d.mts +43 -0
  237. package/src/lib/problem-rules-cel.mjs +407 -0
  238. package/src/lib/problem-rules.test.ts +287 -0
  239. package/src/lib/problem-rules.ts +271 -0
  240. package/src/lib/problem-subjects.ts +737 -0
  241. package/src/lib/rule-entries.ts +39 -0
  242. package/src/lib/{data-problems.test.ts → rules-data.test.ts} +18 -16
  243. package/src/lib/rules-deploy.test.ts +133 -0
  244. package/src/lib/{problems.test.ts → rules-edges.test.ts} +8 -3
  245. package/src/lib/{proto-problems.test.ts → rules-proto.test.ts} +9 -25
  246. package/src/lib/{wire-problems.test.ts → rules-wire.test.ts} +13 -11
  247. package/src/lib/rules.test.ts +51 -0
  248. package/src/lib/rules.ts +86 -0
  249. package/src/lib/setup-info.test.ts +17 -0
  250. package/src/lib/setup-info.ts +58 -0
  251. package/src/lib/shape.test.ts +32 -0
  252. package/src/lib/shape.ts +30 -6
  253. package/src/lib/tech.ts +16 -0
  254. package/src/lib/trace-project.test.ts +34 -0
  255. package/src/lib/trace-project.ts +50 -0
  256. package/src/lib/use-problems.ts +23 -0
  257. package/src/lib/warnings.test.ts +63 -0
  258. package/src/lib/warnings.ts +260 -0
  259. package/src/likec4/ids.test.ts +6 -2
  260. package/src/likec4/ids.ts +43 -0
  261. package/src/main.tsx +23 -0
  262. package/src/map/ContextMapGraph.tsx +76 -32
  263. package/src/merge-deployments.test.ts +127 -0
  264. package/src/merge.test.ts +82 -0
  265. package/src/merge.ts +189 -18
  266. package/src/pages/AggregatePage.tsx +65 -14
  267. package/src/pages/ContextMap.tsx +45 -3
  268. package/src/pages/ContextPage.tsx +8 -5
  269. package/src/pages/EventPage.tsx +15 -5
  270. package/src/pages/FlowDetail.tsx +23 -2
  271. package/src/pages/GraphPage.tsx +40 -3
  272. package/src/pages/Overview.tsx +152 -12
  273. package/src/pages/PluginIndex.tsx +190 -0
  274. package/src/pages/Problems.tsx +396 -128
  275. package/src/pages/ServicePage.tsx +62 -5
  276. package/src/pages/Settings.tsx +217 -43
  277. package/src/pages/settings/AboutSettings.tsx +8 -1
  278. package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
  279. package/src/pages/settings/IntegrationsSettings.tsx +63 -17
  280. package/src/pages/settings/RecordingSettings.tsx +138 -0
  281. package/src/pages/settings/RulesSettings.tsx +825 -0
  282. package/src/routes.test.ts +9 -0
  283. package/src/routes.ts +22 -1
  284. package/src/selection/DetailPanel.tsx +15 -0
  285. package/src/virtual-provenance.d.ts +11 -0
  286. package/vite.config.ts +5 -0
  287. package/scripts/vendor-lock.mjs +0 -58
  288. package/scripts/vendor-lock.test.mjs +0 -69
  289. package/src/lib/data-problems.ts +0 -314
  290. package/src/lib/proto-problems.ts +0 -237
  291. package/src/lib/wire-problems.ts +0 -342
@@ -0,0 +1,825 @@
1
+ // The rules behind the Problems page, as a table with switches.
2
+ //
3
+ // Every row is one rule: the built-in readers with their passports from
4
+ // rules/builtin.json, then the estate's own, written in CEL in portolan.json.
5
+ // A reader sees what each checks, how many rows it produces right now, and
6
+ // what to do about one; in local mode they switch a rule off with a reason,
7
+ // re-grade it, or write a new one and watch it run over the catalog before
8
+ // it is saved. Nothing is written until the server has type-checked every
9
+ // expression against the same module the page checked it with.
10
+ //
11
+ // The page is laid out like the pipeline's plugin list - one bordered card,
12
+ // a muted group header, rows that open in place - so a reader who knows one
13
+ // settings tab knows this one. A row reads left to right the way a reader
14
+ // asks: is it on, what is it about, what is it called, how bad is a hit, how
15
+ // many hits are there now.
16
+
17
+ import { useEffect, useMemo, useRef, useState } from "react";
18
+ import { Switch } from "@headlessui/react";
19
+ import { Link } from "react-router";
20
+ import { ChevronDown, Pencil, Plus, Search, Trash2, X } from "lucide-react";
21
+ import { catalog, index } from "../../data";
22
+ import { useToastStore } from "../../app/toast";
23
+ import { KindIcon } from "../../components/kind";
24
+ import { ICON_OF } from "../../components/ProblemRow";
25
+ import { SectionTitle } from "../../components/PageHeader";
26
+ import { plural } from "../../lib/format";
27
+ import { problemRules as readProblemRules, saveProblemRules } from "../../lib/local-api";
28
+ import {
29
+ runCustomRule,
30
+ SUBJECTS,
31
+ useProblemRules,
32
+ useRuleEntries,
33
+ } from "../../lib/problem-rules";
34
+ import type { ProblemRule, ProblemRuleEntry, RuleSeverity, RuleSubject } from "../../lib/problem-rules";
35
+ import { regraded, switched } from "../../lib/rule-entries";
36
+ import { useProblemEvaluation } from "../../lib/use-problems";
37
+ import { paths } from "../../routes";
38
+
39
+ const FIELD = "mono w-full rounded-control border border-line bg-canvas px-3 py-2 text-ink outline-none focus:border-accent";
40
+ // A control that takes its own width: a filter beside other filters.
41
+ const CONTROL = "mono rounded-control border border-line bg-canvas px-2.5 py-1 text-ink outline-none focus:border-accent";
42
+ const SUBJECT_NAMES = Object.keys(SUBJECTS) as RuleSubject[];
43
+
44
+
45
+ const SEVERITY_CHIP: Record<RuleSeverity, string> = {
46
+ error: "chip status-unresolved",
47
+ warning: "chip status-declared",
48
+ };
49
+
50
+ export function RulesSettings({ local }: { local: boolean }) {
51
+ const say = useToastStore((s) => s.say);
52
+ const rules = useProblemRules();
53
+ const { matches, failures } = useProblemEvaluation();
54
+ const entries = useRuleEntries((s) => s.entries);
55
+ const setEntries = useRuleEntries((s) => s.setEntries);
56
+ const [revision, setRevision] = useState<string | null>(null);
57
+ const [busy, setBusy] = useState(false);
58
+ const [editing, setEditing] = useState<"new" | string | null>(null);
59
+
60
+ // The file on disk is the truth in local mode: the bundle's copy of the
61
+ // manifest may be older than the server's, and a save must quote the
62
+ // revision it read.
63
+ useEffect(() => {
64
+ if (!local) return;
65
+ let cancelled = false;
66
+ readProblemRules()
67
+ .then((state) => {
68
+ if (cancelled) return;
69
+ setRevision(state.revision);
70
+ setEntries(state.rules);
71
+ })
72
+ .catch((cause) => say(cause instanceof Error ? cause.message : String(cause)));
73
+ return () => {
74
+ cancelled = true;
75
+ };
76
+ }, [local, setEntries, say]);
77
+
78
+ async function write(next: ProblemRuleEntry[], done: string) {
79
+ if (!revision) return;
80
+ setBusy(true);
81
+ try {
82
+ const saved = await saveProblemRules(revision, next);
83
+ setRevision(saved.revision);
84
+ setEntries(saved.rules);
85
+ setEditing(null);
86
+ say(done);
87
+ } catch (cause) {
88
+ say(cause instanceof Error ? cause.message : String(cause));
89
+ } finally {
90
+ setBusy(false);
91
+ }
92
+ }
93
+
94
+ // The filters narrow what is listed, never what is in force: a rule hidden
95
+ // here still runs. They are the page's own state and are not written
96
+ // anywhere - a filter is how a reader looks, not a fact about the estate.
97
+ const [query, setQuery] = useState("");
98
+ const [over, setOver] = useState<RuleSubject | "all">("all");
99
+ const [severity, setSeverity] = useState<RuleSeverity | "all">("all");
100
+ const [state, setState] = useState<"all" | "on" | "off">("all");
101
+ const [rows, setRows] = useState<"all" | "with" | "without">("all");
102
+ const needle = query.trim().toLowerCase();
103
+ const shown = rules.filter((rule) => {
104
+ const count = matches.get(rule.id) ?? 0;
105
+ return (
106
+ (over === "all" || rule.over === over) &&
107
+ (severity === "all" || rule.severity === severity) &&
108
+ (state === "all" || (state === "on") === rule.enabled) &&
109
+ (rows === "all" || (rows === "with") === count > 0) &&
110
+ (!needle || rule.id.includes(needle) || rule.title.toLowerCase().includes(needle) || rule.note.toLowerCase().includes(needle))
111
+ );
112
+ });
113
+ const filtered = shown.length !== rules.length;
114
+ const clearFilters = () => {
115
+ setQuery("");
116
+ setOver("all");
117
+ setSeverity("all");
118
+ setState("all");
119
+ setRows("all");
120
+ };
121
+ const builtin = shown.filter((rule) => rule.builtin);
122
+ const custom = shown.filter((rule) => !rule.builtin);
123
+ const customTotal = rules.filter((rule) => !rule.builtin).length;
124
+ const off = rules.filter((rule) => !rule.enabled).length;
125
+ const producing = rules.filter((rule) => rule.enabled && (matches.get(rule.id) ?? 0) > 0).length;
126
+ const total = rules.filter((rule) => rule.enabled).reduce((sum, rule) => sum + (matches.get(rule.id) ?? 0), 0);
127
+ const canWrite = local && revision !== null && !busy;
128
+
129
+ const row = (rule: ProblemRule) => (
130
+ <RuleRow
131
+ key={rule.id}
132
+ rule={rule}
133
+ count={matches.get(rule.id) ?? 0}
134
+ failure={failures.find((failure) => failure.rule === rule.id)?.message}
135
+ canWrite={canWrite}
136
+ onToggle={(enabled, reason) => void write(switched(entries, rule, enabled, reason), enabled ? `${rule.id} is on` : `${rule.id} is off`)}
137
+ onSeverity={(next) => void write(regraded(entries, rule, next), `${rule.id} is now ${next}`)}
138
+ {...(rule.builtin
139
+ ? {}
140
+ : {
141
+ onEdit: () => setEditing(rule.id),
142
+ onRemove: () => void write(entries.filter((entry) => entry.id !== rule.id), `${rule.id} is removed`),
143
+ })}
144
+ />
145
+ );
146
+
147
+ return (
148
+ <section>
149
+ <SectionTitle right={local ? "written to portolan.json after a type check" : "declared in portolan.json"}>Rules</SectionTitle>
150
+ <p className="max-w-prose text-muted">
151
+ What the Problems page looks for. Every rule is one condition in CEL over one subject - a table that knows who writes it, a channel that knows who
152
+ else publishes there. The shipped ones can be switched off or re-graded here, with a reason; a rule of your own is written the same way and runs the
153
+ moment it is saved.
154
+ </p>
155
+
156
+ {/* Four numbers a reader wants before the list: how many rules there
157
+ are, how many are finding something, how many the estate switched
158
+ off, and how many it wrote itself. */}
159
+ <div className="mt-4 grid grid-cols-2 gap-grid sm:grid-cols-4">
160
+ <Stat value={rules.length} label={plural(rules.length, "rule")} />
161
+ <Stat value={producing} label={`producing ${total} ${plural(total, "row")}`} to={paths.problems()} />
162
+ <Stat value={off} label="switched off" tone={off > 0 ? "muted" : undefined} />
163
+ <Stat value={customTotal} label="of your own" />
164
+ </div>
165
+
166
+ {failures.length > 0 ? (
167
+ <div className="mt-4 rounded-control border border-unresolved px-3 py-2">
168
+ <div className="text-ink">
169
+ {failures.length} {plural(failures.length, "rule")} could not run, so the rows {failures.length === 1 ? "it" : "they"} would produce are missing
170
+ </div>
171
+ <ul className="mono mt-1 space-y-0.5 text-muted">
172
+ {failures.map((failure) => (
173
+ <li key={failure.rule}>
174
+ <a href={`#rule-${failure.rule}`} className="text-accent hover:underline">
175
+ {failure.rule}
176
+ </a>{" "}
177
+ — {failure.message}
178
+ </li>
179
+ ))}
180
+ </ul>
181
+ </div>
182
+ ) : null}
183
+
184
+ <div className="mt-section flex flex-wrap items-center gap-2">
185
+ <label className="relative">
186
+ <Search size={13} aria-hidden className="pointer-events-none absolute top-1/2 left-2.5 -translate-y-1/2 text-faint" />
187
+ <input
188
+ type="search"
189
+ aria-label="Find a rule"
190
+ className={`${CONTROL} min-w-52 pl-7`}
191
+ value={query}
192
+ onChange={(event) => setQuery(event.target.value)}
193
+ placeholder="find by id or title"
194
+ spellCheck={false}
195
+ />
196
+ </label>
197
+ <select aria-label="Filter rules by subject" className={CONTROL} value={over} onChange={(event) => setOver(event.target.value as RuleSubject | "all")}>
198
+ <option value="all">all subjects</option>
199
+ {SUBJECT_NAMES.map((name) => (
200
+ <option key={name} value={name}>
201
+ {name}
202
+ </option>
203
+ ))}
204
+ </select>
205
+ <select aria-label="Filter rules by severity" className={CONTROL} value={severity} onChange={(event) => setSeverity(event.target.value as RuleSeverity | "all")}>
206
+ <option value="all">all severities</option>
207
+ <option value="error">error</option>
208
+ <option value="warning">warning</option>
209
+ </select>
210
+ <select aria-label="Filter rules by switch" className={CONTROL} value={state} onChange={(event) => setState(event.target.value as "all" | "on" | "off")}>
211
+ <option value="all">on and off</option>
212
+ <option value="on">on</option>
213
+ <option value="off">off</option>
214
+ </select>
215
+ <select aria-label="Filter rules by rows" className={CONTROL} value={rows} onChange={(event) => setRows(event.target.value as "all" | "with" | "without")}>
216
+ <option value="all">with and without rows</option>
217
+ <option value="with">with rows</option>
218
+ <option value="without">without rows</option>
219
+ </select>
220
+ {filtered ? (
221
+ <span className="mono ml-auto flex items-center gap-2 text-muted">
222
+ <span>
223
+ <span className="tnum text-ink">{shown.length}</span> of {rules.length}
224
+ </span>
225
+ <button type="button" className="tbtn py-0.5" onClick={clearFilters}>
226
+ <X size={12} aria-hidden /> clear
227
+ </button>
228
+ </span>
229
+ ) : null}
230
+ </div>
231
+
232
+ <div className="mt-3 overflow-hidden rounded-card border border-line shadow-xs">
233
+ <section>
234
+ <h3 className="label flex items-center gap-2 bg-surface px-3 py-1.5">
235
+ built in
236
+ <span className="text-muted/70">{builtin.length}</span>
237
+ </h3>
238
+ {builtin.length === 0 ? <p className="mono border-t border-line px-3 py-3 text-muted">No built-in rule matches the filter.</p> : null}
239
+ {builtin.map(row)}
240
+ </section>
241
+ <section className="border-t border-line">
242
+ <h3 className="label flex items-center gap-2 bg-surface px-3 py-1.5">
243
+ yours
244
+ <span className="text-muted/70">{customTotal}</span>
245
+ {local ? (
246
+ <button type="button" className="tbtn ml-auto normal-case" onClick={() => setEditing("new")} disabled={!canWrite || editing === "new"}>
247
+ <Plus size={13} aria-hidden /> Add rule
248
+ </button>
249
+ ) : null}
250
+ </h3>
251
+ {editing === "new" ? (
252
+ <div className="border-t border-line">
253
+ <RuleEditor
254
+ taken={new Set(rules.map((rule) => rule.id))}
255
+ busy={busy}
256
+ onCancel={() => setEditing(null)}
257
+ onSave={(entry) => void write([...entries, entry], `${entry.id} is saved`)}
258
+ />
259
+ </div>
260
+ ) : null}
261
+ {custom.length === 0 && editing !== "new" ? (
262
+ <p className="border-t border-line px-3 py-3 text-muted">
263
+ {customTotal > 0
264
+ ? "No rule of yours matches the filter."
265
+ : local
266
+ ? "No rules of your own yet. A rule is one subject, a condition and a message; the built-in rows above show the shape."
267
+ : "portolan.json declares no rules of its own."}
268
+ </p>
269
+ ) : null}
270
+ {custom.map((rule) =>
271
+ editing === rule.id ? (
272
+ <div key={rule.id} className="border-t border-line">
273
+ <RuleEditor
274
+ initial={entries.find((entry) => entry.id === rule.id)}
275
+ taken={new Set(rules.filter((other) => other.id !== rule.id).map((other) => other.id))}
276
+ busy={busy}
277
+ onCancel={() => setEditing(null)}
278
+ onSave={(entry) => void write(entries.map((existing) => (existing.id === rule.id ? entry : existing)), `${entry.id} is saved`)}
279
+ />
280
+ </div>
281
+ ) : (
282
+ row(rule)
283
+ ),
284
+ )}
285
+ </section>
286
+ </div>
287
+
288
+ <SubjectReference />
289
+ </section>
290
+ );
291
+ }
292
+
293
+ function Stat({ value, label, to, tone }: { value: number; label: string; to?: string; tone?: "muted" }) {
294
+ const body = (
295
+ <>
296
+ <span className={`tnum text-lg leading-none ${tone === "muted" && value > 0 ? "text-declared" : "text-ink"}`}>{value}</span>
297
+ <span className="mono mt-1 block text-muted">{label}</span>
298
+ </>
299
+ );
300
+ const className = "block rounded-control border border-line px-3 py-2";
301
+ return to ? (
302
+ <Link to={to} className={`${className} hover:border-accent`}>
303
+ {body}
304
+ </Link>
305
+ ) : (
306
+ <div className={className}>{body}</div>
307
+ );
308
+ }
309
+
310
+ // ---------------------------------------------------------------------------
311
+ // One rule.
312
+
313
+ /**
314
+ * The switch the delivery presets use: Headless UI's, styled the same way,
315
+ * so a toggle is one thing across the settings tabs. Off on a shipped rule
316
+ * in local mode asks for a reason first, which is why the change handler
317
+ * is the row's and not a plain setter.
318
+ */
319
+ function RuleSwitch({ on, id, disabled, onChange }: { on: boolean; id: string; disabled: boolean; onChange: () => void }) {
320
+ return (
321
+ <Switch
322
+ checked={on}
323
+ disabled={disabled}
324
+ onChange={onChange}
325
+ aria-label={`Switch ${on ? "off" : "on"} ${id}`}
326
+ title={disabled ? "local mode required" : on ? "on · click to switch off" : "off · click to switch on"}
327
+ className="group inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-line-strong bg-surface transition-colors outline-none data-checked:border-accent data-checked:bg-accent focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed"
328
+ >
329
+ <span
330
+ aria-hidden
331
+ className="size-3.5 translate-x-0.5 rounded-full bg-muted shadow-xs transition-transform group-data-checked:translate-x-[18px] group-data-checked:bg-canvas"
332
+ />
333
+ </Switch>
334
+ );
335
+ }
336
+
337
+ function RuleRow({
338
+ rule,
339
+ count,
340
+ failure,
341
+ canWrite,
342
+ onToggle,
343
+ onSeverity,
344
+ onEdit,
345
+ onRemove,
346
+ }: {
347
+ rule: ProblemRule;
348
+ count: number;
349
+ failure: string | undefined;
350
+ canWrite: boolean;
351
+ onToggle: (enabled: boolean, reason: string) => void;
352
+ onSeverity: (severity: RuleSeverity) => void;
353
+ onEdit?: () => void;
354
+ onRemove?: () => void;
355
+ }) {
356
+ const [open, setOpen] = useState(false);
357
+ const [askingReason, setAskingReason] = useState(false);
358
+ const [reason, setReason] = useState("");
359
+ const regradedFrom = rule.severity !== rule.defaultSeverity ? rule.defaultSeverity : null;
360
+
361
+ function toggle() {
362
+ if (!canWrite) return;
363
+ if (rule.enabled && rule.builtin) {
364
+ setAskingReason(true);
365
+ setOpen(true);
366
+ return;
367
+ }
368
+ onToggle(!rule.enabled, "");
369
+ }
370
+
371
+ return (
372
+ <div id={`rule-${rule.id}`} className="scroll-mt-4 border-t border-line">
373
+ <div className={`grid grid-cols-[auto_auto_minmax(0,1fr)_auto] items-center gap-x-3 px-3 py-2 sm:grid-cols-[auto_auto_minmax(0,1fr)_auto_5.5rem_auto] ${open ? "bg-surface/60" : "hover:bg-surface/60"}`}>
374
+ <RuleSwitch on={rule.enabled} id={rule.id} disabled={!canWrite} onChange={toggle} />
375
+ <KindIcon kind={ICON_OF[rule.over]} className={rule.enabled ? "" : "opacity-50"} />
376
+ <button
377
+ type="button"
378
+ className="min-w-0 cursor-pointer text-left"
379
+ aria-expanded={open}
380
+ aria-label={`${open ? "Collapse" : "Expand"} ${rule.id}`}
381
+ onClick={() => setOpen((v) => !v)}
382
+ >
383
+ <span className={`block truncate ${rule.enabled ? "text-ink" : "text-muted"}`}>{rule.title}</span>
384
+ {/* One line of what the rule checks. The id, the subject and the
385
+ CEL are read in the panel; a closed row is for scanning. */}
386
+ <span className="block truncate text-muted" title={rule.description}>
387
+ {!rule.enabled ? <span className="text-faint">off{rule.reason ? ` — ${rule.reason}` : ""} · </span> : null}
388
+ {rule.enabled && regradedFrom ? <span className="text-faint">was {regradedFrom} · </span> : null}
389
+ {rule.description || rule.note}
390
+ </span>
391
+ </button>
392
+ <span className={SEVERITY_CHIP[rule.severity]} title={regradedFrom ? `re-graded from ${regradedFrom}` : undefined}>
393
+ {rule.severity}
394
+ </span>
395
+ <span className="mono hidden text-right text-muted sm:block">
396
+ {failure ? (
397
+ <span className="text-unresolved">failed</span>
398
+ ) : count > 0 ? (
399
+ <Link
400
+ to={`${paths.problems()}?rule=${encodeURIComponent(rule.id)}`}
401
+ className={`hover:underline ${rule.enabled ? "text-ink" : ""}`}
402
+ title={rule.enabled ? "Rows on the Problems page" : "Rows this rule would produce if switched on"}
403
+ >
404
+ <span className="tnum">{count}</span> {plural(count, "row")}
405
+ </Link>
406
+ ) : (
407
+ <span className="text-faint">no rows</span>
408
+ )}
409
+ </span>
410
+ <button type="button" className="tbtn hidden border-transparent p-1 sm:flex" aria-hidden tabIndex={-1} onClick={() => setOpen((v) => !v)}>
411
+ <ChevronDown size={15} aria-hidden className={`transition-transform ${open ? "rotate-180" : ""}`} />
412
+ </button>
413
+ </div>
414
+
415
+ {open ? (
416
+ <div className="border-t border-line bg-surface/50 px-4 py-4">
417
+ {askingReason ? (
418
+ <form
419
+ className="mb-4 flex flex-wrap items-end gap-2 rounded-control border border-accent bg-canvas p-3"
420
+ onSubmit={(event) => {
421
+ event.preventDefault();
422
+ if (!reason.trim()) return;
423
+ onToggle(false, reason.trim());
424
+ setAskingReason(false);
425
+ setReason("");
426
+ }}
427
+ >
428
+ <label className="block min-w-64 flex-1">
429
+ <span className="label mb-1.5 block">why is this rule off here</span>
430
+ <input className={FIELD} value={reason} onChange={(event) => setReason(event.target.value)} placeholder="the estate shares one database by design" autoFocus />
431
+ </label>
432
+ <button type="submit" className="product-primary" disabled={!reason.trim()}>
433
+ Switch off
434
+ </button>
435
+ <button type="button" className="tbtn py-1.5" onClick={() => setAskingReason(false)}>
436
+ Keep on
437
+ </button>
438
+ </form>
439
+ ) : null}
440
+ <div className="grid gap-5 lg:grid-cols-[minmax(0,1.4fr)_minmax(14rem,1fr)]">
441
+ <div className="space-y-4">
442
+ {rule.description ? (
443
+ <div>
444
+ <div className="label mb-1.5">what it checks</div>
445
+ <p className="max-w-prose text-ink">{rule.description}</p>
446
+ </div>
447
+ ) : null}
448
+ {rule.action ? (
449
+ <div>
450
+ <div className="label mb-1.5">what to do about a row</div>
451
+ <p className="max-w-prose text-muted">{rule.action}</p>
452
+ </div>
453
+ ) : null}
454
+ {rule.when ? <Expression label="when" source={rule.when} /> : null}
455
+ {rule.message ? <Expression label="message" source={rule.message} /> : null}
456
+ {rule.peer ? <Expression label="peer" source={rule.peer} /> : null}
457
+ {failure ? (
458
+ <div>
459
+ <div className="label mb-1.5 text-unresolved">could not run</div>
460
+ <p className="mono text-unresolved">{failure}</p>
461
+ </div>
462
+ ) : null}
463
+ </div>
464
+ <dl className="mono grid h-fit grid-cols-[auto_minmax(0,1fr)] items-center gap-x-4 gap-y-2 text-muted">
465
+ <dt>id</dt>
466
+ <dd className="text-ink">{rule.id}</dd>
467
+ <dt>subject</dt>
468
+ <dd className="flex items-center gap-1.5 text-ink">
469
+ <KindIcon kind={ICON_OF[rule.over]} size={12} /> {rule.over}
470
+ </dd>
471
+ <dt>severity</dt>
472
+ <dd>
473
+ {canWrite ? (
474
+ <select aria-label={`Severity of ${rule.id}`} className={CONTROL} value={rule.severity} onChange={(event) => onSeverity(event.target.value as RuleSeverity)}>
475
+ <option value="error">error</option>
476
+ <option value="warning">warning</option>
477
+ </select>
478
+ ) : (
479
+ <span className={SEVERITY_CHIP[rule.severity]}>{rule.severity}</span>
480
+ )}
481
+ {regradedFrom ? <span className="ml-2 text-faint">was {regradedFrom}</span> : null}
482
+ </dd>
483
+ <dt>rows</dt>
484
+ <dd className="text-ink">
485
+ {count > 0 ? (
486
+ <Link to={`${paths.problems()}?rule=${encodeURIComponent(rule.id)}`} className="text-accent hover:underline">
487
+ {count} on the Problems page
488
+ </Link>
489
+ ) : (
490
+ <span className="text-muted">none right now</span>
491
+ )}
492
+ </dd>
493
+ <dt>state</dt>
494
+ <dd className="text-ink">{rule.enabled ? "on" : "off"}</dd>
495
+ {rule.reason ? (
496
+ <>
497
+ <dt className="self-start">reason</dt>
498
+ <dd className="font-sans text-ink">{rule.reason}</dd>
499
+ </>
500
+ ) : null}
501
+ <dt>written in</dt>
502
+ <dd className="text-ink">{rule.builtin ? "rules/builtin.json, shipped with Portolan" : "portolan.json → problemRules"}</dd>
503
+ {onEdit || onRemove ? (
504
+ <>
505
+ <dt />
506
+ <dd className="flex flex-wrap gap-2">
507
+ {onEdit ? (
508
+ <button type="button" className="tbtn" onClick={onEdit} disabled={!canWrite}>
509
+ <Pencil size={13} aria-hidden /> Edit
510
+ </button>
511
+ ) : null}
512
+ {onRemove ? (
513
+ <button type="button" className="tbtn text-unresolved" onClick={onRemove} disabled={!canWrite}>
514
+ <Trash2 size={13} aria-hidden /> Remove
515
+ </button>
516
+ ) : null}
517
+ </dd>
518
+ </>
519
+ ) : null}
520
+ </dl>
521
+ </div>
522
+ </div>
523
+ ) : null}
524
+ </div>
525
+ );
526
+ }
527
+
528
+ function Expression({ label, source }: { label: string; source: string }) {
529
+ return (
530
+ <div>
531
+ <div className="label mb-1.5">{label}</div>
532
+ <pre className="mono overflow-x-auto rounded-control border border-line bg-canvas px-3 py-2 whitespace-pre-wrap text-ink">{source}</pre>
533
+ </div>
534
+ );
535
+ }
536
+
537
+ // ---------------------------------------------------------------------------
538
+ // Writing a rule, with the catalog answering as you type.
539
+
540
+ const RULE_ID = /^[a-z0-9]+(?:[-.][a-z0-9]+)*$/;
541
+
542
+ function RuleEditor({
543
+ initial,
544
+ taken,
545
+ busy,
546
+ onCancel,
547
+ onSave,
548
+ }: {
549
+ initial?: ProblemRuleEntry;
550
+ taken: Set<string>;
551
+ busy: boolean;
552
+ onCancel: () => void;
553
+ onSave: (entry: ProblemRuleEntry) => void;
554
+ }) {
555
+ const [draft, setDraft] = useState<ProblemRuleEntry>(() => ({
556
+ id: initial?.id ?? "",
557
+ title: initial?.title ?? "",
558
+ over: initial?.over ?? "service",
559
+ severity: initial?.severity ?? "warning",
560
+ when: initial?.when ?? "",
561
+ message: initial?.message ?? "",
562
+ peer: initial?.peer ?? "",
563
+ note: initial?.note ?? "",
564
+ action: initial?.action ?? "",
565
+ reason: initial?.reason ?? "",
566
+ ...(initial?.enabled === false ? { enabled: false } : {}),
567
+ }));
568
+ const set = <K extends keyof ProblemRuleEntry>(key: K, value: ProblemRuleEntry[K]) => setDraft((prev) => ({ ...prev, [key]: value }));
569
+ const whenBox = useRef<HTMLTextAreaElement | null>(null);
570
+ const subject = draft.over ?? "service";
571
+
572
+ // A field chip drops `subject.field` where the cursor is in the condition,
573
+ // which is how a reader learns the vocabulary without leaving the box.
574
+ function insertField(field: string) {
575
+ const box = whenBox.current;
576
+ const text = draft.when ?? "";
577
+ const token = `${subject}.${field}`;
578
+ if (!box) {
579
+ set("when", text ? `${text} ${token}` : token);
580
+ return;
581
+ }
582
+ const start = box.selectionStart ?? text.length;
583
+ const end = box.selectionEnd ?? text.length;
584
+ const before = text.slice(0, start);
585
+ const after = text.slice(end);
586
+ const next = `${before}${before && !/\s$/.test(before) ? " " : ""}${token}${after && !/^\s/.test(after) ? " " : ""}${after}`;
587
+ set("when", next);
588
+ requestAnimationFrame(() => {
589
+ box.focus();
590
+ const at = next.length - after.length - (after && !/^\s/.test(after) ? 1 : 0);
591
+ box.setSelectionRange(at, at);
592
+ });
593
+ }
594
+
595
+ // The draft, run over the catalog on every keystroke: the count and the
596
+ // first rows are how a reader learns whether the expression says what they
597
+ // meant, before it is a rule.
598
+ const preview = useMemo(() => {
599
+ if (!draft.when?.trim() || !draft.message?.trim()) return null;
600
+ const rule: ProblemRule = {
601
+ id: draft.id || "draft",
602
+ over: subject,
603
+ severity: draft.severity ?? "warning",
604
+ defaultSeverity: draft.severity ?? "warning",
605
+ title: draft.title ?? "",
606
+ note: draft.note ?? "",
607
+ description: "",
608
+ action: "",
609
+ builtin: false,
610
+ enabled: true,
611
+ when: draft.when,
612
+ message: draft.message,
613
+ ...(draft.peer?.trim() ? { peer: draft.peer } : {}),
614
+ };
615
+ return runCustomRule(rule, catalog, index);
616
+ }, [draft, subject]);
617
+
618
+ const idProblem = !draft.id ? "required" : !RULE_ID.test(draft.id) ? "lower-case words joined by - or ." : taken.has(draft.id) ? "already a rule" : null;
619
+ const ready = !idProblem && draft.title?.trim() && draft.when?.trim() && draft.message?.trim() && preview && !preview.failure;
620
+ const fields = Object.entries(SUBJECTS[subject].schema);
621
+
622
+ function save() {
623
+ if (!ready) return;
624
+ const entry: ProblemRuleEntry = {
625
+ id: draft.id,
626
+ over: subject,
627
+ severity: draft.severity,
628
+ title: draft.title!.trim(),
629
+ when: draft.when!.trim(),
630
+ message: draft.message!.trim(),
631
+ };
632
+ if (draft.peer?.trim()) entry.peer = draft.peer.trim();
633
+ if (draft.note?.trim()) entry.note = draft.note.trim();
634
+ if (draft.action?.trim()) entry.action = draft.action.trim();
635
+ if (draft.reason?.trim()) entry.reason = draft.reason.trim();
636
+ if (draft.enabled === false) entry.enabled = false;
637
+ onSave(entry);
638
+ }
639
+
640
+ return (
641
+ <form
642
+ className="bg-surface/50 px-4 py-4"
643
+ onSubmit={(event) => {
644
+ event.preventDefault();
645
+ save();
646
+ }}
647
+ >
648
+ <div className="mb-4 flex flex-wrap items-baseline justify-between gap-2">
649
+ <div className="font-medium text-ink">{initial ? `Edit ${initial.id}` : "A rule of your own"}</div>
650
+ <span className="mono text-muted">one subject · a condition · a message</span>
651
+ </div>
652
+ <div className="grid gap-5 lg:grid-cols-[minmax(0,1.4fr)_minmax(16rem,1fr)]">
653
+ <div className="space-y-3">
654
+ <div className="grid gap-3 sm:grid-cols-2">
655
+ <label className="block">
656
+ <span className="label mb-1.5 block">id</span>
657
+ <input className={FIELD} value={draft.id} onChange={(event) => set("id", event.target.value)} placeholder="team.event-without-consumer" disabled={Boolean(initial)} spellCheck={false} />
658
+ {idProblem && draft.id ? <span className="mono mt-1 block text-unresolved">{idProblem}</span> : null}
659
+ </label>
660
+ <label className="block">
661
+ <span className="label mb-1.5 block">title</span>
662
+ <input className={FIELD} value={draft.title ?? ""} onChange={(event) => set("title", event.target.value)} placeholder="Event nobody consumes" />
663
+ </label>
664
+ <label className="block">
665
+ <span className="label mb-1.5 block">over</span>
666
+ <select className={FIELD} value={subject} onChange={(event) => set("over", event.target.value as RuleSubject)}>
667
+ {SUBJECT_NAMES.map((name) => (
668
+ <option key={name} value={name}>
669
+ {name} — {SUBJECTS[name].description}
670
+ </option>
671
+ ))}
672
+ </select>
673
+ </label>
674
+ <label className="block">
675
+ <span className="label mb-1.5 block">severity</span>
676
+ <select className={FIELD} value={draft.severity} onChange={(event) => set("severity", event.target.value as RuleSeverity)}>
677
+ <option value="error">error</option>
678
+ <option value="warning">warning</option>
679
+ </select>
680
+ </label>
681
+ </div>
682
+ <label className="block">
683
+ <span className="label mb-1.5 flex items-baseline justify-between">
684
+ <span>when · CEL returning bool</span>
685
+ <span className="normal-case text-faint">a row for every {subject} where this holds</span>
686
+ </span>
687
+ <textarea ref={whenBox} className={`${FIELD} min-h-20 resize-y`} value={draft.when ?? ""} onChange={(event) => set("when", event.target.value)} placeholder={`size(${subject}.consumers) == 0`} spellCheck={false} />
688
+ </label>
689
+ <div>
690
+ <div className="label mb-1.5">fields of {subject} · click to insert</div>
691
+ <div className="flex flex-wrap gap-1">
692
+ {fields.map(([name, type]) => (
693
+ <button key={name} type="button" className="chip border-line-strong text-muted hover:border-accent hover:text-accent" onClick={() => insertField(name)} title={type}>
694
+ {name}
695
+ <span className="text-faint">{type.replace("list<string>", "[]")}</span>
696
+ </button>
697
+ ))}
698
+ <span className="chip border-transparent text-faint">estate.services · contexts · stores · channels · externals</span>
699
+ </div>
700
+ </div>
701
+ <label className="block">
702
+ <span className="label mb-1.5 flex items-baseline justify-between">
703
+ <span>message · CEL returning string</span>
704
+ <span className="normal-case text-faint">the note on the row</span>
705
+ </span>
706
+ <textarea className={`${FIELD} min-h-12 resize-y`} value={draft.message ?? ""} onChange={(event) => set("message", event.target.value)} placeholder={`'nothing consumes ' + ${subject}.id`} spellCheck={false} />
707
+ </label>
708
+ <details className="group">
709
+ <summary className="label flex cursor-pointer list-none items-center gap-1.5">
710
+ <ChevronDown size={13} aria-hidden className="transition-transform group-open:rotate-180" /> more · peer, note, what to do, reason
711
+ </summary>
712
+ <div className="mt-3 grid gap-3 sm:grid-cols-2">
713
+ <label className="block">
714
+ <span className="label mb-1.5 block">peer · CEL returning string</span>
715
+ <input className={FIELD} value={draft.peer ?? ""} onChange={(event) => set("peer", event.target.value)} placeholder={`${subject}.service`} spellCheck={false} />
716
+ </label>
717
+ <label className="block">
718
+ <span className="label mb-1.5 block">note · on every row</span>
719
+ <input className={FIELD} value={draft.note ?? ""} onChange={(event) => set("note", event.target.value)} placeholder="an event with no consumer" />
720
+ </label>
721
+ <label className="block">
722
+ <span className="label mb-1.5 block">what to do</span>
723
+ <input className={FIELD} value={draft.action ?? ""} onChange={(event) => set("action", event.target.value)} placeholder="Subscribe a consumer or retire the event." />
724
+ </label>
725
+ <label className="block">
726
+ <span className="label mb-1.5 block">reason · why the estate holds this rule</span>
727
+ <input className={FIELD} value={draft.reason ?? ""} onChange={(event) => set("reason", event.target.value)} placeholder="every event should have a reader by the end of the quarter" />
728
+ </label>
729
+ </div>
730
+ </details>
731
+ </div>
732
+
733
+ <div className="lg:sticky lg:top-4 lg:self-start">
734
+ <div className={`rounded-control border px-3 py-3 ${preview?.failure ? "border-unresolved" : "border-line"} bg-canvas`}>
735
+ <div className="label mb-2">what the catalog answers</div>
736
+ {preview ? (
737
+ preview.failure ? (
738
+ <p className="mono text-unresolved">{preview.failure.message}</p>
739
+ ) : (
740
+ <>
741
+ <div className="flex items-baseline gap-2">
742
+ <span className="tnum text-lg leading-none text-ink">{preview.problems.length}</span>
743
+ <span className="mono text-muted">{plural(preview.problems.length, "row")} right now</span>
744
+ </div>
745
+ {preview.problems.length > 0 ? (
746
+ <ul className="mt-3 space-y-2 border-t border-line pt-3">
747
+ {preview.problems.slice(0, 6).map((problem) => (
748
+ <li key={problem.id} className="min-w-0">
749
+ <div className="mono truncate text-ink" title={problem.id}>
750
+ {problem.id}
751
+ {problem.peer ? <span className="text-muted"> → {problem.peer}</span> : null}
752
+ </div>
753
+ <div className="truncate text-muted" title={problem.note}>
754
+ {problem.note}
755
+ </div>
756
+ </li>
757
+ ))}
758
+ {preview.problems.length > 6 ? <li className="mono text-faint">+ {preview.problems.length - 6} more</li> : null}
759
+ </ul>
760
+ ) : (
761
+ <p className="mt-2 text-muted">Nothing in the catalog matches. That may be the point, or the condition may be too tight.</p>
762
+ )}
763
+ </>
764
+ )
765
+ ) : (
766
+ <p className="text-muted">Write a condition and a message, and the rows they would produce appear here before anything is saved.</p>
767
+ )}
768
+ </div>
769
+ <div className="mt-3 flex flex-wrap gap-2">
770
+ <button type="submit" className="product-primary" disabled={!ready || busy}>
771
+ {initial ? "Save rule" : "Add rule"}
772
+ </button>
773
+ <button type="button" className="tbtn py-1.5" onClick={onCancel} disabled={busy}>
774
+ Cancel
775
+ </button>
776
+ </div>
777
+ </div>
778
+ </div>
779
+ </form>
780
+ );
781
+ }
782
+
783
+ // ---------------------------------------------------------------------------
784
+ // What an expression may read.
785
+
786
+ function SubjectReference() {
787
+ return (
788
+ <details className="group mt-section">
789
+ <summary className="label flex cursor-pointer list-none items-center gap-1.5">
790
+ <ChevronDown size={13} aria-hidden className="transition-transform group-open:rotate-180" /> what a rule can read
791
+ </summary>
792
+ <div className="mt-3 grid gap-grid md:grid-cols-2">
793
+ {SUBJECT_NAMES.map((name) => (
794
+ <div key={name} className="rounded-control border border-line px-3 py-2.5">
795
+ <div className="flex items-center gap-2">
796
+ <KindIcon kind={ICON_OF[name]} />
797
+ <span className="mono text-ink">{name}</span>
798
+ <span className="truncate text-muted">{SUBJECTS[name].description}</span>
799
+ </div>
800
+ <div className="mono mt-2 flex flex-wrap gap-x-3 gap-y-1 text-muted">
801
+ {Object.entries(SUBJECTS[name].schema).map(([field, type]) => (
802
+ <span key={field} className="whitespace-nowrap">
803
+ {field} <span className="text-faint">{type}</span>
804
+ </span>
805
+ ))}
806
+ </div>
807
+ </div>
808
+ ))}
809
+ <div className="rounded-control border border-line px-3 py-2.5 md:col-span-2">
810
+ <div className="flex items-center gap-2">
811
+ <span className="mono text-ink">estate</span>
812
+ <span className="text-muted">The names the estate answers to, beside every subject, for `in`.</span>
813
+ </div>
814
+ <div className="mono mt-2 flex flex-wrap gap-x-3 text-muted">
815
+ {["services", "contexts", "stores", "channels", "externals"].map((field) => (
816
+ <span key={field} className="whitespace-nowrap">
817
+ {field} <span className="text-faint">list&lt;string&gt;</span>
818
+ </span>
819
+ ))}
820
+ </div>
821
+ </div>
822
+ </div>
823
+ </details>
824
+ );
825
+ }