{(matches.length - filtered.length).toLocaleString("en-US")} more matches. Keep typing to narrow down.
}
);
}
// Live control-plane health: seeded from the bootstrap result, then re-probed
// every 15s against /api/healthz. In mock mode (no IMARA_LIVE) it stays green.
function useControlPlaneHealth() {
const [h, setH] = useState(() => (typeof window !== "undefined" && window.IMARA_HEALTH) || { ok: true, reason: "" });
useEffect(() => {
if (!window.IMARA_LIVE) return;
let alive = true;
const probe = async () => {
try {
const r = await fetch("/api/healthz", { cache: "no-store" });
if (alive) setH({ ok: r.ok, reason: r.ok ? "Imara server reachable" : `Imara server health check returned ${r.status}`, at: Date.now() });
} catch (e) {
if (alive) setH({ ok: false, reason: (e && e.message) || "Imara server unreachable", at: Date.now() });
}
};
probe();
const t = setInterval(probe, 15000);
return () => { alive = false; clearInterval(t); };
}, []);
return h;
}
// Login modal. Appears when a mutation returns 401 (auth required), or when the
// user clicks "sign in". On success the session cookie is set and the pending
// action can be retried. In mock mode / when auth is disabled this never shows.
function LoginGate() {
// "fullpage" = login required before anything shows (checked at boot).
// "modal" = a mid-session mutation returned 401.
const [mode, setMode] = useState(() => (typeof window !== "undefined" && window.IMARA_LOGIN_REQUIRED) ? "fullpage" : null);
const [user, setUser] = useState("admin");
const [pw, setPw] = useState("");
const [err, setErr] = useState("");
const [busy, setBusy] = useState(false);
useEffect(() => {
window.__imaraOnAuthRequired = (method) => setMode((m) => m || (method && method !== "GET" ? "modal" : "expired"));
return () => { delete window.__imaraOnAuthRequired; };
}, []);
if (!mode) return null;
const full = mode === "fullpage";
const submit = async (e) => {
e.preventDefault();
setBusy(true); setErr("");
try {
await window.ImaraAPI.login(user, pw);
setPw("");
if (full) { window.location.reload(); return; } // reload to fetch real data
setMode(null);
} catch (ex) { setErr(ex.message || "Sign-in failed"); }
finally { setBusy(false); }
};
return (
);
}
// ---- sidebar ----
function Sidebar({ view, nav, openCmdk, alertCount, loadedAt }) {
const health = useControlPlaneHealth();
return (
);
}
// ---- root app ----
// Merge audit pages by id, newest first (ids are serial; occurred_at breaks ties).
function mergeAudit(fresh, cur) {
const seen = new Map();
[...(fresh || []), ...(cur || [])].forEach((r) => { if (r && !seen.has(r.id)) seen.set(r.id, r); });
return [...seen.values()].sort((a, b) => {
const ta = new Date(a.occurred_at).getTime(), tb = new Date(b.occurred_at).getTime();
return tb - ta || (b.id > a.id ? 1 : b.id < a.id ? -1 : 0);
});
}
const ACTION_LABEL = {
request: "Sending for approval", approve: "Approving", reject: "Requesting changes", promote: "Applying to all servers",
rollback: "Rolling back the rule", "rollback-to": "Restoring the version", save: "Saving", delete: "Deleting",
};
// Toast text built from the row the server returned AFTER the action, so it
// reports what actually happened (e.g. 1 of 2 approvals, not "approved").
function describeOutcome(action, it, opts) {
if (!it) return [`${ACTION_LABEL[action] || action}: done`, "ok"];
const need = it.required_approvers || 1, have = it.approvers_count || 0;
switch (action) {
case "request": return [`Sent for approval. ${need === 1 ? "1 approval needed (can be you)" : `${need} approvals needed`}.`, "warn"];
case "approve":
if (it.approval_state === "approved") {
if (it.canary_state === "canarying" || it.canary_state === "baking") { const ts = (window.MS_DATA.byId || {})[it.canary_target]; return ["Approved. " + window.ImaraTerms.trialFollowText(it, window.MS_DATA.byId || {}), ts && ts.fresh !== "live" ? "warn" : "ok"]; }
if (it.status === "error") return ["Approved, but sending failed on some servers. Open the rule for details.", "warn"];
if (it.status === "applied") return ["Approved and confirmed by its servers.", "ok"];
return ["Approved. Sending to its servers.", "ok"];
}
return [`Approval recorded (${have} of ${need}). ${need - have} more needed from someone other than the author.`, "ok"];
case "reject": return ["Sent back to the author with your reason.", "warn"];
case "promote": return ["Applying to all its servers.", "ok"];
case "rollback": return ["Rolled back. The whole rule is being removed from its servers.", "warn"];
case "rollback-to": return [`Version ${opts.bundle.version} restored as version ${it.version} and sent to the servers.`, "ok"];
case "save": return [Number(it.approved_version || 0) > 0 && it.approval_state !== "rolled_back"
? `Saved as draft version ${it.version}. Servers keep the approved version until this one is approved.`
: `Saved as draft version ${it.version}. Nothing reaches servers until it is approved.`, "ok"];
default: return [`${ACTION_LABEL[action] || action}: done`, "ok"];
}
}
function normalizeApp(a) {
return {
id: a.id, name: a.name, description: a.description, env: a.env || "",
required_approvers: a.required_approvers || 1,
tiers: (a.tiers || []).map((t) => ({ name: t.name, group_ids: t.group_ids || [] })),
};
}
// One page that fails to draw shows what went wrong instead of blanking the
// whole console; the menu and the other pages keep working.
// Versions this browser created (saves, restores): a rule panel showing
// one of them does not ask the operator to read it again.
const ownVersions = new Set();
window.IMARA_OWN_VERSIONS = ownVersions;
const markOwn = (row) => { if (row && row.id != null && row.version != null) ownVersions.add(row.id + ":" + row.version); return row; };
// kind: "page" (default), "panel" (a drawer or dialog; onClose closes it)
// or "app" (everything; only a reload helps).
class PageBoundary extends React.Component {
constructor(props) { super(props); this.state = { error: null }; }
static getDerivedStateFromError(error) { return { error }; }
componentDidCatch(error) { console.error("[imara] failed to draw:", error); }
render() {
if (!this.state.error) return this.props.children;
const msg = String((this.state.error && this.state.error.message) || this.state.error);
const kind = this.props.kind || "page";
const card = (title, text, actions) => (
{title}
{text} If it happens again, send the Imara team what you did and this message: {msg}
{card("This panel could not be shown", "The rest of the console still works.", [
, {reload}])}
);
if (kind === "app") return
{card("The console encountered an error", "Reload the page to continue.", [reload])}
;
return
{card("This page could not be shown", "The other pages still work. Reload to try again.", [reload])}
;
}
}
function App() {
const [view, setView] = useState("overview");
const [host, setHost] = useState(null);
const [alert, setAlert] = useState(null);
const [intent, setIntent] = useState(null);
const [cmdk, setCmdk] = useState(false);
const [toasts, setToasts] = useState([]);
const st = useStore();
const API = window.ImaraAPI;
// live-mutable state — single source of truth (copies of the loaded data)
const [intents, setIntents] = useState(() => [...(window.MS_DATA.INTENTS || [])]);
const [alerts, setAlerts] = useState(() => [...(window.MS_DATA.ALERTS || [])]);
const [groups, setGroups] = useState(() => [...(window.MS_DATA.GROUPS || [])]);
const [applications, setApplications] = useState(() => [...(window.MS_DATA.APPLICATIONS || [])]);
const [audit, setAudit] = useState(() => [...(window.MS_DATA.AUDIT || [])]);
const [auditEnd, setAuditEnd] = useState(() => (window.MS_DATA.AUDIT || []).length < 100);
const [wizard, setWizard] = useState(null); // null | {initial}
const [groupFormSignal, setGroupFormSignal] = useState(0); // bump to open the new-group form
// Redraw every minute, so evidence that grew too old reads "unknown" even
// when refreshes keep failing and nothing else changes.
const [, setTick] = useState(0);
useEffect(() => { const t = setInterval(() => setTick((n) => n + 1), 60000); return () => clearInterval(t); }, []);
// Firewall reports of a large fleet arrive after the first screen: draw again.
useEffect(() => { if (window.ImaraLive) window.ImaraLive.onFirewalls = () => setTick((n) => n + 1); return () => { if (window.ImaraLive) window.ImaraLive.onFirewalls = null; }; }, []);
const [protectGroup, setProtectGroup] = useState(null); // group whose protection is being requested
const [confirm, setConfirm] = useState(null); // ConfirmModal props + run
// Live-data bookkeeping: real load time, partial-load errors, refresh errors.
const [loadedAt, setLoadedAt] = useState(() => window.MS_DATA.LOADED_AT || null);
const [loadErrors, setLoadErrors] = useState(() => window.IMARA_LOAD_ERRORS || []);
const [bootFailed, setBootFailed] = useState(() => !!window.IMARA_BOOT_FAILED);
const [refreshErr, setRefreshErr] = useState("");
const [, setDataVersion] = useState(0);
const lastMutation = useRef(0);
const refreshing = useRef(false);
const auditRef = useRef(audit); auditRef.current = audit;
const intentsRef = useRef(intents); intentsRef.current = intents;
const groupsRef = useRef(groups); groupsRef.current = groups;
const alertsRef = useRef(alerts); alertsRef.current = alerts;
const appsRef = useRef(applications); appsRef.current = applications;
// The open-alert counts as this console last knew them (a refresh that
// started before an Acknowledge or Resolve does not undo its change).
const alertCountsRef = useRef(window.MS_DATA.ALERT_OPEN_COUNTS);
// The signed-in operator, from the control plane session (not a fixture).
const [me, setMe] = useState({ user: "", authenticated: false });
useEffect(() => {
if (API && API.me) API.me().then((m) => m && setMe(m)).catch(() => {});
}, []);
const who = me.user || (me.auth_enabled === false ? "auth disabled" : "not signed in");
const signOut = async () => {
try { await API.logout(); } catch (e) { /* session may already be gone */ }
window.location.reload();
};
// Components such as the intent wizard and host drawer read window.MS_DATA
// directly. Mirror the live state into it so a change made in this session
// shows up there without a page reload.
useEffect(() => {
const D = window.MS_DATA;
D.GROUPS = groups; D.INTENTS = intents; D.ALERTS = alerts; D.APPLICATIONS = applications;
}, [groups, intents, alerts, applications]);
// Sentences name rules by their titles: kept current while rendering, so a
// rule saved a moment ago is named by its title at once.
useMemo(() => { if (window.ImaraTerms && window.ImaraTerms.configureRuleTitles) window.ImaraTerms.configureRuleTitles(intents); }, [intents]);
const toast = useCallback((msg, tone = "accent") => {
const id = Math.random().toString(36).slice(2);
setToasts((t) => [...t, { id, msg, tone }]);
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), tone === "bad" ? 8000 : 3500);
}, []);
// The audit log is the server's. Nothing is ever written client-side:
// after any change we re-read GET /api/audit and merge it in.
const refreshAudit = useCallback(async () => {
try {
const limit = Math.min(500, Math.max(100, auditRef.current.length));
const rows = await API.listAudit({ limit, offset: 0 });
setAudit((cur) => mergeAudit(rows, cur));
} catch (e) { /* keep the last good list; the page says how many are shown */ }
}, []);
const loadMoreAudit = useCallback(async () => {
const PAGE = 100;
try {
const rows = await API.listAudit({ limit: PAGE, offset: auditRef.current.length });
setAudit((cur) => mergeAudit(rows, cur));
if (rows.length < PAGE) setAuditEnd(true);
} catch (e) { toast(`Could not load older audit log entries: ${e.message}`, "bad"); }
}, [toast]);
// Re-read one intent (GET /api/intents/{id} carries approvers_count) and
// put the server's row into the list and the open drawer.
const refreshIntent = useCallback(async (id) => {
let raw;
try { raw = await API.getIntent(id); }
catch (e) {
if (e && e.status === 404) {
setIntents((l) => l.filter((x) => x.id !== id));
setIntent((cur) => (cur && cur.id === id ? null : cur));
return null;
}
throw e;
}
if (!raw || raw.id == null) return null; // nothing usable came back: keep the list as it is
const fresh = window.ImaraLive ? await window.ImaraLive.enrichIntent(raw, { fromItem: true }) : raw;
setIntents((l) => (l.some((x) => x.id === id) ? l.map((x) => (x.id === id ? fresh : x)) : [fresh, ...l]));
setIntent((cur) => (cur && cur.id === id ? fresh : cur));
return fresh;
}, []);
// Re-run every live loader without a page reload, keeping open drawers on
// the same objects (by id). Runs every 30 s while the tab is visible.
const refreshAll = useCallback(async () => {
if (!window.IMARA_LIVE || !window.ImaraLive || window.IMARA_LOGIN_REQUIRED) return;
if (refreshing.current) return;
refreshing.current = true;
const startedAt = Date.now();
try {
const r = await window.ImaraLive.loadAll({ initial: false, auditLimit: auditRef.current.length });
const D = window.MS_DATA;
// An action finished while this refresh was in flight: its own refetch
// is newer than what this refresh read, so keep it.
const stale = lastMutation.current > startedAt;
if (stale) {
D.INTENTS = intentsRef.current; D.GROUPS = groupsRef.current;
D.ALERTS = alertsRef.current; D.APPLICATIONS = appsRef.current;
D.ALERT_OPEN_COUNTS = alertCountsRef.current;
}
else {
setIntents([...D.INTENTS]); setGroups([...D.GROUPS]);
setIntent((cur) => {
if (!cur) return cur;
const now = D.INTENTS.find((i) => i.id === cur.id);
if (now) return now;
// Gone from a complete list: someone deleted it.
if (!(D.LOAD_FAILED && D.LOAD_FAILED.INTENTS) && typeof cur.id === "number") { setTimeout(() => toast(`The rule ${cur.name} was deleted by someone else.`, "warn"), 0); return null; }
return cur;
});
}
setAudit((cur) => mergeAudit(D.AUDIT, cur));
// Alerts and applications follow the same rule as rules and
// groups: a change made here meanwhile is not undone (the kept lists
// were put back into the shared data above).
if (!stale) {
alertCountsRef.current = D.ALERT_OPEN_COUNTS;
setAlerts([...D.ALERTS]); setApplications([...D.APPLICATIONS]);
setAlert((cur) => (cur ? (D.ALERTS.find((a) => a.id === cur.id) || cur) : cur));
}
setHost((cur) => (cur ? (D.byId[cur.agent_id] || cur) : cur));
setLoadedAt(r.at); setLoadErrors(r.errors || []); setBootFailed(false); setRefreshErr("");
setDataVersion((v) => v + 1);
} catch (e) {
window.IMARA_HEALTH = { ok: false, reason: (e && e.message) || "refresh failed", at: Date.now() };
setRefreshErr((e && e.message) || "refresh failed");
} finally {
refreshing.current = false;
}
}, []);
useEffect(() => {
if (!window.IMARA_LIVE) return;
const t = setInterval(() => { if (!document.hidden) refreshAll(); }, 30000);
// Back on the tab: refresh at once, so old evidence is not shown as now.
const v = () => { if (!document.hidden) refreshAll(); };
document.addEventListener("visibilitychange", v);
return () => { clearInterval(t); document.removeEventListener("visibilitychange", v); };
}, [refreshAll]);
const nav = useCallback((v) => { setView(v); setHost(null); setAlert(null); setIntent(null); }, []);
const openWizard = useCallback((initial) => setWizard({ initial }), []);
// The rule as the server has it now, refused unless it is the version the
// page shows.
const sameVersion = useCallback(async (id, version) => {
const fresh = await API.getIntent(id);
if (!fresh) throw new Error("The rule could not be read. Try again.");
if (version != null && Number(fresh.version) !== Number(version)) {
refreshIntent(id).catch(() => {});
throw new Error(`The rule changed since this page loaded it (now version ${fresh.version}). Close and reopen it to see the new version.`);
}
return fresh;
}, [refreshIntent]);
// The approved text of a rule as the Imara server has it now: null before
// any approval. Throws when it cannot be read: the checks below refuse
// rather than guess.
const approvedTextOf = useCallback(async (row) => {
const av = Number(row.approved_version || 0);
if (!av) return null;
if (Number(row.version) === av) return row.body;
const body = await API.versionText(row.id, av);
if (body == null) throw new Error(`approved version ${av} could not be read, so whether this change turns default deny on or off cannot be confirmed. Try again`);
return body;
}, []);
// Does nextBody turn incoming protection on or off compared with the
// rule's approved version, read fresh from the server (end dates and a
// different destination count)? -> {sw: boolean, why}. Plan D3: such a
// change needs two approvals. Throws when the console cannot tell.
const switchNow = useCallback(async (row, nextBody) => {
const r = window.ImaraTerms.protectionSwitch({ ...row, approved_body: await approvedTextOf(row) }, nextBody);
if (r.sw === "unknown") throw new Error(r.why);
return r;
}, [approvedTextOf]);
// The Imara server approves first and compiles later, so the text is
// checked here, fresh, before any approval: a text that does not compile
// would leave its servers without any change.
const checkCompiles = useCallback(async (row) => {
let v;
try { v = await API.previewVerdict({ name: row.name, body: row.body, ...API.targetOf(row) }); }
catch (e) { throw new Error(e && e.status === 422 ? `the rule fails validation: ${e.message}` : `the rule could not be validated just now (${(e && e.message) || e}). Try again`); }
if (v && v.error) throw new Error(`the rule fails validation: ${v.error}`);
}, []);
// A version that certainly cannot work (a group it names has no usable
// server, another rule uses its name on the same servers, or its trial
// server cannot be updated) is refused, with the data loaded now.
const refuseIfCannotWork = useCallback((row) => {
const DD = window.MS_DATA;
const it = (DD.INTENTS || []).find((x) => x.id === row.id) || row;
const lines = window.ImaraTerms.approvalLines({ ...it, body: row.body, version: row.version }, { groups: DD.GROUPS || [], servers: DD.SERVERS, byId: DD.byId || {}, intents: DD.INTENTS, incomplete: DD.POLICY_INCOMPLETE });
const bad = lines.find((l) => l.cannotWork);
if (bad) throw new Error(bad.text.replace(/^This version cannot work now: /, "this version cannot work now: "));
}, []);
// At confirmation: reload everything and recompute what releasing the
// rule's current text does; refuse when it is not what the page showed.
const recheckChange = useCallback(async (id, version, shown) => {
if (!window.IMARA_LIVE || !window.ImaraLive) return;
await window.ImaraLive.loadAll({ initial: false });
const DD = window.MS_DATA;
const it = (DD.INTENTS || []).find((x) => x.id === id);
if (!it || Number(it.version) !== Number(version)) throw new Error("The rule changed while this page was open. Reopen it from the Rules page.");
const now = window.ImaraTerms.lineKeys(window.ImaraTerms.approvalLines(it, { groups: DD.GROUPS || [], servers: DD.SERVERS, byId: DD.byId || {}, intents: DD.INTENTS, audit: DD.AUDIT, incomplete: DD.POLICY_INCOMPLETE }));
if (JSON.stringify(now) !== JSON.stringify(shown)) throw new Error("The effect of this change is different now (another rule or a server's state changed). Review it again before applying.");
}, []);
// Operations behind the rule page (wizard.jsx). Each one talks to the
// server and re-reads the rule; nothing is optimistic.
const ruleOps = useMemo(() => ({
// Creates the draft and stores the approvals the page shows. Resolves to
// {row, warning}: once the create succeeded, a later failure is a
// warning and never loses the new rule (saving again would create a
// second rule with the same name).
async saveDraft(input) {
const names = await API.listIntents();
if ((names || []).some((i) => i.name === input.name)) throw new Error(`A rule named ${input.name} already exists (possibly just created). Choose another name.`);
lastMutation.current = Date.now();
if (!input.target_selector || !Object.keys(input.target_selector).length) throw new Error("The destination group has no conditions, so it matches every server. Pick a narrower group.");
const created = await API.createIntent({ name: input.name, body: input.body, target_selector: input.target_selector });
if (!created || created.id == null) throw new Error("the Imara server did not return the new rule");
const warnings = [];
if (input.approvals && input.approvals !== created.required_approvers) {
try { await API.setApprovalPolicy(created.id, input.approvals); }
catch (e) { warnings.push(`the approvals needed could not be set to ${input.approvals} (${e.message}); it needs ${created.required_approvers || 1} for now`); }
}
refreshAudit();
markOwn(created);
let row = created;
try { row = (await refreshIntent(created.id)) || created; }
catch (e) { warnings.push(`re-reading it failed (${e.message})`); }
return { row, warning: warnings.length ? `Saved as a draft, but ${warnings.join(", and ")}.` : "" };
},
// Resolves to {row, warning}. row is the server's re-read rule, or the
// row the update itself returned; null only when neither is available.
// expect: {state, approvers} the page showed. Sending a version for
// approval does not change its number, so the state is compared too: a
// request (and its approvals) is never dropped without the page asking.
async updateDraft(id, body, approvals, version, expect) {
// Never replace a newer version someone else saved meanwhile.
if (version != null) {
const fresh = await sameVersion(id, version);
const st = String(fresh.approval_state || ""), have = Number(fresh.approvers_count || 0);
if (expect && (st !== String(expect.state || "") || (expect.approvers != null && have !== Number(expect.approvers)))) {
refreshIntent(id).catch(() => {});
const sentMeanwhile = st === "requested" && String(expect.state || "") !== "requested";
const e = new Error(sentMeanwhile
? `Version ${fresh.version} was sent for approval while this page was open${have ? ` and has ${have} approval${have === 1 ? "" : "s"}` : ""}. Saving would cancel that request. Nothing was saved: review it, then save again to replace it.`
: st === "requested"
? `Version ${fresh.version} now has ${have} approval${have === 1 ? "" : "s"}, not the number this page loaded. Saving would cancel the request and its approvals. Nothing was saved: review it, then save again.`
: `The rule's approval state changed while this page was open. Nothing was saved: review it, then save again.`);
e.code = "STATE_CHANGED"; e.fresh = fresh;
throw e;
}
}
lastMutation.current = Date.now();
const put = await API.updateIntent(id, body);
markOwn(put);
let row = null, warning = "";
try { row = await refreshIntent(id); }
catch (e) {
warning = `Saved, but re-reading the rule failed (${e.message}).`;
if (put && put.id === id && typeof put.body === "string") row = { ...put, approvers_count: put.approvers_count || 0 };
}
if (row && approvals && approvals > (row.required_approvers || 1) && ["draft", "rejected"].includes(row.approval_state)) {
try { await API.setApprovalPolicy(id, approvals); row = (await refreshIntent(id)) || row; }
catch (e) { warning = `Saved, but the approvals needed could not be set to ${approvals} (${e.message}).`; }
}
refreshAudit();
return { row, warning };
},
// expected: the number the page believes the rule needs now; version:
// the version the page shows. If either moved meanwhile, nothing is
// written. Lowering only with allowLower (a new, never-sent draft made
// on that page).
async setApprovals(id, n, expected, allowLower, version) {
const fresh = await sameVersion(id, version);
const now = fresh.required_approvers || 1;
if (expected != null && now !== expected) {
refreshIntent(id).catch(() => {});
throw new Error(`The approvals needed changed to ${now} since this page loaded the rule. Close and reopen it.`);
}
if (n < now && !(allowLower && fresh.approval_state === "draft" && !Number(fresh.approved_version || 0))) throw new Error("The approvals needed cannot be lowered here.");
lastMutation.current = Date.now();
await API.setApprovalPolicy(id, n);
refreshAudit();
return refreshIntent(id);
},
reload: (id) => refreshIntent(id),
simulate: (id) => API.intentSimulate(id, 14),
// Both re-read the rule first and refuse when its version is not the one
// on screen (someone else saved a newer version meanwhile).
async send(id, approvals, current, version) {
const fresh = await sameVersion(id, version);
const now = fresh.required_approvers || 1;
if (now !== (current || 1)) throw new Error(`The approvals needed changed to ${now} since this page loaded the rule. Close and reopen it.`);
if (approvals && approvals < now) throw new Error("The approvals needed cannot be lowered here.");
// A version that turns incoming protection on or off is sent needing
// two approvals, whatever the page asked for; so is one the console
// cannot judge (the approved text could not be read).
const sw = await switchNow(fresh, fresh.body).catch(() => ({ sw: true }));
const want = Math.max(approvals || now, sw.sw ? 2 : 1);
lastMutation.current = Date.now();
if (want > now) await API.setApprovalPolicy(id, want);
await API.intentTransition(id, "request-approval");
refreshAudit();
// Sent: a failed read-back must not read as a failed send.
try { return await refreshIntent(id); }
catch (e) { return { ...fresh, approval_state: "requested", required_approvers: want, read_back_failed: String((e && e.message) || e) }; }
},
// Only for rules that need exactly one approval and do not turn
// incoming protection on or off: request it and approve it. Never
// lowers the approvals a rule needs. shown: the consequence lines the
// page showed, checked again with fresh data first.
async applyNow(id, current, version, shown) {
if (current !== 1) throw new Error("This rule needs more than one approval, so it cannot be applied without review.");
const fresh = await sameVersion(id, version);
if ((fresh.required_approvers || 1) !== 1) throw new Error("This rule now needs more than one approval, so it cannot be applied without review.");
const sw = await switchNow(fresh, fresh.body);
if (sw.sw) throw new Error(`${sw.why} 2 approvals needed: send it for approval instead.`);
await checkCompiles(fresh);
if (shown) await recheckChange(id, version, shown);
refuseIfCannotWork(fresh);
lastMutation.current = Date.now();
await API.intentTransition(id, "request-approval");
await API.intentTransition(id, "approve");
// Its servers get new firewalls: note them and read their reports again.
const ids = window.ImaraTerms.ruleServers(fresh, { servers: window.MS_DATA.SERVERS, byId: window.MS_DATA.byId || {} }).map((x) => x.agent_id);
window.ImaraTerms.noteServerChange(ids);
if (window.ImaraLive && window.ImaraLive.invalidateFirewalls) {
window.ImaraLive.invalidateFirewalls(ids);
setTimeout(() => { window.ImaraLive.invalidateFirewalls(ids); refreshAll(); }, 35000);
}
refreshAudit();
// Approved: a failed read-back must not read as a failed approval.
try { return await refreshIntent(id); }
catch (e) { return { ...fresh, approval_state: "approved", read_back_failed: String((e && e.message) || e) }; }
},
// Deletes a draft only after the server confirms it is still an unsent
// draft that was never approved.
async discard(id) {
lastMutation.current = Date.now();
const fresh = await API.getIntent(id);
if (!fresh || fresh.approval_state !== "draft" || Number(fresh.approved_version || 0) > 0) {
refreshIntent(id).catch(() => {});
throw new Error("Not deleted: this rule is no longer an unsent draft. It is on the Rules page.");
}
await API.deleteIntent(id);
setIntents((l) => l.filter((x) => x.id !== id));
refreshAudit();
},
// The one-server group of a server (rule text names groups only): the
// one the Imara server already has, or a new one. The servers are read
// again first: the server's name must belong to it alone. A new group is
// waited for (up to about 6 s) until the compiler sees it, since the
// Imara server reads groups for compiling at most every 2 seconds.
// Resolves to the group, with created: true when it was made here.
async ensureServerGroup(server) {
const T = window.ImaraTerms;
const fresh = await API.listServers();
const me = (fresh || []).find((x) => x.agent_id === server.agent_id);
if (!me) throw new Error(`${server.hostname} is no longer in the server list.`);
const issue = T.serverNameIssue(me, fresh);
if (issue) throw new Error(`${server.hostname} cannot get its own group: ${issue.charAt(0).toLowerCase() + issue.slice(1)}`);
if (!T.hasApprovedAddress(me)) throw new Error(`${server.hostname} has no approved IP address yet, so rules cannot use it. Approve its addresses on its page first.`);
const sel = T.serverSelector(me);
const known = await API.listGroups();
const add = (row) => {
const D = window.MS_DATA;
if (!(D.GROUPS || []).some((g) => g.id === row.id)) D.GROUPS = [...(D.GROUPS || []), row];
setGroups((gs) => (gs.some((g) => g.id === row.id) ? gs : [...gs, row]));
return row;
};
const found = (known || []).find((g) => T.sameSelector(g.selector || {}, sel));
if (found) {
if (T.groupMembers(found.selector || sel, fresh).length !== 1) throw new Error(`The group for ${server.hostname} no longer holds exactly that server. Check it on the Groups page.`);
return add({ ...found, selector: found.selector || sel, created: false });
}
lastMutation.current = Date.now();
const g = await API.createGroup({ name: T.serverGroupName(me, known), description: `Only the server ${me.hostname}`, selector: sel });
if (!g || !g.name) throw new Error("the Imara server did not return the new group");
refreshAudit();
const row = add({ ...g, selector: g.selector || sel, created: true });
const probe = `policy: group-ready\nfrom: {group: ${JSON.stringify(row.name)}}\nto: {group: ${JSON.stringify(row.name)}}\nallow: ["tcp/1"]\n`;
for (let i = 0; i < 9; i++) {
try { await API.previewVerdict({ name: "group-ready", body: probe, target_selector: row.selector }); break; }
catch (e) { if (!/not found in registry/i.test((e && e.message) || "")) break; await new Promise((r) => setTimeout(r, 700)); }
}
return row;
},
// An automatic group (a site or a subnet the servers report): the
// stored group with exactly these conditions, or a new one, waited for
// until the compiler sees it, as for one-server groups. Resolves to the
// group, with created: true when it was made here.
async ensureAutoGroup(sel, description) {
const T = window.ImaraTerms;
const known = await API.listGroups();
const add = (row) => {
const D = window.MS_DATA;
if (!(D.GROUPS || []).some((g) => g.id === row.id)) D.GROUPS = [...(D.GROUPS || []), row];
setGroups((gs) => (gs.some((g) => g.id === row.id) ? gs : [...gs, row]));
return row;
};
const found = (known || []).find((g) => T.sameSelector(g.selector || {}, sel));
if (found) return add({ ...found, selector: found.selector || sel, created: false });
lastMutation.current = Date.now();
const g = await API.createGroup({ name: T.suggestGroupName(sel, known), description: description || T.selectorText(sel), selector: sel });
if (!g || !g.name) throw new Error("the Imara server did not return the new group");
refreshAudit();
const row = add({ ...g, selector: g.selector || sel, created: true });
const probe = `policy: group-ready\nfrom: {group: ${JSON.stringify(row.name)}}\nto: {group: ${JSON.stringify(row.name)}}\nallow: ["tcp/1"]\n`;
for (let i = 0; i < 9; i++) {
try { await API.previewVerdict({ name: "group-ready", body: probe, target_selector: row.selector }); break; }
catch (e) { if (!/not found in registry/i.test((e && e.message) || "")) break; await new Promise((r) => setTimeout(r, 700)); }
}
return row;
},
// Deletes a group made by the rule page when the rule it was made for
// could not be saved, unless a rule names it meanwhile
// (read again first). Never throws: a group left behind is listed on the
// Groups page and can be deleted there.
async dropUnusedGroup(g) {
const T = window.ImaraTerms;
try {
const list = await API.listIntents();
const use = T.rulesUsingGroup(g.name, list || []);
if (use.rules.length || use.unknown.length) return false;
await API.deleteGroup(g.id);
const D = window.MS_DATA;
D.GROUPS = (D.GROUPS || []).filter((x) => x.id !== g.id);
setGroups((gs) => gs.filter((x) => x.id !== g.id));
refreshAudit();
return true;
} catch (e) { return false; }
},
open(row) { if (!row) return; setView("intents"); setTimeout(() => setIntent(row), 60); },
// Closes the page and opens an empty one.
newRule() { setWizard(null); setTimeout(() => setWizard({ initial: {} }), 0); },
// Incoming protection for a group: create the managed rule, require two
// approvals, send it for approval. Each step is the server's answer; a
// failure after the create says what exists.
async requestProtection(input) {
const names = await API.listIntents();
if ((names || []).some((i) => i.name === input.name)) throw new Error(`A rule named ${input.name} was just created, possibly the same request. Check the Rules page; if you still need this one, close this page and try again (it gets a new name).`);
lastMutation.current = Date.now();
const created = await API.createIntent({ name: input.name, body: input.body, target_selector: input.target_selector });
if (!created || created.id == null) throw new Error("the Imara server did not return the new rule");
try {
if ((created.required_approvers || 1) !== 2) await API.setApprovalPolicy(created.id, 2);
await API.intentTransition(created.id, "request-approval");
} catch (e) {
refreshAudit(); refreshIntent(created.id).catch(() => {});
throw new Error(`The rule ${created.name} was created as a draft, but ${e.message}. Open it on the Rules page to finish.`);
}
refreshAudit();
return (await refreshIntent(created.id).catch(() => null)) || created;
},
}), [refreshAudit, refreshIntent, sameVersion, switchNow, recheckChange, checkCompiles, refuseIfCannotWork]);
useEffect(() => {
const h = (e) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
// Not while a dialog is open (the search would open underneath it),
// and not over an unsaved code edit (choosing a result would close it).
if (document.querySelector("[data-modal]") || window.IMARA_UNSAVED_EDIT) { toast(window.IMARA_UNSAVED_EDIT ? "Save or discard your edit first." : "Close this window first.", "warn"); return; }
setCmdk((o) => !o);
}
};
window.addEventListener("keydown", h); return () => window.removeEventListener("keydown", h);
}, []);
const trueOpen = window.MS_DATA.ALERT_OPEN_COUNTS;
const openCount = trueOpen ? (trueOpen.critical || 0) + (trueOpen.warn || 0) : alerts.filter((a) => a.status === "open" && a.severity !== "info").length;
// Intent actions. Nothing is optimistic: await the server, then re-read the
// intent and the audit log. On failure show the server's error (and still
// re-read, since a 409 means the row moved) and reject so the caller's
// dialog can stay open.
async function intentAction(it, action, opts = {}) {
const id = it.id;
let deleted = null;
lastMutation.current = Date.now();
try {
// A dialog may have been open for a while: act only on the rule it
// described. opts.expect lists what must still hold.
if (opts.expect) {
const cur = await API.getIntent(id);
const e = opts.expect;
// The API leaves out zero and empty fields: compare normalised values.
const norm = { version: (x) => Number(x || 0), approved_version: (x) => Number(x || 0), approval_state: (x) => String(x || ""), canary_state: (x) => String(x || "none"), approvers_count: (x) => Number(x || 0) };
const moved = !cur || Object.keys(norm).some((k) => e[k] !== undefined && norm[k](cur[k]) !== norm[k](e[k]));
if (moved && action === "save" && cur && cur.approval_state === "requested" && Number(cur.version) === Number(e.version)) throw new Error(e.approval_state === "requested"
? `version ${cur.version} now has ${cur.approvers_count || 0} approval${cur.approvers_count === 1 ? "" : "s"}, not the number you confirmed. Saving would cancel the request and its approvals. Nothing was saved: review it, then save again`
: `version ${cur.version} was sent for approval while you were editing it${cur.approvers_count ? ` and has ${cur.approvers_count} approval${cur.approvers_count === 1 ? "" : "s"}` : ""}. Saving would cancel that request. Nothing was saved: review it, then save again to replace it`);
if (moved) throw new Error("the rule changed while this dialog was open. Close the dialog and check the rule again");
}
if (action === "request") {
const cur = await API.getIntent(id);
if (!cur) throw new Error("the rule could not be read. Try again");
if (opts.version != null && Number(cur.version) !== Number(opts.version)) throw new Error(`the rule changed since you opened it (now version ${cur.version}). Review the new version first.`);
// A version that turns incoming protection on or off is sent needing
// two approvals (checked here with fresh data, whatever the panel
// thought).
const sw = await switchNow(cur, cur.body).catch(() => ({ sw: true }));
const raise = Math.max(opts.raiseTo || 1, sw.sw ? 2 : 1);
if ((cur.required_approvers || 1) < raise) await API.setApprovalPolicy(id, raise);
await API.intentTransition(id, "request-approval");
}
else if (action === "approve") {
// The server approves whatever version is waiting; make sure it is
// the one the approver reviewed.
const cur = await API.getIntent(id);
if (!cur) throw new Error("the rule could not be read. Try again");
if (opts.version != null && Number(cur.version) !== Number(opts.version)) throw new Error(`the rule changed since you opened it (now version ${cur.version}). Review the new version first.`);
if ((cur.required_approvers || 1) < 2) {
const sw = await switchNow(cur, cur.body);
if (sw.sw) throw new Error(`${sw.why.replace(/\.$/, "")}, but it is set to 1 approval. Request changes so it is sent again needing 2`);
}
await checkCompiles(cur);
refuseIfCannotWork(cur);
await API.intentTransition(id, "approve");
}
else if (action === "reject") await API.intentTransition(id, "reject", { reason: opts.reason });
else if (action === "promote") await API.intentTransition(id, "promote");
else if (action === "rollback") await API.intentTransition(id, "rollback", opts.reason ? { reason: opts.reason } : {});
else if (action === "rollback-to") {
// A restore is approved at once: never one that switches protection.
const cur = await API.getIntent(id);
if (!cur) throw new Error("the rule could not be read. Try again");
const sw = await switchNow(cur, opts.bundle.body);
if (sw.sw) throw new Error(`version ${opts.bundle.version} cannot be restored here: ${sw.why.charAt(0).toLowerCase() + sw.why.slice(1).replace(/\.$/, "")}, and a restore is approved immediately. Send its text for approval instead`);
await API.rollbackToBundle(id, opts.bundle.id, opts.reason || `restored version ${opts.bundle.version} from the console`);
}
else if (action === "save") await API.updateIntent(id, opts.body);
else if (action === "delete") deleted = await API.deleteIntent(id);
else throw new Error("unknown action " + action);
} catch (e) {
let msg = (e && e.message) || String(e);
if (/another operator must approve|cannot approve it \(requires/i.test(msg)) msg = "you wrote, edited or sent this rule, so 2 others must approve it";
toast(`${ACTION_LABEL[action] || action} failed: ${msg}`, "bad");
refreshIntent(id).catch(() => {});
refreshAudit();
throw e;
}
lastMutation.current = Date.now();
refreshAudit();
// Servers' firewalls change after rule actions: re-read their reports on
// the next refresh, and refresh soon.
// The servers of the rule get new firewalls: a removal is acknowledged
// without a rule id, so the console notes the change itself (terms.js
// shows protection as not confirmed until a newer report), and reads
// their reports again soon and after the agents' next report.
// Only actions that change servers: approving, applying to all,
// withdrawing, restoring, deleting (not saving, sending or asking for
// changes). The servers are those it applies to and those that had it.
// An approval that is not yet the last one needed changes nothing.
const lastApproval = action !== "approve" || it.approvers_count == null || Number(it.approvers_count || 0) + 1 >= Number(it.required_approvers || 1);
const changesServers = ["approve", "promote", "rollback", "rollback-to", "delete"].includes(action) && lastApproval;
let changedIds = [];
if (changesServers) {
try {
changedIds = Array.from(new Set(window.ImaraTerms.ruleServers(it, { servers: window.MS_DATA.SERVERS, byId: window.MS_DATA.byId || {} }).map((x) => x.agent_id)
.concat((Array.isArray(it.targets) ? it.targets : []).map((t) => t.agent_id))));
window.ImaraTerms.noteServerChange(changedIds);
} catch (e) { /* nothing to note */ }
}
if (changesServers && window.ImaraLive && window.ImaraLive.invalidateFirewalls) {
window.ImaraLive.invalidateFirewalls(changedIds); setTimeout(() => refreshAll(), 8000);
setTimeout(() => { window.ImaraLive.invalidateFirewalls(changedIds); refreshAll(); }, 35000);
}
if (action === "delete") {
setIntents((l) => l.filter((x) => x.id !== id));
setIntent((cur) => (cur && cur.id === id ? null : cur));
const failedHosts = (deleted && deleted.push_errors) || [];
if (failedHosts.length) {
const hn = (id) => ((window.MS_DATA.byId || {})[id] || {}).hostname || id;
toast(`Rule "${window.ImaraTerms.ruleTitle(it)}" deleted, but ${failedHosts.length} server${failedHosts.length === 1 ? " was" : "s were"} not updated and still ${failedHosts.length === 1 ? "has" : "have"} it: ${failedHosts.map((e) => hn(e.agent_id)).join(", ")}.`, "bad");
} else {
toast(`Rule "${window.ImaraTerms.ruleTitle(it)}" deleted.`, "warn");
}
return null;
}
let fresh = null;
try { fresh = await refreshIntent(id); }
catch (e) { toast(`${ACTION_LABEL[action] || action}: done, but re-reading the rule failed (${e.message}).`, "warn"); return null; }
if (action === "save" || action === "rollback-to") markOwn(fresh);
const [msg, tone] = describeOutcome(action, fresh, opts);
toast(msg, tone);
return fresh;
}
async function alertStatus(al, status) {
lastMutation.current = Date.now();
try { await API.updateAlert(al.id, status); }
catch (e) { toast(`Alert not updated: ${e.message}`, "bad"); return false; }
// The Imara server's open counts follow at once (until the next refresh).
const oc = window.MS_DATA.ALERT_OPEN_COUNTS;
if (oc && al.status === "open" && status !== "open" && oc[al.severity] > 0) window.MS_DATA.ALERT_OPEN_COUNTS = { ...oc, [al.severity]: oc[al.severity] - 1 };
alertCountsRef.current = window.MS_DATA.ALERT_OPEN_COUNTS;
lastMutation.current = Date.now();
setAlerts((list) => list.map((x) => (x.id === al.id ? { ...x, status } : x)));
setAlert((a) => (a && a.id === al.id ? { ...a, status } : a));
refreshAudit();
toast(`Alert ${status === "false_positive" ? "marked false positive" : status}`, status === "resolved" ? "ok" : "accent");
return true;
}
// Rejects on failure so the group form stays open.
async function createGroup(input) {
lastMutation.current = Date.now();
let g;
try { g = await API.createGroup(input); }
catch (e) { toast(`Group not created: ${e.message}`, "bad"); throw e; }
const sel = (g && g.selector) || {};
const row = { ...g, selector: sel,
member_count: window.MS_DATA.SERVERS.filter((s) => Object.keys(sel).length && Object.entries(sel).every(([k, v]) => s.labels[k] === v)).length };
setGroups((gs) => [...gs, row]);
refreshAudit();
toast("Group created", "ok");
return row;
}
// A group that a rule names cannot be deleted: every compile for the
// servers of that rule would fail until it is changed. The rules are read
// again first; any doubt refuses.
async function deleteGroup(g) {
lastMutation.current = Date.now();
const T = window.ImaraTerms;
let freshIntents;
try { freshIntents = await API.listIntents(); }
catch (e) { toast(`Not deleted: the rules could not be read (${e.message}). Cannot confirm whether ${T.groupName(g.name)} is in use.`, "warn"); return; }
// Texts of approved versions under newer drafts come from the loaded data.
const known = Object.fromEntries((intentsRef.current || []).map((i) => [i.id, i]));
const merged = (freshIntents || []).map((i) => {
const k = known[i.id];
const same = k && Number(k.approved_version) === Number(i.approved_version) && Number(k.stable_version || 0) === Number(i.stable_version || 0);
return { ...i, approved_body: same ? k.approved_body : null, stable_body: same ? k.stable_body : null };
});
const use = T.rulesUsingGroup(g.name, merged);
if (use.rules.length || use.unknown.length) {
toast(use.rules.length
? `${T.groupName(g.name)} is used by ${T.joinList(use.rules.map(T.ruleLabel))}. Change or delete ${use.rules.length === 1 ? "that rule" : "those rules"} first.`
: `Not deleted: some rules could not be read (${T.joinList(use.unknown)}). Cannot confirm whether ${T.groupName(g.name)} is in use.`, "warn");
return;
}
const apps = applications.filter((a) => (a.tiers || []).some((t) => (t.group_ids || []).includes(g.id))).map((a) => a.name);
setConfirm({
title: `Delete group ${T.groupName(g.name)}?`, confirmLabel: "Delete group", tone: "danger",
body: `No rule uses it. Its servers are not changed.${apps.length ? ` ${apps.length === 1 ? "The application" : "The applications"} ${T.joinList(apps)} ${apps.length === 1 ? "loses" : "lose"} this group.` : ""} Groups cannot be renamed or edited: to replace one, create a new group and rules for it, then delete the old rules and group.`,
run: async () => {
const ints2 = await API.listIntents();
const again = T.rulesUsingGroup(g.name, (ints2 || []).map((i) => { const k = known[i.id]; const same = k && Number(k.approved_version) === Number(i.approved_version) && Number(k.stable_version || 0) === Number(i.stable_version || 0); return { ...i, approved_body: same ? k.approved_body : null, stable_body: same ? k.stable_body : null }; }));
if (again.rules.length || again.unknown.length) throw new Error(`${T.groupName(g.name)} is now used by ${T.joinList(again.rules.concat(again.unknown).map(T.ruleLabel))}. Not deleted.`);
lastMutation.current = Date.now();
await API.deleteGroup(g.id);
setGroups((gs) => gs.filter((x) => x.id !== g.id));
refreshAudit();
toast("Group deleted", "warn");
},
});
}
// Rejects on failure so the application form stays open.
async function createApplication(input) {
lastMutation.current = Date.now();
let created;
try { created = await API.createApplication(input); }
catch (e) { toast(`Application not created: ${e.message}`, "bad"); throw e; }
if (!created || created.id == null) { const e = new Error("the Imara server did not return the new application"); toast(e.message, "bad"); throw e; }
// The Imara server creates every application with one approval; the
// choice made in the form is set with its own request.
const want = input.required_approvers || 1;
let warn = "";
if ((created.required_approvers || 1) !== want) {
try { const r = await API.setApplicationApprovals(created.id, want); created = { ...created, ...(r || {}), required_approvers: (r && r.required_approvers) || want }; }
catch (e) { warn = `Application created, but its approvals could not be set to ${want}: ${e.message}. New rules from it need 1 approval.`; }
}
const row = normalizeApp(created);
setApplications((a) => [row, ...a]);
refreshAudit();
toast(warn || "Application created", warn ? "warn" : "ok");
return row;
}
function deleteApplication(app) {
lastMutation.current = Date.now();
setConfirm({
title: `Delete application ${app.name}?`, confirmLabel: "Delete application", tone: "danger",
body: "Removes the application. Its groups, rules and servers are not changed.",
run: async () => {
lastMutation.current = Date.now();
await API.deleteApplication(app.id);
setApplications((a) => a.filter((x) => x.id !== app.id));
refreshAudit();
toast("Application deleted", "warn");
},
});
}
const PAGES = {
overview: ,
topology: openWizard(ini)} />,
servers: ,
groups: openWizard({ to: g.auto ? g.id : g.name })} formSignal={groupFormSignal} onProtect={setProtectGroup} openIntent={setIntent} openHost={setHost} />,
applications: openWizard({ application: app })} />,
intents: ,
audit: {
// Opens what an audit log entry is about, when it still exists in the loaded data.
if (kind === "intent") { const i = intents.find((x) => String(x.id) === String(id)); if (i) { setIntent(i); return true; } }
else if (kind === "agent") { const s = (window.MS_DATA.byId || {})[id]; if (s) { setHost(s); return true; } }
else if (kind === "alert") { const a = alerts.find((x) => String(x.id) === String(id)); if (a) { setAlert(a); return true; } }
return false;
}} />,
alerts: ,
detections: ,
};
const asOf = loadedAt ? new Date(loadedAt).toLocaleTimeString() : null;
return (
Cannot reach the Imara server. No live data loaded: every page is empty and nothing shown is current. Retrying every 30 seconds.
{window.IMARA_BOOT_ERROR && ({window.IMARA_BOOT_ERROR})}
)}
{!bootFailed && refreshErr && (
The latest refresh failed ({refreshErr}). Showing data from {asOf || "-"}.
)}
{!bootFailed && loadErrors.length > 0 && (
Some data failed to load and may be empty or out of date: {loadErrors.join(" · ")}
)}
{TITLES[view]}
{PURPOSES[view]}{view === "overview" && asOf ? ` Data from ${asOf}.` : ""}
{who.slice(0, 2).toUpperCase()}
{who}
{me.authenticated && operator}
{me.authenticated && }
{PAGES[view]} setHost(null)}>
setHost(null)} toast={toast} onAudit={refreshAudit} onRefresh={refreshAll} onProtect={(g) => { setHost(null); setProtectGroup(g); }}
onProtectServer={(s) => {
// Its one-server group is made only when the request is sent.
const T = window.ImaraTerms;
const g = T.serverGroupFor(s, groups);
setHost(null);
setProtectGroup(g || { id: null, name: T.serverGroupName(s, groups), selector: T.serverSelector(s), description: `Only the server ${s.hostname}`, pending: true, server: s });
}} />
setAlert(null)}>
setAlert(null)} onStatus={alertStatus} onOpenHost={(s) => { setAlert(null); setHost(s); }} />
setIntent(null)}>
setIntent(null)} onAction={intentAction} toast={toast} onEdit={(it) => openWizard({ edit: it })} />
setCmdk(false)} nav={nav} onNewIntent={() => openWizard(null)}
onNewGroup={() => { nav("groups"); setGroupFormSignal((n) => n + 1); }} openHost={setHost} openRule={(i) => ruleOps.open(i)} />
setWizard(null)}>
setWizard(null)} toast={toast} initial={wizard?.initial} ops={ruleOps} />
setProtectGroup(null)}>
setProtectGroup(null)} toast={toast} ops={ruleOps} />
confirm.run(text)} onClose={() => setConfirm(null)}>{confirm?.body}
);
}
// Wait for the live bootstrap (window.__IMARA_BOOT__) to populate MS_DATA from
// the real backend before rendering. In mock mode the promise resolves instantly.
(window.__IMARA_BOOT__ || Promise.resolve()).then(() => {
ReactDOM.createRoot(document.getElementById("root")).render(
React.createElement(PageBoundary, { kind: "app" }, React.createElement(StoreProvider, null, React.createElement(App)))
);
});