fix(web): make rule JSON paste-import lossless in both rule editors

The JSON editors serialize the FORM object, but what users paste is usually a
rule EXPORT (e.g. received from another tenant). The create/edit whitelists
rebuilt collector parameters and evaluateOn from form fields the export does
not carry, so a pasted gather rule silently lost every collector parameter and
a pasted analyze rule silently reverted to the enrollment-end default.

Both rules pages get a single shape-tolerant mapper in types.ts:

- gatherRuleToForm unflattens the nested parameters object (authoritative when
  present, so edit-merge leftovers cannot leak), maps nullable rule fields to
  form defaults, imports a missing emitMode as 'always' (the rule's actual
  behavior) instead of the empty-form on_change default, and now also backs
  startEditing so edit and paste share one mapping.
- analyze jsonToForm maps evaluateOn onto the eval* checkboxes (an explicit
  evaluateOn wins over stale form flags in edit-mode merges); terminal-only
  exports fall back to the enrollment-end default.
- tags and the folded effective markSessionAsFailed now survive the import:
  both forms carry tags into the create/save payloads, analyze creates carry
  markSessionAsFailedDefault. notify/notifyChannelIds stay excluded — channel
  ids are tenant-specific.

9 new vitest cases pin both shapes, the merge precedence, and the round-trips;
full web suite 824/824, tsc and eslint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-08-21 14:29:08 +02:00
parent 81c14bb3ed
commit 2d5ee887d7
10 changed files with 340 additions and 60 deletions
+2
View File
@@ -2,6 +2,8 @@
## 2026-08-21
* **Update**: `rules/rule-id-namespace.md` — the export→paste round-trip is no longer lossy: the JSON editors now map rule-shaped exports into the form (`gatherRuleToForm` unflattens the nested gather `parameters` object, analyze `jsonToForm` maps `evaluateOn` onto the eval checkboxes; `tags` and the effective `markSessionAsFailed` carry into the created rule). Both editors still accept the serialized-form shape; edit-mode pastes merge, with an explicit `parameters`/`evaluateOn` winning over stale flat fields.
* **New**: `rules/rule-id-namespace.md` — rule identity model: human-readable `ruleId` strings (no GUIDs), tenant isolation via Table Storage partitioning (`global` = built-in/community, tenantId = custom), the reserved built-in namespace from `RuleIdPolicy`, and the four collision guards: catalog-only global `RuleStats` aggregate (same-ID custom rules of unrelated tenants no longer sum into one `global_{ruleId}` row), combine.js duplicate/namespace fail, GitHub-reseed rejection of non-reserved IDs into the global partition, and the namespace gate on the cross-tenant RuleState GC.
* **New**: `backend/app-install-duration-attempt.md` — attempt-scoped app install durations: `DurationSeconds` = status-defining (last) attempt anchored on the new max-folded `LastAttemptStartedAt` (StartedAt stays the earliest-wins window/version-ordering key; legacy rows without the anchor keep the span fallback); `InstallPassCount` with replay-guarded additive fold; weaker-terminal guard (completed-with-Skipped never overrides Installed/Error, in-batch and cross-batch); attempt-scoped `DownloadDurationSeconds` pairing; the version-regression radar's both-sides cutover gate; time attribution counts the union of per-pass active segments (occupancy merges segments, hulls stay for display/what-if); agent payload timing re-arms both endpoints on terminal→active. No backfill — pre-cutover rows keep span semantics.
+1 -1
View File
@@ -27,7 +27,7 @@ Namespace split (`RuleIdPolicy.cs`, shared model):
- **Custom vs built-in**: impossible via API — `CreateRuleAsync`/`UpdateRuleAsync` reject reserved IDs (409) and check existence against `global` ∪ own tenant partition. Legacy debris is neutralized at merge time: global wins, tenant copy dropped with a warning (pinned by `RuleMergeCollisionGuardTests`, motivated by the 5ca2b350 duplicate-key incident).
- **Custom vs custom across tenants**: allowed and harmless — separate partitions, separate evaluation, separate RuleResults/tenant stats. Two tenants both using `ANALYZE-CUSTOM-101` never interact.
- **JSON copy between tenants** (export → paste into create editor): goes through the same create path, so an ID already taken in the receiving tenant surfaces as 409 — the user renames. There is no bulk import endpoint; note the export→paste round-trip is lossy (gather `parameters`, analyze `evaluateOn` are form-derived and silently dropped by the create editor).
- **JSON copy between tenants** (export → paste into create editor): goes through the same create path, so an ID already taken in the receiving tenant surfaces as 409 — the user renames. There is no bulk import endpoint. The JSON editors accept both shapes — a rule export and the serialized form — via `gatherRuleToForm` / `jsonToForm` (web `types.ts` of each rules page): nested gather `parameters` are unflattened, analyze `evaluateOn` maps onto the eval checkboxes, and `tags` plus the effective `markSessionAsFailed` survive the import.
# Guards (all enforced, 2026-08-21)
@@ -0,0 +1,97 @@
import { describe, it, expect } from "vitest";
import { EMPTY_FORM, formToEvaluateOn, jsonToForm } from "../types";
/**
* The JSON editor serializes the FORM object (eval* checkbox fields), but what users
* paste is usually a rule EXPORT (the "Export" button's shape, e.g. received from
* another tenant) which carries `evaluateOn` instead. Before the mapper, a pasted
* export silently lost evaluateOn — the imported rule quietly reverted to the
* enrollment-end default.
*/
describe("jsonToForm", () => {
const baseExport = {
ruleId: "ANALYZE-CUSTOM-101",
title: "VPN client install failed",
severity: "high",
category: "network",
conditions: [
{
signal: "vpn_failed",
source: "event_data",
eventType: "app_install_failed",
dataField: "appName",
operator: "contains",
value: "VPN",
required: true,
},
],
baseConfidence: 70,
confidenceThreshold: 50,
tags: ["vpn", "network"],
};
it("maps a rule-shaped export's evaluateOn onto the form flags", () => {
const form = jsonToForm({
...baseExport,
evaluateOn: ["whiteglove_sealed", "on_event:vpn_error"],
});
expect(form.evalAtEnrollmentEnd).toBe(false);
expect(form.evalAtWhitegloveSealed).toBe(true);
expect(form.evalOnEventTypes).toBe("vpn_error");
expect(form.conditions).toHaveLength(1);
expect(form.tags).toEqual(["vpn", "network"]);
});
it("defaults a terminal-only export (no evaluateOn key) to enrollment end", () => {
const form = jsonToForm(baseExport);
expect(form.evalAtEnrollmentEnd).toBe(true);
expect(form.evalAtWhitegloveSealed).toBe(false);
expect(form.evalOnEventTypes).toBe("");
});
it("passes form-shaped JSON (eval* fields, no evaluateOn) through unchanged", () => {
const form = jsonToForm({
...EMPTY_FORM,
ruleId: "ANALYZE-CUSTOM-102",
title: "Pasted form",
evalAtEnrollmentEnd: false,
evalAtWhitegloveSealed: true,
});
expect(form.evalAtEnrollmentEnd).toBe(false);
expect(form.evalAtWhitegloveSealed).toBe(true);
});
it("prefers an explicit evaluateOn over stale form flags on an edit merge", () => {
// Edit-mode paste merges into the current form, so both shapes are present:
// the export's evaluateOn must win over the edited rule's stale checkboxes.
const form = jsonToForm({
...EMPTY_FORM,
...baseExport,
evalAtEnrollmentEnd: true,
evaluateOn: ["whiteglove_sealed"],
});
expect(form.evalAtEnrollmentEnd).toBe(false);
expect(form.evalAtWhitegloveSealed).toBe(true);
});
it("folds the tenant markSessionAsFailed override into the imported default", () => {
// An exported rule that (effectively) marks sessions failed must keep doing so
// when re-created in another tenant from its JSON.
expect(jsonToForm({ ...baseExport, markSessionAsFailed: true }).markSessionAsFailedDefault).toBe(true);
expect(jsonToForm({ ...baseExport, markSessionAsFailedDefault: true }).markSessionAsFailedDefault).toBe(true);
expect(jsonToForm({ ...baseExport, markSessionAsFailedDefault: true, markSessionAsFailed: false }).markSessionAsFailedDefault).toBe(false);
expect(jsonToForm(baseExport).markSessionAsFailedDefault).toBe(false);
});
it("round-trips evaluateOn through the form and back", () => {
const evaluateOn = ["enrollment_end", "whiteglove_sealed", "on_event:vpn_error"];
expect(formToEvaluateOn(jsonToForm({ ...baseExport, evaluateOn }))).toEqual(evaluateOn);
// Terminal-only stays clean of the field (undefined = backend default).
expect(formToEvaluateOn(jsonToForm(baseExport))).toBeUndefined();
});
});
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { AnalyzeRule, RuleForm, getSeverityColor, getCategoryColor, interimTriggerLabels } from "../types";
import { AnalyzeRule, RuleForm, PastedAnalyzeJson, getSeverityColor, getCategoryColor, interimTriggerLabels, jsonToForm } from "../types";
import { FormJsonToggle, JsonModeToggleButtons, ReadOnlyJsonView } from "@/components/rules/FormJsonToggle";
import AnalyzeRuleFormFields from "./AnalyzeRuleFormFields";
import { stripInternalFields } from "@/lib/rulePageHelpers";
@@ -635,9 +635,11 @@ export default function AnalyzeRuleCard({
jsonError={jsonError}
onApplyJson={() => {
try {
const parsed = JSON.parse(jsonText) as RuleForm;
const parsed = JSON.parse(jsonText) as PastedAnalyzeJson;
if (!parsed.title) throw new Error("JSON must include title");
setEditForm({ ...editForm, ...parsed });
// Merge keeps unmentioned fields; a rule-shaped paste brings an explicit
// evaluateOn, which the mapper prefers over the stale eval* form fields.
setEditForm(jsonToForm({ ...editForm, ...parsed }));
onSetJsonModeEdit(false);
onSetJsonError(null);
} catch (e) {
@@ -655,9 +657,9 @@ export default function AnalyzeRuleCard({
<button onClick={() => {
if (jsonModeEdit) {
try {
const parsed = JSON.parse(jsonText) as RuleForm;
const parsed = JSON.parse(jsonText) as PastedAnalyzeJson;
onSetJsonError(null);
onSaveEdit(rule, { ...editForm, ...parsed });
onSaveEdit(rule, jsonToForm({ ...editForm, ...parsed }));
} catch (e) {
onSetJsonError(e instanceof Error ? e.message : "Invalid JSON");
}
@@ -16,7 +16,7 @@ import { GlobalAdminBanner, globalAdminSubtitle } from "@/components/GlobalAdmin
import { TenantScopeSelector } from "@/components/TenantScopeSelector";
import { isTemplateRule } from "@/lib/analyzeRuleTabs";
import { AnalyzeRule, RuleForm, EMPTY_FORM, EMPTY_CONDITION, ruleToForm, formToEvaluateOn, validateOnEventTypes } from "./types";
import { AnalyzeRule, RuleForm, PastedAnalyzeJson, EMPTY_FORM, EMPTY_CONDITION, ruleToForm, jsonToForm, formToEvaluateOn, validateOnEventTypes } from "./types";
import { BLOCKED_INTERIM_TRIGGER_EVENT_TYPES } from "@/utils/guardrails.generated";
/** Pre-flight for the evaluation-trigger form fields; returns an error message or null. */
@@ -340,6 +340,8 @@ export default function AnalyzeRulesPage() {
setCreating(true);
const payload = {
evaluateOn: formToEvaluateOn(form),
tags: form.tags ?? [],
markSessionAsFailedDefault: form.markSessionAsFailedDefault ?? false,
ruleId: form.ruleId.trim(),
title: form.title.trim(),
description: form.description.trim(),
@@ -398,6 +400,8 @@ export default function AnalyzeRulesPage() {
const payload = {
...rule,
evaluateOn: formToEvaluateOn(form) ?? null,
// From the form (not ...rule) so tag changes pasted in JSON mode survive the save.
tags: form.tags ?? rule.tags ?? [],
title: form.title.trim(),
description: form.description.trim(),
severity: form.severity,
@@ -797,9 +801,11 @@ export default function AnalyzeRulesPage() {
jsonError={jsonError}
onApplyJson={() => {
try {
const parsed = JSON.parse(jsonText) as RuleForm;
const parsed = JSON.parse(jsonText) as PastedAnalyzeJson;
if (!parsed.ruleId && !parsed.title) throw new Error("JSON must include at least ruleId and title");
setNewRule({ ...EMPTY_FORM, ...parsed });
// Handles both shapes: rule-shaped exports (evaluateOn) and the
// serialized form itself (eval* checkbox fields).
setNewRule(jsonToForm(parsed));
setJsonModeCreate(false);
setJsonError(null);
} catch (e) {
@@ -816,9 +822,9 @@ export default function AnalyzeRulesPage() {
<button onClick={() => {
if (jsonModeCreate) {
try {
const parsed = JSON.parse(jsonText) as RuleForm;
const parsed = JSON.parse(jsonText) as PastedAnalyzeJson;
setJsonError(null);
handleCreateRule({ ...EMPTY_FORM, ...parsed });
handleCreateRule(jsonToForm(parsed));
} catch (e) {
setJsonError(e instanceof Error ? e.message : "Invalid JSON");
}
@@ -123,6 +123,10 @@ export interface RuleForm {
evalAtWhitegloveSealed: boolean;
/** Comma-separated event types for on_event:<type> interim triggers. */
evalOnEventTypes: string;
/** Carried through create/save so exports and JSON pastes don't lose them (no form UI). */
tags: string[];
/** Effective "fire → mark session failed" carried as the new rule's default on import. */
markSessionAsFailedDefault: boolean;
}
export const CATEGORIES = ["network", "identity", "apps", "device", "esp", "enrollment"] as const;
@@ -210,6 +214,8 @@ export const EMPTY_FORM: RuleForm = {
evalAtEnrollmentEnd: true,
evalAtWhitegloveSealed: false,
evalOnEventTypes: "",
tags: [],
markSessionAsFailedDefault: false,
};
export function ruleToForm(rule: AnalyzeRule): RuleForm {
@@ -235,9 +241,52 @@ export function ruleToForm(rule: AnalyzeRule): RuleForm {
.filter(t => t.toLowerCase().startsWith("on_event:"))
.map(t => t.slice("on_event:".length))
.join(", "),
tags: [...(rule.tags ?? [])],
// Fold the tenant override into the effective value: an exported rule that marks
// sessions failed must keep doing so when re-created from its JSON.
markSessionAsFailedDefault: rule.markSessionAsFailed ?? rule.markSessionAsFailedDefault ?? false,
};
}
/**
* What JSON-mode paste can contain: the serialized form, a rule export, or a mix.
* Shared fields take the rule's (nullable) typing so an AnalyzeRule assigns directly;
* the form-only eval* fields come from RuleForm.
*/
export type PastedAnalyzeJson = Partial<AnalyzeRule> & Partial<Omit<RuleForm, keyof AnalyzeRule>>;
/**
* Maps pasted JSON into a RuleForm, accepting BOTH shapes: the serialized form (has the
* eval* checkbox fields) and a rule-shaped export (has evaluateOn, or neither when the
* rule is terminal-only). Without the rule-shaped path, pasting an export silently
* dropped evaluateOn — the rule quietly reverted to the enrollment-end default.
*/
export function jsonToForm(parsed: PastedAnalyzeJson): RuleForm {
// An explicit evaluateOn is authoritative rule-shape; the form fields only decide when
// it is absent (a terminal-only export carries neither, which the default handles).
const formShaped =
parsed.evaluateOn === undefined &&
(parsed.evalAtEnrollmentEnd !== undefined ||
parsed.evalAtWhitegloveSealed !== undefined ||
parsed.evalOnEventTypes !== undefined);
if (formShaped) return { ...EMPTY_FORM, ...(parsed as Partial<RuleForm>) };
return ruleToForm({
...parsed,
ruleId: parsed.ruleId ?? "",
title: parsed.title ?? "",
severity: parsed.severity ?? EMPTY_FORM.severity,
category: parsed.category ?? EMPTY_FORM.category,
trigger: parsed.trigger ?? EMPTY_FORM.trigger,
baseConfidence: parsed.baseConfidence ?? EMPTY_FORM.baseConfidence,
confidenceThreshold: parsed.confidenceThreshold ?? EMPTY_FORM.confidenceThreshold,
conditions: Array.isArray(parsed.conditions) ? parsed.conditions : [],
confidenceFactors: Array.isArray(parsed.confidenceFactors) ? parsed.confidenceFactors : [],
remediation: Array.isArray(parsed.remediation) ? parsed.remediation : [],
relatedDocs: Array.isArray(parsed.relatedDocs) ? parsed.relatedDocs : [],
} as AnalyzeRule);
}
/**
* Builds the evaluateOn payload from the structured form fields. Returns undefined when the
* selection equals the default (enrollment_end only) so unchanged rules stay clean of the field.
@@ -4,6 +4,7 @@ import {
NewRuleForm,
buildScopeFields,
firesExactlyOnce,
gatherRuleToForm,
supportsEmitMode,
supportsPhaseScope,
validateScopeSelection,
@@ -134,3 +135,98 @@ describe("withDerivedScopeMode", () => {
expect(withDerivedScopeMode(parsed).activePhases).toEqual([]);
});
});
/**
* The JSON editor serializes the FORM object, but what users paste is usually a rule
* EXPORT (the "Export" button's shape) — e.g. a rule received from another tenant.
* Exports keep collector options in a nested `parameters` object; before the mapper,
* that key was silently discarded and the imported rule lost every parameter.
*/
describe("gatherRuleToForm", () => {
it("unflattens the parameters object of a rule-shaped export", () => {
const form = gatherRuleToForm({
ruleId: "CONTOSO-WIFI-001",
title: "WiFi driver version",
collectorType: "registry",
target: "HKLM\\SOFTWARE\\Contoso",
parameters: { valueName: "DriverVersion", listSubkeys: "true" },
trigger: "startup",
intervalSeconds: null,
triggerPhase: null,
triggerEventType: null,
outputEventType: "contoso_wifi_driver",
outputSeverity: "info",
tags: ["contoso", "network"],
});
expect(form.valueName).toBe("DriverVersion");
expect(form.listSubkeys).toBe(true);
expect(form.tags).toEqual(["contoso", "network"]);
expect(form.intervalSeconds).toBe(60);
// A rule without emitMode behaves "always" — the import must not upgrade it to
// the empty-form on_change default.
expect(form.emitMode).toBe("always");
});
it("prefers the parameters object over stale flat fields on an edit merge", () => {
const staleEditForm = { ...EMPTY_FORM, logPattern: "old-pattern", trackPosition: false };
const form = gatherRuleToForm({
...staleEditForm,
title: "IME log watcher",
collectorType: "logparser",
target: "%ProgramData%\\Logs\\App*.log",
parameters: { pattern: "new-pattern", maxLines: "50" },
});
expect(form.logPattern).toBe("new-pattern");
expect(form.maxLines).toBe("50");
// parameters is authoritative: keys it does not carry fall back to collector defaults,
// not to the stale flat fields of the previously edited rule.
expect(form.trackPosition).toBe(true);
});
it("passes form-shaped JSON (no parameters key) through unchanged", () => {
const form = gatherRuleToForm({
...EMPTY_FORM,
ruleId: "GATHER-CUSTOM-101",
title: "Pasted form",
valueName: "State",
emitMode: "on_change",
scopeMode: "from",
activeFromPhase: "AccountSetup",
});
expect(form.valueName).toBe("State");
expect(form.emitMode).toBe("on_change");
// scopeMode is UI-only and re-derived from the data, exactly like withDerivedScopeMode.
expect(form.scopeMode).toBe("from");
});
it("round-trips a rule through form and back without losing scope or params", () => {
const exported = {
ruleId: "GATHER-CUSTOM-042",
title: "Battery status",
collectorType: "wmi",
target: "SELECT BatteryStatus FROM Win32_Battery",
parameters: {},
trigger: "interval",
intervalSeconds: 300,
activePhases: ["DeviceSetup", "AppsDevice"],
emitMode: "on_change",
outputEventType: "battery_status",
outputSeverity: "info",
tags: ["power"],
};
const form = gatherRuleToForm(exported);
expect(form.scopeMode).toBe("during");
expect(form.activePhases).toEqual(["DeviceSetup", "AppsDevice"]);
expect(form.intervalSeconds).toBe(300);
expect(form.emitMode).toBe("on_change");
expect(buildScopeFields(form)).toEqual({
activePhases: ["DeviceSetup", "AppsDevice"],
activeFromPhase: null,
emitMode: "on_change",
});
});
});
@@ -1,7 +1,7 @@
"use client";
import { useMemo, useState } from "react";
import { GatherRule, NewRuleForm, CATEGORY_COLORS, COLLECTOR_TYPE_LABELS, formatTrigger, formatGatherPhase, withDerivedScopeMode } from "../types";
import { GatherRule, NewRuleForm, PastedGatherJson, CATEGORY_COLORS, COLLECTOR_TYPE_LABELS, formatTrigger, formatGatherPhase, gatherRuleToForm } from "../types";
import { GatherRuleFormFields } from "./GatherRuleFormFields";
import { FormJsonToggle, JsonModeToggleButtons, ReadOnlyJsonView } from "@/components/rules/FormJsonToggle";
import { validateGatherRuleTarget } from "@/utils/guardValidation";
@@ -484,8 +484,8 @@ export function GatherRuleCard({
onClick={() => {
if (jsonModeEdit) {
try {
const parsed = JSON.parse(jsonText) as NewRuleForm;
onSaveEdit(rule, withDerivedScopeMode({ ...editForm, ...parsed }));
const parsed = JSON.parse(jsonText) as PastedGatherJson;
onSaveEdit(rule, gatherRuleToForm({ ...editForm, ...parsed }));
} catch {
// jsonError is handled by parent
}
@@ -14,7 +14,7 @@ import { FormJsonToggle, JsonModeToggleButtons } from "@/components/rules/FormJs
import { useAuthenticatedFetch, useNotificationMessages, useGlobalAdminScope } from "@/hooks";
import { GlobalAdminBanner, globalAdminSubtitle } from "@/components/GlobalAdminBanner";
import { TenantScopeSelector } from "@/components/TenantScopeSelector";
import { GatherRule, NewRuleForm, EMPTY_FORM, PHASE_TRIGGERS, buildScopeFields, targetBlocked, validateScopeSelection, withDerivedScopeMode } from "./types";
import { GatherRule, NewRuleForm, PastedGatherJson, EMPTY_FORM, PHASE_TRIGGERS, buildScopeFields, gatherRuleToForm, targetBlocked, validateScopeSelection } from "./types";
import { GatherRuleFormFields } from "./components/GatherRuleFormFields";
import { GatherRuleCard } from "./components/GatherRuleCard";
import { DOCS_URL } from "@/utils/config";
@@ -238,6 +238,7 @@ export default function GatherRulesPage() {
...buildScopeFields(form),
outputEventType: form.outputEventType,
outputSeverity: form.outputSeverity,
tags: form.tags ?? [],
enabled: !blocked,
};
@@ -267,41 +268,7 @@ export default function GatherRulesPage() {
// Ensure the card stays expanded when entering edit mode
setExpandedRuleId(rule.ruleId);
setEditingRuleId(rule.ruleId);
setEditForm({
ruleId: rule.ruleId,
title: rule.title,
description: rule.description,
category: rule.category,
collectorType: rule.collectorType,
target: rule.target,
valueName: rule.parameters?.valueName || "",
listSubkeys: rule.parameters?.listSubkeys === "true",
eventId: rule.parameters?.eventId || "",
messageFilter: rule.parameters?.messageFilter || "",
maxEntries: rule.parameters?.maxEntries || "",
source: rule.parameters?.source || "",
readContent: rule.parameters?.readContent === "true",
logPattern: rule.parameters?.pattern || "",
logFormat: rule.parameters?.format || "cmtrace",
trackPosition: rule.parameters?.trackPosition !== "false",
maxLines: rule.parameters?.maxLines || "",
jsonPath: rule.parameters?.jsonpath || "",
xpath: rule.parameters?.xpath || "",
xmlNamespaces: rule.parameters?.namespaces || "",
maxResults: rule.parameters?.maxResults || "",
trigger: rule.trigger,
intervalSeconds: rule.intervalSeconds || 60,
triggerPhase: rule.triggerPhase || "",
triggerEventType: rule.triggerEventType || "",
scopeMode: rule.activePhases?.length ? "during" : rule.activeFromPhase ? "from" : "always",
activePhases: rule.activePhases || [],
activeFromPhase: rule.activeFromPhase || "",
// Existing rules without the field keep today's behavior ("always") — the on_change
// default applies to NEW rules only.
emitMode: rule.emitMode === "on_change" ? "on_change" : "always",
outputEventType: rule.outputEventType,
outputSeverity: rule.outputSeverity,
});
setEditForm(gatherRuleToForm(rule));
};
const handleSaveEdit = async (rule: GatherRule, overrideForm?: NewRuleForm) => {
@@ -338,8 +305,9 @@ export default function GatherRulesPage() {
version: bumpVersion(rule.version),
enabled: rule.enabled && !forcedOff,
createdAt: rule.createdAt,
// Same wipe class as the toggle bug: the full-replace PUT must carry tags or they vanish.
tags: rule.tags ?? [],
// Same wipe class as the toggle bug: the full-replace PUT must carry tags or they
// vanish. The form carries them (populated by gatherRuleToForm, editable via JSON).
tags: form.tags ?? rule.tags ?? [],
isBuiltIn: rule.isBuiltIn ?? false,
isCommunity: rule.isCommunity ?? false,
};
@@ -550,11 +518,11 @@ export default function GatherRulesPage() {
jsonError={jsonError}
onApplyJson={() => {
try {
const parsed = JSON.parse(jsonText) as NewRuleForm;
const parsed = JSON.parse(jsonText) as PastedGatherJson;
if (!parsed.ruleId && !parsed.title) throw new Error("JSON must include at least ruleId and title");
// Rule-shaped JSON carries activePhases/activeFromPhase but no scopeMode
// — derive it so the scope fields survive into the create payload.
setNewRule(withDerivedScopeMode({ ...EMPTY_FORM, ...parsed }));
// Handles both shapes: rule-shaped exports (nested parameters,
// no scopeMode) and the serialized form itself.
setNewRule(gatherRuleToForm(parsed));
setJsonModeCreate(false);
setJsonError(null);
} catch (e) {
@@ -585,9 +553,9 @@ export default function GatherRulesPage() {
onClick={() => {
if (jsonModeCreate) {
try {
const parsed = JSON.parse(jsonText) as NewRuleForm;
const parsed = JSON.parse(jsonText) as PastedGatherJson;
setJsonError(null);
handleCreateRule(withDerivedScopeMode({ ...EMPTY_FORM, ...parsed }));
handleCreateRule(gatherRuleToForm(parsed));
} catch (e) {
setJsonError(e instanceof Error ? e.message : "Invalid JSON");
}
@@ -666,9 +634,11 @@ export default function GatherRulesPage() {
onJsonTextChange={(text) => { setJsonText(text); setJsonError(null); }}
onApplyJson={() => {
try {
const parsed = JSON.parse(jsonText) as NewRuleForm;
const parsed = JSON.parse(jsonText) as PastedGatherJson;
if (!parsed.title) throw new Error("JSON must include title");
setEditForm(withDerivedScopeMode({ ...editForm, ...parsed }));
// Merge keeps unmentioned fields; a rule-shaped paste brings a
// parameters object, which the mapper prefers over stale flat fields.
setEditForm(gatherRuleToForm({ ...editForm, ...parsed }));
setJsonModeEdit(false);
setJsonError(null);
} catch (e) {
@@ -59,6 +59,7 @@ export interface NewRuleForm {
emitMode: string;
outputEventType: string;
outputSeverity: string;
tags: string[];
}
export const CATEGORIES = ["network", "identity", "apps", "device", "esp", "enrollment"] as const;
@@ -169,6 +170,7 @@ export const EMPTY_FORM: NewRuleForm = {
emitMode: "on_change",
outputEventType: "",
outputSeverity: "info",
tags: [],
};
export function formatTrigger(trigger: string) {
@@ -265,3 +267,59 @@ export function withDerivedScopeMode(form: NewRuleForm): NewRuleForm {
const emitMode = form.emitMode === "on_change" ? "on_change" : "always";
return { ...form, activePhases, activeFromPhase, scopeMode, emitMode };
}
/**
* What JSON-mode paste can contain: the serialized form, a rule export, or a mix.
* Shared fields take the rule's (nullable) typing so a GatherRule assigns directly;
* the form-only flat fields come from NewRuleForm.
*/
export type PastedGatherJson = Partial<GatherRule> & Partial<Omit<NewRuleForm, keyof GatherRule>>;
/**
* Maps rule-shaped OR form-shaped JSON into a NewRuleForm. Rule exports carry a nested
* `parameters` object (collector options) while the form stores them as flat fields —
* without this unflattening, pasting an export into the JSON editor silently dropped
* every collector parameter. When `parameters` is present it is authoritative and the
* flat fields are ignored (they can only be stale merge leftovers); form-shaped JSON
* (no `parameters` key — the shape the JSON toggle serializes) keeps its flat fields.
* Also the single mapper behind startEditing, so edit and paste stay equivalent.
*/
export function gatherRuleToForm(input: PastedGatherJson): NewRuleForm {
const p = input.parameters && typeof input.parameters === "object" ? input.parameters : null;
return withDerivedScopeMode({
...EMPTY_FORM,
ruleId: input.ruleId ?? "",
title: input.title ?? "",
description: input.description ?? "",
category: input.category ?? EMPTY_FORM.category,
collectorType: input.collectorType ?? EMPTY_FORM.collectorType,
target: input.target ?? "",
valueName: p ? p.valueName || "" : input.valueName ?? "",
listSubkeys: p ? p.listSubkeys === "true" : input.listSubkeys ?? false,
eventId: p ? p.eventId || "" : input.eventId ?? "",
messageFilter: p ? p.messageFilter || "" : input.messageFilter ?? "",
maxEntries: p ? p.maxEntries || "" : input.maxEntries ?? "",
source: p ? p.source || "" : input.source ?? "",
readContent: p ? p.readContent === "true" : input.readContent ?? false,
logPattern: p ? p.pattern || "" : input.logPattern ?? "",
logFormat: p ? p.format || "cmtrace" : input.logFormat ?? "cmtrace",
trackPosition: p ? p.trackPosition !== "false" : input.trackPosition ?? true,
maxLines: p ? p.maxLines || "" : input.maxLines ?? "",
jsonPath: p ? p.jsonpath || "" : input.jsonPath ?? "",
xpath: p ? p.xpath || "" : input.xpath ?? "",
xmlNamespaces: p ? p.namespaces || "" : input.xmlNamespaces ?? "",
maxResults: p ? p.maxResults || "" : input.maxResults ?? "",
trigger: input.trigger ?? EMPTY_FORM.trigger,
intervalSeconds: input.intervalSeconds || 60,
triggerPhase: input.triggerPhase ?? "",
triggerEventType: input.triggerEventType ?? "",
activePhases: input.activePhases ?? [],
activeFromPhase: input.activeFromPhase ?? "",
// A rule without the field behaves "always"; only an explicit on_change survives —
// the on_change default is for rules started from the empty form, not for imports.
emitMode: input.emitMode ?? "always",
outputEventType: input.outputEventType ?? "",
outputSeverity: input.outputSeverity ?? EMPTY_FORM.outputSeverity,
tags: Array.isArray(input.tags) ? input.tags.filter((t): t is string => typeof t === "string") : [],
});
}