// ============================================================================ // Imara — app shell: nav, topbar, command palette, routing // ============================================================================ // Pages, grouped by what people come to do. View ids stay as they were // (intents = Rules, topology = Traffic map, audit = Audit log, detections = // Suspicious behaviour). const NAV = [ { group: "Monitor", items: [ { id: "overview", label: "Overview", icon: "Gauge", purpose: "Fleet status and what needs attention." }, { id: "topology", label: "Traffic map", icon: "Network", purpose: "Observed connections and how rules treat them." }, { id: "alerts", label: "Alerts", icon: "Bell", alert: true, purpose: "Events on your servers that need review." }, ] }, { group: "Protect", items: [ { id: "intents", label: "Rules", icon: "Shield", purpose: "Allowed traffic: source, destination and services." }, { id: "groups", label: "Groups", icon: "Boxes", purpose: "Sites, subnets and custom groups, for rules and default deny." }, { id: "applications", label: "Applications", icon: "AppWindow", purpose: "Application tiers and the traffic allowed between them." }, ] }, { group: "Manage", items: [ { id: "servers", label: "Servers", icon: "LayoutGrid", purpose: "All servers running the Imara agent." }, { id: "detections", label: "Suspicious behaviour", icon: "ScanEye", purpose: "Behaviour checks and their sensitivity." }, { id: "audit", label: "Audit log", icon: "ScrollText", purpose: "Who did what, and when." }, ] }, ]; const TITLES = Object.fromEntries(NAV.flatMap((g) => g.items.map((i) => [i.id, i.label]))); const PURPOSES = Object.fromEntries(NAV.flatMap((g) => g.items.map((i) => [i.id, i.purpose]))); // ---- toasts ---- function Toasts({ items }) { return (
{items.map((t) => (
{t.msg}
))}
); } // ---- command palette ---- function CommandPalette({ open, onClose, nav, onNewIntent, onNewGroup, openHost, openRule }) { const [q, setQ] = useState(""); const [sel, setSel] = useState(0); const st = useStore(); const inputRef = useRef(null); useEffect(() => { if (open) { setQ(""); setSel(0); setTimeout(() => inputRef.current?.focus(), 30); } }, [open]); const D = window.MS_DATA; const cmds = useMemo(() => [ ...NAV.flatMap((g) => g.items.map((i) => ({ type: "Go to", label: i.label, icon: i.icon, run: () => nav(i.id) }))), { type: "Action", label: "New rule…", icon: "Plus", run: () => onNewIntent() }, { type: "Action", label: "New group…", icon: "Boxes", run: () => onNewGroup() }, { type: "Action", label: "Switch light / dark", icon: "SunMoon", run: () => st.set({ theme: st.theme === "dark" ? "light" : "dark" }) }, ...D.SERVERS.map((s) => ({ type: "Server", label: s.hostname, icon: "Server", run: () => openHost(s) })), ...(D.INTENTS || []).map((i) => ({ type: "Rule", label: window.ImaraTerms.ruleTitle(i), alt: i.name, icon: "Shield", run: () => openRule(i) })), ], [st.theme, open]); // The first 60 matches; on a large fleet the rest come by typing more. const matches = cmds.filter((c) => (c.label + " " + (c.alt || "")).toLowerCase().includes(q.toLowerCase())); const filtered = matches.slice(0, 60); useEffect(() => { if (!open) return; const h = (e) => { if (e.key === "ArrowDown") { e.preventDefault(); setSel((s) => Math.min(filtered.length - 1, s + 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setSel((s) => Math.max(0, s - 1)); } else if (e.key === "Enter") { e.preventDefault(); filtered[sel]?.run(); onClose(); } else if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", h); return () => window.removeEventListener("keydown", h); }, [open, filtered, sel, onClose]); if (!open) return null; return (
e.stopPropagation()}> { setQ(e.target.value); setSel(0); }} />
{filtered.map((c, i) => (
setSel(i)} onClick={() => { c.run(); onClose(); }}> {c.label} {c.type}
))} {filtered.length === 0 &&
Nothing matches.
} {matches.length > filtered.length &&
{(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 (
Sign in to Imara
{full ? "Sign in to use the console." : mode === "expired" ? "Session expired. Sign in again to refresh the data; until then the console shows the last data it loaded." : "Session expired. The last request was not carried out; nothing changed. Sign in and try again."}
Username
setUser(e.target.value)} autoFocus style={{ width: "100%", marginBottom: 12 }} />
Password
setPw(e.target.value)} style={{ width: "100%", marginBottom: 14 }} /> {err &&
{err}
}
{!full && }
); } // ---- 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}
{actions}
); const reload = ; if (kind === "panel") return (
{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 (
setCmdk(true)} alertCount={openCount} loadedAt={loadedAt} />
{bootFailed && (
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))) ); });