@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.
- package/README.md +174 -6
- package/catalog/enum_test.go +46 -0
- package/catalog/evidence_test.go +35 -0
- package/catalog/model.go +1213 -0
- package/catalog/roundtrip_test.go +210 -0
- package/catalog/via_test.go +38 -0
- package/cli/init.test.mjs +6 -1
- package/cli/portolan.mjs +14 -1
- package/cli/portolan.test.mjs +49 -0
- package/go.mod +21 -0
- package/go.sum +34 -0
- package/internal/gocall/README.md +19 -0
- package/internal/gocall/analyze.go +189 -0
- package/internal/gocall/analyze_test.go +107 -0
- package/internal/gohttp/analyze.go +2562 -0
- package/internal/gohttp/destination.go +373 -0
- package/internal/gohttp/endpoints.go +1067 -0
- package/internal/gohttp/roots.go +320 -0
- package/internal/gohttp/typed.go +96 -0
- package/internal/goscan/constants.go +85 -0
- package/internal/goscan/goscan_test.go +227 -0
- package/internal/goscan/index.go +629 -0
- package/internal/goscan/index_test.go +66 -0
- package/internal/goscan/names.go +52 -0
- package/internal/goscan/parse_test.go +11 -0
- package/internal/goscan/source.go +37 -0
- package/internal/goscan/tree.go +284 -0
- package/internal/goscan/types.go +99 -0
- package/internal/wsdl/ids.go +127 -0
- package/internal/wsdl/ids_test.go +21 -0
- package/internal/wsdl/model.go +70 -0
- package/internal/wsdl/parse.go +949 -0
- package/internal/wsdl/parse_test.go +170 -0
- package/package.json +28 -15
- package/plugin/describe.go +156 -0
- package/plugin/describe_test.go +114 -0
- package/plugin/protocol.go +141 -0
- package/plugin/schematest/schematest.go +126 -0
- package/plugins/README.md +404 -50
- package/plugins/cmd/portolan-http-clients/main.go +19 -0
- package/plugins/extract-argocd/options.schema.json +44 -0
- package/plugins/extract-celery/extract.py +0 -2
- package/plugins/extract-celery/extract_test.py +1 -1
- 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 +52 -19
- package/plugins/extract-django/choices.py +47 -0
- package/plugins/extract-django/domain.py +30 -18
- package/plugins/extract-django/extract.py +21 -7
- package/plugins/extract-django/extract_test.py +68 -2
- package/plugins/extract-django/lifecycle.py +4 -28
- package/plugins/extract-django/main.py +1 -0
- package/plugins/extract-django/operations.py +1 -1
- package/plugins/extract-django/routing_test.py +109 -1
- package/plugins/extract-django/rules.py +154 -0
- package/plugins/extract-django/rules_test.py +158 -0
- package/plugins/extract-django/store.py +1 -1
- package/plugins/extract-django/transport.py +101 -55
- package/plugins/extract-django/verbs.py +241 -0
- package/plugins/extract-go/README.md +47 -0
- package/plugins/extract-go-sqs/options.schema.json +21 -0
- package/plugins/extract-http-clients/describe.go +20 -0
- package/plugins/extract-http-clients/describe_test.go +11 -0
- package/plugins/extract-http-clients/extract.go +740 -0
- package/plugins/extract-http-clients/extract_test.go +1561 -0
- package/plugins/extract-http-clients/main.go +41 -0
- package/plugins/extract-java/build/org/portolan/extract/Extract.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Main.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Builder.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Input.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Options.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/extract.py +0 -2
- package/plugins/extract-python-kafka/extract_test.py +1 -1
- package/plugins/extract-python-kafka/main.py +1 -0
- package/plugins/extract-terraform/options.schema.json +26 -0
- package/plugins/extract-ts/extract.test.ts +2 -2
- package/plugins/extract-ts/extract.ts +4 -5
- package/plugins/extract-ts/graphql.test.ts +1 -1
- package/plugins/extract-ts/main.ts +1 -0
- package/plugins/openapi/ids.go +261 -0
- package/plugins/openapi/ids_test.go +98 -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/pyplugin/protocol.py +1 -5
- package/plugins/verify-otel/options.schema.json +12 -0
- package/portolan.json +132 -4
- package/rules/builtin.json +218 -0
- package/schema/portolan.schema.json +905 -4
- package/scripts/README.md +21 -13
- package/scripts/catalog-sources.mjs +6 -0
- package/scripts/delivery-presets.mjs +57 -14
- package/scripts/diff.mjs +5 -1
- package/scripts/django-aggregates.test.mjs +58 -0
- package/scripts/gen-likec4.mjs +150 -17
- package/scripts/gen-likec4.test.mjs +96 -0
- package/scripts/gen.mjs +148 -118
- package/scripts/gitops-example.test.mjs +108 -0
- package/scripts/go-discovery.test.mjs +30 -0
- package/scripts/history.mjs +186 -3
- package/scripts/history.test.mjs +1 -1
- 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 +78 -21
- package/scripts/host-plugins/fetch-git.test.mjs +62 -8
- 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 +287 -12
- package/scripts/local-api.test.mjs +150 -5
- package/scripts/local-discovery.mjs +94 -9
- package/scripts/manifest.mjs +14 -3
- package/scripts/manifest.test.mjs +24 -0
- package/scripts/output-diff.mjs +94 -0
- package/scripts/output-diff.test.mjs +36 -0
- package/scripts/package-smoke.mjs +62 -4
- package/scripts/plugin-host.mjs +45 -3
- package/scripts/plugin-host.test.mjs +9 -0
- package/scripts/plugin-wasm-worker.mjs +4 -1
- package/scripts/plugins-fresh.mjs +97 -0
- package/scripts/plugins-fresh.test.mjs +64 -0
- package/scripts/provenance.mjs +72 -0
- package/scripts/provenance.test.mjs +149 -0
- package/scripts/run-builtin.mjs +62 -7
- package/scripts/schema.mjs +167 -19
- package/scripts/trace-trials.mjs +176 -0
- package/scripts/trace-trials.test.mjs +142 -0
- package/scripts/warning-policy.mjs +167 -0
- package/scripts/warning-policy.test.mjs +93 -0
- 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/Sidebar.tsx +3 -3
- package/src/app/SidebarFooter.tsx +20 -4
- package/src/catalog-docs.test.ts +64 -0
- package/src/catalog-docs.ts +35 -0
- package/src/catalog-error.test.ts +15 -0
- package/src/catalog-index.ts +25 -0
- package/src/catalog-model.ts +293 -5
- package/src/catalog-validation.ts +113 -2
- package/src/catalog.test.ts +40 -1
- package/src/chat/Starter.tsx +5 -11
- package/src/chat/tools.test.ts +27 -0
- package/src/chat/tools.ts +5 -9
- package/src/components/CatalogStamp.tsx +10 -8
- 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.test.tsx +23 -0
- package/src/components/HTTPDestinationEvidence.tsx +31 -0
- package/src/components/Integrations.tsx +1 -1
- package/src/components/MachineDocs.tsx +6 -5
- package/src/components/MethodRows.tsx +9 -2
- package/src/components/PluginIcon.tsx +77 -0
- package/src/components/ProblemRow.tsx +173 -153
- package/src/components/RelationEvidence.test.tsx +14 -0
- package/src/components/RelationEvidence.tsx +53 -0
- package/src/components/RuleMarks.tsx +22 -0
- package/src/components/ShapeRows.tsx +24 -20
- package/src/data.ts +46 -7
- package/src/enrich.test.ts +459 -4
- package/src/enrich.ts +308 -7
- 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 +59 -0
- package/src/flow/TraceTrial.tsx +419 -0
- package/src/flow/evidence.test.ts +16 -0
- package/src/flow/evidence.ts +34 -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 +68 -0
- package/src/landing/DraggableReveal.tsx +3 -2
- package/src/landing/EvidencePipeline.tsx +105 -0
- package/src/landing/LandingPage.tsx +17 -70
- package/src/landing/ProductTour.tsx +6 -6
- package/src/lib/all-problems.ts +27 -17
- package/src/lib/catalog-diff.ts +1 -1
- 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/django-aggregates.d.mts +9 -0
- package/src/lib/django-aggregates.mjs +36 -0
- package/src/lib/django-aggregates.test.ts +29 -0
- package/src/lib/django-aggregates.ts +5 -0
- 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 +116 -4
- 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/setup-info.test.ts +17 -0
- package/src/lib/setup-info.ts +58 -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 +63 -0
- package/src/lib/warnings.ts +260 -0
- package/src/likec4/ids.test.ts +6 -2
- package/src/likec4/ids.ts +43 -0
- package/src/main.tsx +23 -0
- package/src/map/ContextMapGraph.tsx +76 -32
- package/src/merge-deployments.test.ts +127 -0
- package/src/merge.test.ts +82 -0
- package/src/merge.ts +189 -18
- package/src/pages/AggregatePage.tsx +65 -14
- package/src/pages/ContextMap.tsx +45 -3
- package/src/pages/ContextPage.tsx +8 -5
- 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 +62 -5
- package/src/pages/Settings.tsx +217 -43
- package/src/pages/settings/AboutSettings.tsx +8 -1
- package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
- 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/selection/DetailPanel.tsx +15 -0
- package/src/virtual-provenance.d.ts +11 -0
- package/vite.config.ts +5 -0
- package/scripts/vendor-lock.mjs +0 -58
- package/scripts/vendor-lock.test.mjs +0 -69
- package/src/lib/data-problems.ts +0 -314
- package/src/lib/proto-problems.ts +0 -237
- 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<string></span>
|
|
818
|
+
</span>
|
|
819
|
+
))}
|
|
820
|
+
</div>
|
|
821
|
+
</div>
|
|
822
|
+
</div>
|
|
823
|
+
</details>
|
|
824
|
+
);
|
|
825
|
+
}
|