mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+97
@@ -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") : [],
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user