web: add the notification render limits to the notify settings page

PR #172 made the summary length and the number of assets listed in an IM
message configurable, but only through the API. The Notifications page now
shows both next to the other global settings:

  Summary length (characters)   notify_summary_max_runes   0-4000
  Assets listed per finding     notify_assets_max_shown    0-50

The values are read from GET /api/settings (they are not part of
/notify/meta) and saved with the existing "Save global settings" PUT.
Each field carries a help text stating that 0 means no truncation.

A value that is not a whole number within the bounds marks the input
invalid and blocks the save with a toast. The check runs before the
request because the backend applies the fields of one PUT in order, so a
400 on a limit would leave the URL and digest interval already saved. An
empty field is not submitted and keeps the stored value.
This commit is contained in:
BaaRai
2026-11-10 16:24:07 +01:00
parent 3b6eb2fd2c
commit 0cc22cb794
2 changed files with 71 additions and 0 deletions
+68
View File
@@ -32,6 +32,18 @@ import { ConfigField, FilterSummary } from "./_components/channel-form";
import { DeliveryList } from "./_components/delivery-list";
import { formatBacklog, StatTile } from "./_components/stat-tile";
// Upper bounds of the two message render limits. They mirror the backend, which answers 400 outside [0, max].
const SUMMARY_LIMIT_MAX = 4000;
const ASSETS_LIMIT_MAX = 50;
// limitInvalid reports whether a limit input holds anything other than a whole number in [0, max].
// Empty is valid: it is not submitted, so the stored value stays as it is.
function limitInvalid(raw: string, max: number): boolean {
if (raw.trim() === "") return false;
const n = Number(raw);
return !Number.isInteger(n) || n < 0 || n > max;
}
// This page only orchestrates: loading data, holding form state, calling the API.
// Field definitions and parsing live in _components/channel-fields.ts, controls and the filter summary in
// _components/channel-form.tsx, and the delivery log in _components/delivery-list.tsx —
@@ -50,6 +62,8 @@ export default function NotifyPage() {
const [globalSaving, setGlobalSaving] = React.useState(false);
const [baseURL, setBaseURL] = React.useState("");
const [digestMin, setDigestMin] = React.useState("");
const [summaryMax, setSummaryMax] = React.useState("");
const [assetsMax, setAssetsMax] = React.useState("");
const load = React.useCallback(() => {
api
@@ -60,6 +74,14 @@ export default function NotifyPage() {
setDigestMin(m.digest_interval_min);
})
.catch((e) => toast.error("Failed to load notification settings: " + (e as Error).message));
// The two render limits are not part of /notify/meta; they are only exposed through /api/settings.
api
.settings()
.then((s) => {
setSummaryMax(String(s.notify_summary_max_runes ?? ""));
setAssetsMax(String(s.notify_assets_max_shown ?? ""));
})
.catch((e) => toast.error(`Failed to load message limits: ${(e as Error).message}`));
// Channel-list load failures must be reported: failing silently would render as "no channels at all",
// and users would think their config was lost — scarier than a straight-up error.
api
@@ -244,11 +266,23 @@ export default function NotifyPage() {
}
async function saveGlobal() {
// Checked before anything is sent: the backend applies the fields of one PUT in order,
// so a 400 on a limit would leave the URL and digest interval already saved.
if (limitInvalid(summaryMax, SUMMARY_LIMIT_MAX)) {
toast.error(`Summary length must be a whole number between 0 and ${SUMMARY_LIMIT_MAX}`);
return;
}
if (limitInvalid(assetsMax, ASSETS_LIMIT_MAX)) {
toast.error(`Assets listed must be a whole number between 0 and ${ASSETS_LIMIT_MAX}`);
return;
}
setGlobalSaving(true);
try {
const patch: Record<string, unknown> = { notify_public_base_url: baseURL.trim() };
const n = Number(digestMin);
if (Number.isFinite(n) && n > 0) patch.notify_digest_interval_min = n;
if (summaryMax.trim() !== "") patch.notify_summary_max_runes = Number(summaryMax);
if (assetsMax.trim() !== "") patch.notify_assets_max_shown = Number(assetsMax);
await api.setSettings(patch);
toast.success("Saved");
load();
@@ -331,6 +365,40 @@ export default function NotifyPage() {
/>
<p className="text-muted-foreground text-xs">Only applies to channels in "digest" mode.</p>
</div>
<div className="grid gap-2">
<Label htmlFor="n-summary-max">Summary length (characters)</Label>
<Input
id="n-summary-max"
type="number"
min={0}
max={SUMMARY_LIMIT_MAX}
step={1}
placeholder="120"
value={summaryMax}
onChange={(e) => setSummaryMax(e.target.value)}
aria-invalid={limitInvalid(summaryMax, SUMMARY_LIMIT_MAX)}
/>
<p className="text-muted-foreground text-xs">
Longest finding summary shown in a message, 0–{SUMMARY_LIMIT_MAX}. 0 = no truncation.
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="n-assets-max">Assets listed per finding</Label>
<Input
id="n-assets-max"
type="number"
min={0}
max={ASSETS_LIMIT_MAX}
step={1}
placeholder="3"
value={assetsMax}
onChange={(e) => setAssetsMax(e.target.value)}
aria-invalid={limitInvalid(assetsMax, ASSETS_LIMIT_MAX)}
/>
<p className="text-muted-foreground text-xs">
Most asset names listed for a finding, 0–{ASSETS_LIMIT_MAX}. 0 = no truncation (all assets listed).
</p>
</div>
<div className="sm:col-span-2">
<Button onClick={saveGlobal} disabled={globalSaving}>
Save global settings
+3
View File
@@ -957,6 +957,9 @@ export interface Settings {
notify_enabled?: boolean; // master push switch, on by default; one-click kill switch for maintenance windows
notify_public_base_url?: string; // external base URL for vulnerability detail links; empty = messages carry no link
notify_digest_interval_min?: number; // digest mode period (minutes), default 30
// Render limits of IM messages; 0 = no truncation. The backend answers 400 outside the bounds.
notify_summary_max_runes?: number; // summary characters per finding, 0-4000, default 120
notify_assets_max_shown?: number; // asset names listed per finding, 0-50, default 3
}
// ---- Vulnerability IM push ----