Compare commits

..
Author SHA1 Message Date
BaaRai 0cc22cb794 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.
2026-11-10 16:24:07 +01:00
9 changed files with 71 additions and 19 deletions
-1
View File
@@ -9,7 +9,6 @@ import (
// The cost ledger: profile prices (EUR per 1M tokens) turn token buckets into
// EUR on the usage aggregation; unpriced profiles stay nil.
func TestCostLedgerEurOnUsage(t *testing.T) {
skipWithoutDSN(t)
m, err := NewManager(t.TempDir(), "")
if err != nil {
t.Fatal(err)
-1
View File
@@ -22,7 +22,6 @@ import (
func trafficEvidenceServer(t *testing.T) (*Server, *db.RecordedFinding, func(string, string, string) *httptest.ResponseRecorder) {
t.Helper()
skipWithoutDSN(t)
m, err := NewManager(t.TempDir(), "")
if err != nil {
t.Fatal(err)
-2
View File
@@ -103,7 +103,6 @@ func TestOllamaProviderWiresOpenAIProtocol(t *testing.T) {
// loadProfileConfig accepts it, the task chain validates it, the failover pool
// includes it, and the connection test completes against a fake Ollama.
func TestOllamaKeylessProfileLifecycle(t *testing.T) {
skipWithoutDSN(t)
m, err := NewManager(t.TempDir(), "")
if err != nil {
t.Fatal(err)
@@ -181,7 +180,6 @@ func TestOllamaKeylessProfileLifecycle(t *testing.T) {
// Model listing for an Ollama server works without an API key and tolerates a
// base URL with or without the /v1 suffix.
func TestOllamaListModels(t *testing.T) {
skipWithoutDSN(t)
var calls atomic.Value // []string paths
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
-2
View File
@@ -18,7 +18,6 @@ import (
// assigned raw, so a key saved with surrounding whitespace ran fine in
// conversations but 401'd the connection test.
func TestLLMTestStoredKeyFallbackIsTrimmed(t *testing.T) {
skipWithoutDSN(t)
m, err := NewManager(t.TempDir(), "")
if err != nil {
t.Fatal(err)
@@ -98,7 +97,6 @@ func TestLLMTestFallbackMatchesRuntimeNormalization(t *testing.T) {
// (the list-view test button) fills provider/model/base_url from the row, so
// the key lands on the configured host instead of the canonical default one.
func TestLLMTestStoredProfileUsesProfileEndpoint(t *testing.T) {
skipWithoutDSN(t)
m, err := NewManager(t.TempDir(), "")
if err != nil {
t.Fatal(err)
-2
View File
@@ -10,7 +10,6 @@ import (
// open-goal fingerprint is unchanged since the last completed round AND active
// work exists (open or running intents). Every other combination plans.
func TestPlannerQuietSkipEdge(t *testing.T) {
skipWithoutDSN(t)
m, err := NewManager(t.TempDir(), "")
if err != nil {
t.Fatal(err)
@@ -79,7 +78,6 @@ func TestPlannerQuietSkipEdge(t *testing.T) {
// The quiet-mode switch is editable mid-run (#86): PATCHing plan_quiet_mode
// updates both the stored row and the live Task the engine reads per round.
func TestPlannerQuietModeToggleMidRun(t *testing.T) {
skipWithoutDSN(t)
m, err := NewManager(t.TempDir(), "")
if err != nil {
t.Fatal(err)
-10
View File
@@ -32,13 +32,3 @@ func TestMain(m *testing.M) {
defer conn.Exec(`SELECT pg_advisory_unlock(7337741002)`) //nolint:errcheck
os.Exit(m.Run())
}
// skipWithoutDSN skips the test when neither ARTEX_PG_DSN nor a config file
// supplies a database, so fixtures that t.Fatal on a NewManager error skip
// instead of failing. A configured database that cannot be reached still fails.
func skipWithoutDSN(t *testing.T) {
t.Helper()
if _, _, err := db.DSN(); err != nil {
t.Skipf("no database config (%v) — skipping", err)
}
}
-1
View File
@@ -15,7 +15,6 @@ import (
// trafficEvidenceServer but without the finding plumbing.
func ingestServer(t *testing.T) (*Server, func(method, path, body string) *httptest.ResponseRecorder) {
t.Helper()
skipWithoutDSN(t)
m, err := NewManager(t.TempDir(), "")
if err != nil {
t.Fatal(err)
+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 ----