// ============================================================================ // Imara — governance pages: Intents, Alerts, Groups, Applications, Audit // ============================================================================ function PageHead({ children, actions }) { return
{children}
{actions}
; } function SearchBox({ value, onChange, placeholder, width = 320 }) { return (
onChange(e.target.value)} />
); } // One line per fact about a rule's state (terms.js ruleFacts), coloured by tone. function RuleFacts({ intent, compact, max }) { const byId = window.MS_DATA.byId || {}; const facts = window.ImaraTerms.ruleFacts(intent, { targets: intent.targets, byId, hostName: (id) => (byId[id] && byId[id].hostname) || id }); const color = { ok: "var(--ok)", accent: "var(--accent)", warn: "var(--warn)", bad: "var(--bad)", muted: "var(--text-faint)" }; // In a list row: the most serious facts first, one line each; the rest // are in the rule's panel. const rank = { bad: 0, warn: 1, accent: 2, ok: 3, muted: 4 }; const shown = max ? facts.slice().sort((a, b) => (rank[a.tone] ?? 5) - (rank[b.tone] ?? 5)).slice(0, max) : facts; return (
{shown.map((f, k) => ( {f.text} ))} {max && facts.length > max && and {facts.length - max} more}
); } // Filters for the rules list. A rule can match several (version 8 active, // version 9 waiting for approval). const RULE_FILTERS = [ { id: "all", label: "All" }, { id: "active", label: "Active", test: (i) => window.ImaraTerms.confirmedTargets(i, window.MS_DATA.byId || {}) > 0 }, { id: "waiting", label: "Waiting for approval", test: (i) => i.approval_state === "requested" }, { id: "drafts", label: "Drafts", test: (i) => i.approval_state === "draft" || i.approval_state === "rejected" }, { id: "problems", label: "Problems", test: (i) => i.status === "error" || (i.push_errors || []).length > 0 || i.approval_state === "rolled_back" || (i.canary_state === "rolled_back" && !["draft", "requested", "rejected"].includes(i.approval_state)) }, ]; // ---------- RULES (intents in the API) ---------- function IntentsPage({ intents, openIntent }) { const D = window.MS_DATA; const T = window.ImaraTerms; const ctx = { groups: D.GROUPS || [], servers: D.SERVERS, intents }; // Opened from the Overview with a filter chosen. const [filter, setFilter] = useState(() => { const v = IntentsPage.openWith || "all"; IntentsPage.openWith = null; return v; }); const [q, setQ] = useState(""); // A row describes the approved version when one exists, and says what a // newer draft would change (D5: "asks for" and "in force" never mixed). // When the approved text did not load, the row says so and never shows // the draft in its place. Worked out when the data changes, not on every // key press. const described = useMemo(() => intents.map((i) => { const v = Number(i.version || 0), av = Number(i.approved_version || 0); const newer = av > 0 && v > av; const approvedText = newer ? T.bodyOfVersion(i, av) : i.body; const missing = newer && approvedText == null; const rule = T.parseRule(missing ? "" : approvedText || ""); const d = missing ? { text: `Approved version ${av} could not be loaded. Retrying on the next refresh.`, lines: [] } : T.describeRule(rule, ctx); const draft = newer ? T.parseRule(i.body) : null; return { i, rule, d, missing, draft: draft && draft.ok ? { v, d: T.describeRule(draft, ctx) } : null }; }), [intents, D.GROUPS, D.SERVERS]); const counts = useMemo(() => Object.fromEntries(RULE_FILTERS.map((f) => [f.id, f.test ? intents.filter(f.test).length : intents.length])), [intents, D.byId]); const active = RULE_FILTERS.find((f) => f.id === filter) || RULE_FILTERS[0]; const needle = q.trim().toLowerCase(); const filtered = described.filter(({ i, rule, d, draft }) => (!active.test || active.test(i)) && (!needle || [i.name, rule.ok ? rule.description : "", d.text, draft ? draft.d.text : ""].join(" ").toLowerCase().includes(needle))); const pg = usePaged(filtered, 25, filter + "|" + q); return (
{RULE_FILTERS.map((f) => ( ))}
{pg.total > 0 && (
RuleStateDestinationChanged
{pg.slice.map(({ i, rule, d, draft, missing }) => { const members = i.target_agent_id ? [D.byId[i.target_agent_id]].filter(Boolean) : T.groupMembers(i.target_selector || {}, D.SERVERS); const bad = T.unusableGroupRefs(i, { groups: D.GROUPS || [], servers: D.SERVERS }); const stuck = bad.filter((b) => b.stuck), none = bad.filter((b) => !b.stuck); const exposed = T.internetExposure(rule); // Short tags; the full sentences are in the rule's panel. const tags = []; if (stuck.length) tags.push({ tone: "bad", icon: "OctagonAlert", text: "its servers get no changes", title: `Its servers get no firewall changes: ${T.joinList(stuck.map((b) => `${T.groupName(b.group)} (${b.why})`))}.` }); if (none.length) tags.push({ tone: "warn", icon: "CircleOff", text: "applies to no server now", title: `Applies to no server now: ${T.joinList(none.map((b) => `${T.groupName(b.group)} (${b.why})`))}.` }); if (d.protection) tags.push({ tone: "warn", icon: "ShieldAlert", text: d.protection === "outgoing" ? "turns on outbound default deny" : "turns on default deny", title: d.lines[0] || "" }); if (exposed.length) tags.push({ tone: "warn", icon: "Globe", text: `${T.servicesText(exposed)} open to any address`, title: "Any address includes the internet." }); if (rule.ok && rule.denyPorts.length) tags.push({ tone: "warn", icon: "Ban", text: `always blocks ${T.servicesText(rule.denyPorts)}`, title: "Blocked even where another rule allows it." }); if (rule.ok && rule.expires) tags.push(rule.expires < T.serverToday() ? { tone: "text-muted", icon: "CalendarX", text: `ended ${rule.expires}`, title: "Expired: taken off its servers." } : { tone: "text-muted", icon: "CalendarClock", text: `ends ${rule.expires}`, title: "At the end of that day, UTC." }); if (rule.ok && (rule.identityUser || rule.binarySha)) tags.push({ tone: "text-muted", icon: "User", text: rule.identityUser ? `only for user ${rule.identityUser}` : "only for one program", title: "Applies only to connections made by that user or program." }); if (draft) tags.push({ tone: "accent", icon: "FilePen", text: `version ${draft.v}: ${i.approval_state === "requested" ? "waiting for approval" : i.approval_state === "rejected" ? "changes requested" : "draft"}`, title: `Version ${draft.v} ${missing ? "asks for" : "would change it to"}: ${draft.d.text}` }); return ( ); })}
)} {pg.total === 0 && (intents.length ? : D.LOAD_FAILED && D.LOAD_FAILED.INTENTS ? : )}
); } // ---------- ALERTS ---------- function AlertsPage({ alerts, openAlert, onStatus }) { const D = window.MS_DATA; const T = window.ImaraTerms; const [sev, setSev] = useState("all"); // Opened from the Overview with a state chosen. const [status, setStatus] = useState(() => { const v = AlertsPage.openWith || "open"; AlertsPage.openWith = null; return v; }); const [q, setQ] = useState(""); const open = alerts.filter((a) => a.status === "open"); // The Imara server's true counts; the list holds the newest 500 open alerts. const trueOpen = D.ALERT_OPEN_COUNTS; const oc = trueOpen ? { critical: trueOpen.critical || 0, warn: trueOpen.warn || 0, info: trueOpen.info || 0 } : { critical: open.filter((a) => a.severity === "critical").length, warn: open.filter((a) => a.severity === "warn").length, info: open.filter((a) => a.severity === "info").length }; const openTotal = oc.critical + oc.warn + oc.info; const needle = q.trim().toLowerCase(); // Critical first, then warning, then info; newest first within each. const sevRank = { critical: 0, warn: 1, info: 2 }; const filtered = alerts.filter((a) => (sev === "all" || a.severity === sev) && (status === "all" || a.status === status) && (!needle || [T.alertName(a.kind), a.title, a.server_id ? (D.byId[a.server_id] || {}).hostname : ""].join(" ").toLowerCase().includes(needle))) .sort((a, b) => (sevRank[a.severity] ?? 3) - (sevRank[b.severity] ?? 3) || (String(b.created_at) > String(a.created_at) ? 1 : String(b.created_at) < String(a.created_at) ? -1 : 0)); const pg = usePaged(filtered, 25, [sev, status, q].join("|")); const STATES = [["open", "Open"], ["acknowledged", "Acknowledged"], ["resolved", "Resolved"], ["all", "All"]]; return (
{/* the tiles are the severity filter: click one, click again for all */} setSev((x) => (x === "critical" ? "all" : "critical"))} trend={oc.critical ? { tone: "bad", text: "needs action" } : undefined} foot={sev === "critical" ? "shown below · click for all" : "tampering, serious behaviour"} /> setSev((x) => (x === "warn" ? "all" : "warn"))} foot={sev === "warn" ? "shown below · click for all" : "review when possible"} /> setSev((x) => (x === "info" ? "all" : "info"))} foot={sev === "info" ? "shown below · click for all" : "many resolve on their own"} />
{(() => { const ack = alerts.filter((a) => a.status === "acknowledged").length; return ack ?
{ack}{D.ALERT_ACK_PARTIAL ? "+" : ""} acknowledged alert{ack === 1 && !D.ALERT_ACK_PARTIAL ? " is" : "s are"} not counted above.While an alert is acknowledged, no new alert of that kind is raised for that server. Resolve it once dealt with.
: null; })()} {(status === "resolved" || status === "all") &&
Only the newest 100 alerts are loaded; older resolved ones are not listed.
} {D.ALERT_OPEN_LOADED != null && openTotal > D.ALERT_OPEN_LOADED &&
Showing the newest {D.ALERT_OPEN_LOADED.toLocaleString("en-US")} of {openTotal.toLocaleString("en-US")} open alerts (the counts above are complete). Resolve or acknowledge alerts to see older ones.
} }>
{STATES.map(([f, l]) => )}
{status === "all" && } {pg.slice.map((a) => ( openAlert(a)}> {status === "all" && } ))} {pg.total === 0 && }
SeverityAlertServerStatusRaised
{a.severity === "warn" ? "warning" : a.severity} {T.alertName(a.kind)}{a.details && Array.isArray(a.details.also_flagged) && a.details.also_flagged.length ? (+{a.details.also_flagged.length} more) : null} {a.server_id ? (D.byId[a.server_id] || {}).hostname || a.server_id : "all servers"}{a.status === "false_positive" ? "false positive" : a.status}{agoLong(a.created_at)} e.stopPropagation()}> {(a.status === "open" || a.status === "acknowledged") && {a.status === "open" && } }
); } // ---------- GROUPS ---------- // A group is a set of servers chosen by their labels. The API can create and // delete groups but not change them, so the form gets a group right first // time: plain conditions, the live member list, a required description, and // names checked before anything is sent. // One condition as a chip: "tier: DB", "role: DB". function CondChip({ k, v, onRemove }) { const T = window.ImaraTerms; return ( {T.labelText(k, v)} {onRemove && } ); } // A short member list: each server, whether it reports to Imara, and // whether it is matched as a source (only approved IP addresses are). function GroupMembers({ members, limit = 8 }) { const T = window.ImaraTerms; if (!members.length) return
No server matches these conditions now.
; // When none has an approved address, the note below says so once. const someMissing = members.some((m) => T.hasApprovedAddress(m)) && members.some((m) => !T.hasApprovedAddress(m)); return (
{members.slice(0, limit).map((s) => (
{s.hostname} {s.fresh !== "live" && {T.freshnessText(s.fresh)}} {someMissing && !T.hasApprovedAddress(s) && no approved address: left out as a source}
))} {members.length > limit && and {members.length - limit} more}
); } // What servers without approved IP addresses mean for rules. As a source // they are not matched; rules sent to the group can still be applied on // them (the server skips its address check when it has no approved address). function GroupUsability({ members }) { const T = window.ImaraTerms; if (!members.length) return null; const missing = members.filter((s) => !T.hasApprovedAddress(s)).length; if (missing === members.length) { return
Rules cannot use this group yet: none of its servers has an approved IP address. Approve addresses on each server's page.
; } if (missing) { return
{T.plural(missing, "of its servers has", "of its servers have")} no approved IP address yet: rules do not cover {missing === 1 ? "it" : "them"} as a source.Rules allowing traffic from this group include approved addresses only, so traffic from {missing === 1 ? "that server" : "those servers"} is not allowed by them. Rules with this group as destination may still apply on {missing === 1 ? "it" : "them"}. Approve addresses on each server's page.
; } return null; } function GroupForm({ open, onClose, onCreate, groups = [], initial }) { const T = window.ImaraTerms; const D = window.MS_DATA; // Each site and subnet is already a group (automatic), so this form is // for groups people decide on, from what each server's name says (CMP-- // -): the site (hostname ENV) and the role (hostname // ROLE). Networks and the labels set when Imara was installed wait under // Other labels. When the names do not follow that pattern: site, tier and // application labels instead. const { choices, dupes } = useMemo(() => { const all = T.labelChoices(D.SERVERS); const has = (k) => all.some((c) => c.key === k); const named = has("_hn_part_1") && T.configureHostnames(D.SERVERS); const first = named ? ["_hn_part_1", "_hn_part_2"] : ["site", "tier", "app"]; const main = first.map((k) => all.find((c) => c.key === k)).filter(Boolean); return main.length ? { choices: main, dupes: all.filter((c) => !main.includes(c)) } : { choices: all, dupes: [] }; }, [open]); const [moreLabels, setMoreLabels] = useState(false); const [name, setName] = useState(""); const [nameTouched, setNameTouched] = useState(false); const [desc, setDesc] = useState(""); const [descTouched, setDescTouched] = useState(false); const [sel, setSel] = useState({}); const [vq, setVq] = useState({}); // filters for long value lists const [saving, setSaving] = useState(false); const [err, setErr] = useState(""); useEffect(() => { if (!open) return; const ini = initial || {}; setSel(ini.selector ? { ...ini.selector } : {}); setName(ini.name || ""); setNameTouched(!!ini.name); setDesc(""); setDescTouched(false); setSaving(false); setErr(""); setVq({}); setMoreLabels(!!ini.selector && Object.keys(ini.selector).some((k) => dupes.some((c) => c.key === k))); }, [open]); const hasConds = Object.keys(sel).length > 0; const effName = nameTouched ? name : (hasConds ? T.suggestGroupName(sel, groups) : ""); // Filled in from the picks until changed: "APP servers in DC". const autoDesc = (() => { if (!hasConds) return ""; const up = (v) => String(v).toUpperCase(); const site = sel._hn_part_1 || sel.site, role = sel._hn_part_2 || sel.tier, net = sel._subnet_24 || sel._subnet_16; const rest = Object.keys(sel).filter((k) => !["_hn_part_1", "site", "_hn_part_2", "tier", "_subnet_24", "_subnet_16"].includes(k)); if (rest.length || (!site && !role && !net)) { const t = T.selectorText(sel); return t.charAt(0).toUpperCase() + t.slice(1); } return [role ? `${up(role)} servers` : site ? "All servers" : "Servers", site && `in ${up(site)}`, net && `with main address in ${net}`].filter(Boolean).join(" "); })(); const effDesc = descTouched ? desc : autoDesc; const nameProblem = T.groupNameProblem(effName, groups); const members = T.membersOf(sel, D.SERVERS); const dup = hasConds ? groups.find((g) => T.sameSelector(g.selector, sel)) : null; const canCreate = !saving && hasConds && !nameProblem && effDesc.trim(); // Closes only after the server created the group; on failure the form // stays open with the server's reason. const submit = async () => { setSaving(true); setErr(""); try { await onCreate({ name: effName.trim(), description: effDesc.trim(), selector: sel }); onClose(); } catch (e) { setErr((e && e.message) || "create failed"); } finally { setSaving(false); } }; const toggle = (k, v) => setSel((cur) => { const n = { ...cur }; if (n[k] === v) delete n[k]; else n[k] = v; return n; }); // One label: its name, then its values to pick (with many values, such as // networks or applications, a filter and the first 12). const condRow = (c) => { const many = c.values.length > 12; const needle = String(vq[c.key] || "").trim().toLowerCase(); const matches = needle ? c.values.filter((v) => String(v.value).toLowerCase().includes(needle)) : c.values; let shown = many ? matches.slice(0, 12) : matches; const chosenV = c.values.find((v) => sel[c.key] === v.value); if (chosenV && shown.indexOf(chosenV) < 0) shown = [chosenV].concat(shown); return (
{c.name}
{many && setVq((m) => ({ ...m, [c.key]: e.target.value }))} />} {shown.map((v) => ( ))} {many && matches.length > 12 && and {(matches.length - 12).toLocaleString("en-US")} more} {many && needle && !matches.length && none match}
); }; return (
new group
{effName ? T.groupName(effName) : "Untitled group"}
Sites and subnets are automatic. Create a group for an application, tier or other combination, such as the APP servers in DC. A group cannot be renamed or changed later. A set of servers chosen by labels, for example all database servers. Rules allow traffic between groups. Membership updates automatically when labels change. Site and subnet groups are saved when a rule first uses them.
Conditions

A server is a member when it matches every condition.

Site and role come from the hostname. Network comes from the main address (default route), so a network group holds only servers with the agent whose main address is in it, not every device on that network. Other labels are set at install.

Values show in capitals but match exactly, including lower case.

{choices.map((c) => condRow(c))}
{dupes.length > 0 && (
)} {dupes.length > 0 && moreLabels &&
{dupes.map((c) => condRow(c))}
} {!choices.length &&
No server reports any labels yet.
}
{hasConds && (
{Object.entries(sel).map(([k, v]) => toggle(k, v)} />)} · {T.plural(members.length, "server")} now
)} {dup &&
{T.groupName(dup.name)} already has these exact conditions. Create this only to replace {T.groupName(dup.name)}: write rules for the new group, then delete the old rules and {T.groupName(dup.name)}.
} { setDescTouched(true); setDesc(e.target.value); }} /> { setNameTouched(true); setName(e.target.value.toLowerCase()); }} /> {err &&
Group not created: {err}
}
); } // Default deny of some servers as counts: a thin bar (on, off, waiting, // unknown) and "120 / 150 default deny on", with the rest named, never one // colour for the whole set. const PROT_PARTS = [["on", "var(--ok)", "on"], ["off", "var(--warn)", "off"], ["waiting", "var(--accent)", "not confirmed yet"], ["unknown", "var(--text-faint)", "unknown"]]; function ProtCounts({ counts }) { if (!counts || !counts.total) return No servers; const rest = PROT_PARTS.slice(1).filter(([k]) => counts[k]).map(([k, , w]) => `${counts[k].toLocaleString("en-US")} ${w}`); return (
`${counts[k] || 0} ${w}`).join(" · ")}>
{PROT_PARTS.map(([k, c]) => counts[k] ? : null)}
{counts.on.toLocaleString("en-US")} / {counts.total.toLocaleString("en-US")} default deny on{rest.length ? " · " + rest.join(" · ") : ""}
); } // One selection's details in a side panel: its servers, their incoming // protection, the approved rules that let connections in to them, and the // two actions. The selection is a stored group or an automatic one (a site // or a subnet), stored only when a rule or request first uses it; opening // this panel creates nothing. function GroupDrawer({ g, title, intents, onClose, onAuthorIntent, onProtect, onDelete, openIntent, openHost }) { const D = window.MS_DATA, T = window.ImaraTerms; const [only, setOnly] = useState(""); // a protection state the server list is narrowed to const [sq, setSq] = useState(""); // server search useEffect(() => { setOnly(""); setSq(""); }, [g && (g.id || g.name)]); const sel = (g && g.selector) || {}; const members = useMemo(() => (g ? T.groupMembers(sel, D.SERVERS) : []), [g, D.SERVERS]); const ctxP = { intents: D.INTENTS, groups: D.GROUPS, servers: D.SERVERS, incomplete: D.POLICY_INCOMPLETE }; // Worked out again every minute too, so a report that grew too old reads "unknown". const minute = Math.floor(T.serverNow() / 60000); const gp = useMemo(() => (members.length ? T.groupProtection(members, ctxP) : null), [members, D.INTENTS, D.GROUPS, D.FW_VERSION, D.POLICY_INCOMPLETE, minute]); // Approved incoming rules that apply to any of these servers (not only // rules that name this group): how many they apply to, and confirm. const inbound = useMemo(() => { if (!members.length) return []; const out = []; (D.INTENTS || []).forEach((i) => { let applies = 0, confirmed = 0, rule = null; members.forEach((s) => { if (!T.targetsServer(i, s)) return; const want = T.expectedVersion(i, s.agent_id); if (!want) return; if (!rule) rule = T.parseRule(T.bodyOfVersion(i, want) || ""); if (!rule.ok || rule.direction !== "ingress") return; applies++; const row = T.targetRow(i, s.agent_id); if (row && row.status === "applied" && Number(row.version) === want) confirmed++; }); if (applies) out.push({ it: i, rule, applies, confirmed }); }); return out.sort((a, b) => b.applies - a.applies); }, [members, D.INTENTS]); // Every hook runs before the panel may return nothing. const stateMap = useMemo(() => new Map(gp ? gp.per.map((p) => [p.server, p.state]) : []), [gp]); const stateOf = (s) => stateMap.get(s) || "unknown"; const words = sq.trim().toLowerCase().split(/\s+/).filter(Boolean); const listed = members.filter((s) => (!only || stateOf(s) === only) && (!words.length || words.every((w) => [s.hostname, ...(s.approved_ips || [])].join(" ").toLowerCase().includes(w)))); const pg = usePaged(listed, 50, only + "|" + sq); if (!g) return null; const empty = T.isEmptySelector(sel); const usable = !empty && members.some(T.hasApprovedAddress); const use = g.auto ? { rules: [], unknown: [] } : T.rulesUsingGroup(g.name, intents); const incomplete = !!D.POLICY_INCOMPLETE; const inUse = !g.auto && (use.rules.length > 0 || use.unknown.length > 0 || incomplete); const allOn = gp && gp.state === "on"; return (
{title || T.groupName(g.name)}
{T.plural(members.length, "server")} · {empty ? "no conditions: every server" : T.selectorText(sel).replace(/^servers with /, "")} Membership updates automatically as labels change.{g.auto ? " Saved as a group when a rule or request first uses it." : ""}
{allOn ? Default deny on : onProtect && !empty && usable && } {!usable && !empty && members.length > 0 && None of these servers has an approved IP address yet.}
Default denyFrom each server's latest firewall report. On: inbound traffic that no rule allows is blocked.
{gp ? (
{PROT_PARTS.filter(([k]) => gp.counts[k]).map(([k, c, w]) => ( ))}
) : No servers.}
Approved inbound rules ({inbound.length})
{incomplete &&
Some rules could not be checked.
} {gp && gp.counts.off > 0 &&
Default deny is off on {T.plural(gp.counts.off, "server")}: traffic may pass there without an allow rule.
} {inbound.length ? (
{inbound.slice(0, 50).map((x) => ( openIntent && openIntent(x.it)}> ))}
RuleSourceServicesApplies toConfirmed
{T.ruleTitle(x.it)} {x.rule.from && x.rule.from.group ? (T.BUILTIN_GROUPS[x.rule.from.group] ? T.BUILTIN_GROUPS[x.rule.from.group].title : T.groupName(x.rule.from.group)) : "-"} {x.rule.allow.length ? T.servicesText(x.rule.allow) : (x.rule.denyAll ? "blocks the rest" : "-")} {x.applies.toLocaleString("en-US")} of {members.length.toLocaleString("en-US")} {x.confirmed.toLocaleString("en-US")} of {x.applies.toLocaleString("en-US")}
) :
No approved inbound rules.
} {inbound.length > 50 &&
Showing the 50 that apply to the most servers.
}
Servers ({listed.length.toLocaleString("en-US")}{only || sq ? ` of ${members.length.toLocaleString("en-US")}` : ""}) {members.length > 10 && }
{pg.slice.map((s) => { const st = stateOf(s); return ( openHost && openHost(s)}> ); })} {pg.total === 0 && }
ServerMain addressDefault denyAgent status
{s.hostname} {(s.approved_ips || [])[0] || not approved yet} {T.protectionWord(st)} {s.fresh === "live" ? online : {T.freshnessText(s.fresh)}}
{members.length ? "No matches." : "No servers in it right now."}
{!g.auto && onDelete && (
{use.rules.length ? `Used by ${T.joinList(use.rules.slice(0, 8).map(T.ruleLabel))}${use.rules.length > 8 ? ` and ${use.rules.length - 8} more` : ""}.` : "No rule uses this group."}{use.unknown.length > 0 ? ` ${T.plural(use.unknown.length, "rule")} could not be checked.` : ""}
)}
); } // Groups: sites first (DC and DR before the rest), then subnets, then the // groups people make (an application, a tier). Sites and subnets are groups // already (automatic): nothing is created until a rule or request uses one. function GroupsPage({ groups, intents = [], onCreate, onDelete, onAuthorIntent, formSignal, onProtect, openIntent, openHost }) { const D = window.MS_DATA; const T = window.ImaraTerms; const [q, setQ] = useState(""); const [openG, setOpenG] = useState(null); // {g, title} whose panel shows const [form, setForm] = useState(null); // null | {selector?, name?} const [more, setMore] = useState({}); // sections shown in full useEffect(() => { if (formSignal && formSignal !== GroupsPage.handled) { GroupsPage.handled = formSignal; setForm({}); } }, [formSignal]); const ctxP = { intents: D.INTENTS, groups: D.GROUPS, servers: D.SERVERS, incomplete: D.POLICY_INCOMPLETE }; const named = T.namesFollowPattern() && (D.SERVERS || []).some((s) => s.labels && s.labels._hn_part_1 != null); // Servers by site and subnet, once per load. const idx = useMemo(() => { const bySite = {}, byNet = {}; (D.SERVERS || []).forEach((s) => { const l = s.labels || {}; const site = l._hn_part_1, net = l._subnet_24; if (named && site) (bySite[site] = bySite[site] || []).push(s); if (net) (byNet[net] = byNet[net] || []).push(s); }); const ipKey = (n) => String(n).split(/[./]/).map((x) => String(Number(x) || 0).padStart(3, "0")).join("."); const siteRank = (x) => { const u = String(x).toUpperCase(); return u === "DC" ? 0 : u === "DR" ? 1 : 2; }; return { bySite, byNet, sites: Object.keys(bySite).sort((a, b) => siteRank(a) - siteRank(b) || a.localeCompare(b)), nets: Object.keys(byNet).sort((a, b) => (ipKey(a) < ipKey(b) ? -1 : 1)) }; }, [D.SERVERS, named]); const protMemo = useMemo(() => new Map(), [D.SERVERS, D.INTENTS, D.GROUPS, D.FW_VERSION, D.POLICY_INCOMPLETE, Math.floor(T.serverNow() / 60000)]); const counts = (members) => { if (!members || !members.length) return null; if (!protMemo.has(members)) protMemo.set(members, T.groupProtection(members, ctxP).counts); return protMemo.get(members); }; // The group behind a selection: the stored one with exactly these // conditions, or the automatic one. const groupFor = (sel) => groups.find((g) => T.sameSelector(g.selector || {}, sel)) || T.autoGroupFor(sel, groups); const show = (sel, title) => setOpenG({ g: groupFor(sel), title }); const up = (v) => String(v).toUpperCase(); // Shown above (a site or a subnet), so not among the custom groups. const isShown = (sel) => { const keys = Object.keys(sel || {}); return keys.length === 1 && (keys[0] === "_subnet_24" || (named && keys[0] === "_hn_part_1")); }; const words = q.trim().toLowerCase().split(/\s+/).filter(Boolean); const hit = (text) => !words.length || words.every((w) => String(text).toLowerCase().includes(w)); const sites = idx.sites.filter((x) => hit(x)); const nets = idx.nets.filter((n) => hit(n + " " + Array.from(new Set(idx.byNet[n].map((s) => (s.labels || {})._hn_part_1).filter(Boolean))).join(" "))); const stored = groups.filter((g) => !isShown(g.selector)); const custom = T.sortGroups(stored.filter((g) => !T.isServerGroup(g))).filter((g) => hit([g.name, g.description || "", T.selectorText(g.selector || {})].join(" "))); const single = stored.filter((g) => T.isServerGroup(g)).filter((g) => hit([g.name, (g.selector || {})._hostname || ""].join(" "))); const cap = (key, list, n) => (more[key] || words.length ? list : list.slice(0, n)); const moreBtn = (key, list, n, unit) => list.length > n && !words.length && ( ); const pgC = usePaged(more.custom || words.length ? custom : custom.slice(0, 5), 25, "c|" + q + "|" + !!more.custom); const pgN = usePaged(more.nets || words.length ? nets : nets.slice(0, 6), 25, "n|" + q + "|" + !!more.nets); const total = (D.SERVERS || []).length; return (
{D.FW_LOADING &&
Checking firewall reports…
} {total === 0 && } {named && idx.sites.length > 0 && (
Sites
{sites.length ? (
{cap("sites", sites, 4).map((site) => ( ))}
) :
No matches.
} {moreBtn("sites", sites, 4, "sites")}
)} {idx.nets.length > 0 && (
SubnetsServers with the agent whose main address (default route) is in the subnet. Not every device in it.
{pgN.total > 0 ? (
SubnetSitesServersDefault deny
{pgN.slice.map((n) => { const ms = idx.byNet[n]; const siteList = Array.from(new Set(ms.map((s) => (s.labels || {})._hn_part_1).filter(Boolean))).map(up); return ( ); })}
) :
No matches.
} {moreBtn("nets", nets, 6, "subnets")} {!!(more.nets || words.length) && }
)}
Custom groups ({custom.length.toLocaleString("en-US")})
{pgC.total > 0 ? (
NameConditionsServersDefault deny
{pgC.slice.map((g) => { const ms = T.groupMembers(g.selector || {}, D.SERVERS); return ( ); })}
) : (
{stored.length && words.length ? "No matches." : D.LOAD_FAILED && D.LOAD_FAILED.GROUPS ? "Groups could not be loaded. Retrying on the next refresh." : "No custom groups. Create one for any other combination of servers."}
)} {moreBtn("custom", custom, 5, "custom groups")} {!!(more.custom || words.length) && }
{single.length > 0 && (
{(more.single || words.length > 0) && (
{single.slice(0, 30).map((g) => )} {single.length > 30 && and {(single.length - 30).toLocaleString("en-US")} more: search above to find one}
)}
)} setOpenG(null)} onAuthorIntent={(g) => { setOpenG(null); onAuthorIntent(g); }} onProtect={onProtect ? (g) => { setOpenG(null); onProtect(g); } : null} onDelete={(g) => { setOpenG(null); onDelete(g); }} openIntent={openIntent ? (i) => { setOpenG(null); openIntent(i); } : null} openHost={openHost ? (s) => { setOpenG(null); openHost(s); } : null} /> setForm(null)} onCreate={onCreate} groups={groups} initial={form || {}} />
); } // ---------- APPLICATIONS ---------- // An application is a named bundle of tiers (each tier bound to one or more // host groups) plus a default approval threshold that intents authored under // it inherit. Backed by GET|POST /api/applications and // DELETE /api/applications/{id}. Shows only what the server returned: there is // no fallback to sample applications. function ApplicationsPage({ applications = [], groups = [], onCreate, onDelete, onNewIntent, openIntent }) { const D = window.MS_DATA; const T = window.ImaraTerms; const [creating, setCreating] = useState(false); const [q, setQ] = useState(""); const [open, setOpen] = useState(null); // id of the application whose parts show // One row per application (a bank may have hundreds), 20 to a page; the // parts and what may connect between them open in the row. const words = q.trim().toLowerCase().split(/\s+/).filter(Boolean); const groupById = useMemo(() => Object.fromEntries(groups.map((g) => [g.id, g])), [groups]); const shown = applications.filter((a) => { if (!words.length) return true; const text = [a.name, a.description, a.env].concat((a.tiers || []).flatMap((t) => [t.name].concat((t.group_ids || []).map((id) => (groupById[id] || {}).name)))).join(" ").toLowerCase(); return words.every((w) => text.includes(w)); }); const pg = usePaged(shown, 20, q); // Approved incoming rules as they stand: which group may reach which, on // which services. Used to show what is allowed between the parts. const allowed = useMemo(() => (D.INTENTS || []).filter((i) => Number(i.approved_version || 0) > 0 && i.approval_state !== "rolled_back").map((i) => { const r = T.parseRule(T.bodyOfVersion(i, Number(i.approved_version)) || ""); return r.ok && r.direction === "ingress" && r.from && r.to && r.from.group && r.to.group && r.allow.length ? { it: i, from: r.from.group, to: r.to.group, allow: r.allow } : null; }).filter(Boolean), [D.INTENTS]); const ctxP = { intents: D.INTENTS, groups: D.GROUPS, servers: D.SERVERS, incomplete: D.POLICY_INCOMPLETE }; const protTone = { on: "ok", off: "neutral", mixed: "warn", waiting: "accent", unknown: "warn", empty: "neutral" }; // What is allowed from one set of groups to another: the services and // the rules that allow them. const between = (fromNames, toNames) => { const list = allowed.filter((x) => (fromNames === "outside" ? (x.from === "world" || T.BUILTIN_GROUPS[x.from]) : fromNames.includes(x.from)) && toNames.includes(x.to)); const svc = Array.from(new Set(list.flatMap((x) => x.allow))); return { list, svc }; }; // An application's parts, servers and rules; worked out for the rows on // the page only. const summarize = (app) => { const parts = (app.tiers || []).map((t) => { const tg = (t.group_ids || []).map((id) => groupById[id]).filter(Boolean); const seen = new Set(), members = []; tg.forEach((g) => T.groupMembers(g.selector || {}, D.SERVERS).forEach((m) => { if (!seen.has(m.agent_id)) { seen.add(m.agent_id); members.push(m); } })); const fresh = { live: 0, stale: 0, silent: 0 }; members.forEach((m) => { fresh[m.fresh] = (fresh[m.fresh] || 0) + 1; }); return { name: String(t.name || "").toUpperCase(), groups: tg, names: tg.map((g) => g.name), members, fresh }; }); const all = []; const ids = new Set(); parts.forEach((p) => p.members.forEach((m) => { if (!ids.has(m.agent_id)) { ids.add(m.agent_id); all.push(m); } })); const links = parts.slice(1).map((p, k) => between(parts[k].names, p.names)); const entry = parts.length ? between("outside", parts[0].names) : null; const ruleCount = new Set(links.flatMap((l) => l.list.map((x) => x.it.id)).concat(entry ? entry.list.map((x) => x.it.id) : [])).size; const delayed = all.filter((m) => m.fresh === "stale").length, silent = all.filter((m) => m.fresh === "silent").length; const gp = all.length ? T.groupProtection(all, ctxP) : null; return { parts, all, links, entry, ruleCount, delayed, silent, gp }; }; const Link = ({ link, outside }) => (
{link.list.length ? : {outside ? "nothing from outside" : "no rule"}}
); return (
setCreating(true)}> New application}> {applications.length === 0 ? (
{D.LOAD_FAILED && D.LOAD_FAILED.APPLICATIONS ? "The applications could not be loaded" : "No applications yet"}
An application lists a system's tiers in order, for example web, app, database, and shows what each tier may reach. Create the groups first, then add them here.
) : ( {pg.total > 0 && (
ApplicationTiers, in orderServersDefault denyFrom each server's latest firewall report. On: inbound traffic that no rule allows is blocked.RulesApproved rules that allow each tier to the next, or outside traffic to the first tier.
{pg.slice.map((app) => { const sm = summarize(app); const isOpen = open === app.id; return ( {isOpen && (
{sm.entry && } {sm.parts.map((p, k) => ( {k > 0 && }
{p.name || "TIER " + (k + 1)} {p.members.length > 0 && (() => { const gp = T.groupProtection(p.members, ctxP); return {T.protectionWord(gp.state)}{gp.state === "mixed" ? ` ${gp.counts.on}/${gp.counts.total}` : ""}; })()}
{p.members.length}server{p.members.length === 1 ? "" : "s"}
{(p.fresh.stale > 0 || p.fresh.silent > 0) &&
{[p.fresh.stale && `${p.fresh.stale} delayed`, p.fresh.silent && `${p.fresh.silent} offline`].filter(Boolean).join(" · ")}
}
{p.groups.slice(0, 3).map((g) => {T.groupName(g.name)})} {p.groups.length > 3 && and {p.groups.length - 3} more} {p.groups.length === 0 && no group yet}
))} {sm.parts.length === 0 && No tiers yet.}
{onNewIntent && } New rules from here: {(app.required_approvers || 1) >= 2 ? "2 approvals needed" : "1 approval needed (can be you)"} The default for rules started from this application. Each rule keeps its own setting. {onDelete && }
)}
); })}
)} {pg.total === 0 && }
)} setCreating(false)} groups={groups} onCreate={onCreate} />
); } function NewApplicationModal({ open, onClose, groups: groupsIn, onCreate }) { const groups = useMemo(() => window.ImaraTerms.sortGroups(groupsIn || []), [groupsIn]); const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [approvers, setApprovers] = useState(2); const [tiers, setTiers] = useState([{ name: "web", group_ids: [] }]); const [saving, setSaving] = useState(false); const [err, setErr] = useState(""); // With many groups: one search for every part, and the first matches. const [gq, setGq] = useState(""); useEffect(() => { if (open) { setName(""); setDescription(""); setApprovers(2); setTiers([{ name: "web", group_ids: [] }]); setSaving(false); setErr(""); } }, [open]); if (!open) return null; const canSave = name.trim() && tiers.every((t) => t.name.trim()); function setTier(i, patch) { setTiers((ts) => ts.map((t, j) => j === i ? { ...t, ...patch } : t)); } function toggleGroup(i, gid) { setTiers((ts) => ts.map((t, j) => j === i ? { ...t, group_ids: t.group_ids.includes(gid) ? t.group_ids.filter((x) => x !== gid) : [...t.group_ids, gid] } : t)); } // Closes only after the server created the application. async function save() { setSaving(true); setErr(""); try { await onCreate({ name: name.trim(), description: description.trim(), env: "", scope: [], required_approvers: approvers, tiers: tiers.map((t) => ({ name: t.name.trim(), group_ids: t.group_ids })) }); onClose(); } catch (e) { setErr((e && e.message) || "create failed"); } finally { setSaving(false); } } return (
new application
{name || "Untitled application"}
setName(e.target.value)} autoFocus />
{[[2, "2 approvals"], [1, "1 approval (can be you)"]].map(([n, l]) => )}
setDescription(e.target.value)} />
Tiers (for example web, app, database)
{groups.length > 24 &&
}
{tiers.map((t, i) => (
setTier(i, { name: e.target.value })} /> {tiers.length > 1 && }
{groups.length ? (() => { const needle = gq.trim().toLowerCase(); const matches = groups.filter((g) => !needle || [g.name, g.description || ""].join(" ").toLowerCase().includes(needle)); const shown = matches.slice(0, 24); return groups.filter((g) => t.group_ids.includes(g.id) && shown.indexOf(g) < 0).concat(shown); })().map((g) => ( )) : No groups yet: create them on the Groups page first.} {groups.length > 24 && Showing up to 24 groups; search above for others.}
))}
{err &&
Application not created: {err}
}
); } // ---------- AUDIT LOG ---------- // Icons for the action codes the Imara server writes. const ACTION_ICON = { "intent.create": "Plus", "intent.created": "Plus", "intent.update": "Pencil", "intent.updated": "Pencil", "intent.edited": "Pencil", "intent.approved": "Check", "intent.auto_approved": "Check", "intent.approve_signoff": "UserCheck", "intent.approve": "UserCheck", "intent.approval_requested": "Send", "intent.request_approval": "Send", "intent.approval_policy": "UsersRound", "intent.pushed": "Send", "intent.repushed_on_registration": "Send", "intent.auto_restored": "RotateCcw", "intent.rejected": "X", "intent.reject": "X", "intent.canary_started": "Bird", "intent.canary_promoted": "ChevronsUp", "intent.canary_rolled_back": "Undo2", "intent.rollback": "Undo2", "intent.rollback_to_bundle": "Undo2", "intent.revoked": "Trash2", "intent.deleted": "Trash2", "intent.delete": "Trash2", "alert.acknowledged": "Eye", "alert.resolved": "CheckCircle2", "alert.open": "Bell", "alert.false_positive": "EyeOff", "group.create": "Boxes", "group.delete": "Trash2", "application.create": "AppWindow", "application.update": "Pencil", "application.delete": "Trash2", "application.approval_policy": "UsersRound", "role.create": "GitFork", "role.update": "Pencil", "role.delete": "Trash2", "detector.update": "ScanEye", "agent_cert.revoke": "ShieldOff", "agent_cert.revoke_agent": "ShieldOff", "agent_cert.renew": "RefreshCw", "agent_cert.issue": "Server", "auth.login": "LogIn", "auth.logout": "LogOut", "auth.login_failed": "ShieldAlert", }; // Colour by what the action does: creates and approvals green, changes // accent, deletes, withdrawals, refusals and failures red, else muted. function actionTone(action) { if (/(revoke|reject|delete|rolled_back|rollback|failed)/.test(action)) return "var(--bad)"; if (/(approve|approved|signoff|create|applied|resolved|acknowledg|issue)/.test(action)) return "var(--ok)"; if (/(pushed|request|promote|update|restored|policy|edited)/.test(action)) return "var(--accent)"; return "var(--text-muted)"; } // The API layer also records each request beside the action itself; these // codes repeat an entry with another code and are hidden unless asked for. // The Imara server writes these twice (once for the request, once for the // change itself); the second entry says more, so the first is hidden. const REPEATED_ACTIONS = ["intent.create", "intent.delete", "intent.approve", "intent.request_approval", "intent.reject", "intent.canary_promote", "intent.canary_rollback", "intent.rollback_to_bundle"]; // Sentences that already include the reason. const REASON_IN_SENTENCE = { "intent.canary_rolled_back": 1 }; const AUDIT_KINDS = [ ["all", "All entries", () => true], ["intent", "Rules", (a) => a.startsWith("intent.")], ["group", "Groups", (a) => a.startsWith("group.")], ["application", "Applications", (a) => a.startsWith("application.")], ["server", "Servers", (a) => a.startsWith("agent_cert.") || a.startsWith("host.") || a.startsWith("bootstrap_token.")], ["alert", "Alerts", (a) => a.startsWith("alert.")], ["detector", "Detection settings", (a) => a.startsWith("detector.")], ["auth", "Sign-ins", (a) => a.startsWith("auth.")], ]; const AUDIT_PERIODS = [["all", "Any time", 0], ["1h", "Last hour", 3600e3], ["24h", "Last 24 hours", 86400e3], ["7d", "Last 7 days", 7 * 86400e3]]; const auditWho = (a) => (!a.actor ? "unknown" : a.actor === "system" ? "Imara" : a.actor); const auditIsChange = (c) => /^(intent\.(create|created|edited|update|updated|deleted|delete|rollback|rollback_to_bundle|revoked|approval_policy)|group\.|application\.|host\.addresses|detector\.update|agent_cert\.revoke)/.test(c); const auditIsApproval = (c) => /^intent\.(approve_signoff|approved|auto_approved)$/.test(c); function auditDay(iso) { const d = new Date(iso); if (isNaN(d)) return "Unknown date"; const today = new Date(); today.setHours(0, 0, 0, 0); const day = new Date(d); day.setHours(0, 0, 0, 0); const diff = Math.round((today - day) / 86400e3); if (diff === 0) return "Today"; if (diff === 1) return "Yesterday"; return d.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short", year: "numeric" }); } const auditTime = (iso) => { const d = new Date(iso); return isNaN(d) ? "" : d.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", second: "2-digit" }); }; // Every recorded field as rows: nested objects as dotted keys, lists joined. function auditFields(obj, prefix, out) { Object.keys(obj || {}).sort().forEach((k) => { const v = obj[k]; const key = prefix ? prefix + "." + k : k; if (v && typeof v === "object" && !Array.isArray(v)) auditFields(v, key, out); else out.push([key, Array.isArray(v) ? v.map((x) => (x && typeof x === "object" ? JSON.stringify(x) : String(x))).join(", ") : v == null ? "" : String(v)]); }); return out; } function auditCsv(rows, text) { const cell = (s) => '"' + String(s == null ? "" : s).replace(/"/g, '""') + '"'; const head = ["Time (UTC)", "Who", "Action", "Entry", "Target type", "Target", "Details"]; const lines = rows.map((a) => [a.occurred_at, auditWho(a), a.action, text(a), a.target_kind || "", a.target_id || "", JSON.stringify(a.details || {})].map(cell).join(",")); return [head.map(cell).join(",")].concat(lines).join("\r\n"); } function AuditPage({ audit, auditEnd, onLoadMore, openTarget }) { const T = window.ImaraTerms; const D = window.MS_DATA; const [q, setQ] = useState(""); const [kind, setKind] = useState("all"); const [who, setWho] = useState("all"); const [period, setPeriod] = useState("all"); const [every, setEvery] = useState(false); const [loadingMore, setLoadingMore] = useState(false); const [open, setOpen] = useState(null); const hostName = (id) => (D.byId && D.byId[id] && D.byId[id].hostname) || id; const alertById = (id) => (D.ALERTS || []).find((x) => String(x.id) === String(id)) || null; // Applications by id: the loaded ones, and the names recorded when they // were created (so a deleted one keeps its name). const appNames = useMemo(() => { const m = {}; audit.forEach((a) => { if (a.action === "application.create" && a.details && a.details.name) m[String(a.target_id)] = a.details.name; }); (D.APPLICATIONS || []).forEach((x) => { m[String(x.id)] = x.name; }); return m; }, [audit, D.APPLICATIONS]); const appName = (id) => appNames[String(id)] || ""; const text = (a) => T.auditText(a, { hostName, alertById, appName }); const people = useMemo(() => { const names = Array.from(new Set(audit.map(auditWho))); return names.filter((n) => n !== "Imara").sort().concat(names.includes("Imara") ? ["Imara"] : []); }, [audit]); const test = (AUDIT_KINDS.find((k) => k[0] === kind) || AUDIT_KINDS[0])[2]; const span = (AUDIT_PERIODS.find((p) => p[0] === period) || AUDIT_PERIODS[0])[2]; const now = T.serverNow(); const needle = q.trim().toLowerCase(); const filtered = audit.filter((a) => { const code = String(a.action || ""); if (!every && REPEATED_ACTIONS.includes(code)) return false; if (!test(code)) return false; if (who !== "all" && auditWho(a) !== who) return false; if (span && !(now - Date.parse(a.occurred_at) <= span)) return false; return !needle || (text(a) + " " + code + " " + auditWho(a)).toLowerCase().includes(needle); }); const pg = usePaged(filtered, 50, [kind, q, who, period, every].join("|")); const sum = { people: new Set(filtered.map(auditWho).filter((n) => n !== "Imara")).size, signins: filtered.filter((a) => a.action === "auth.login").length, failed: filtered.filter((a) => a.action === "auth.login_failed").length, changes: filtered.filter((a) => auditIsChange(String(a.action || ""))).length, approvals: filtered.filter((a) => auditIsApproval(String(a.action || ""))).length, }; const more = async () => { setLoadingMore(true); try { await onLoadMore(); } finally { setLoadingMore(false); } }; const exportCsv = () => { const blob = new Blob([auditCsv(filtered, text)], { type: "text/csv;charset=utf-8" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = "imara-audit-log-" + new Date().toISOString().slice(0, 16).replace(/[-:T]/g, "") + ".csv"; document.body.appendChild(link); link.click(); link.remove(); setTimeout(() => URL.revokeObjectURL(url), 2000); }; // Rows with a day heading before the first entry of each day. const rows = []; let lastDay = ""; pg.slice.forEach((a) => { const day = auditDay(a.occurred_at); if (day !== lastDay) { rows.push({ day }); lastDay = day; } rows.push({ a }); }); const cur = open ? audit.find((x) => x.id === open) || null : null; return (
Export CSV}> ({ value: v, label: l }))} style={{ width: 170 }} /> setEvery(e.target.checked)} /> Show duplicate entriesThe Imara server records some actions twice: once for the request and once for the change. The second entry says more, so the first is hidden. Latest {audit.length.toLocaleString("en-US")} entries loaded{auditEnd && !(D.LOAD_FAILED && D.LOAD_FAILED.AUDIT) ? " (the whole log)" : ""}. {onLoadMore && !auditEnd && }
setOpen(null)} openTarget={openTarget ? (k, id) => { setOpen(null); return openTarget(k, id); } : null} /> ); } // One audit log entry with every recorded field. function AuditEntryDrawer({ entry, text, hostName, appName, onClose, openTarget }) { const T = window.ImaraTerms; const [copied, setCopied] = useState(false); useEffect(() => setCopied(false), [entry && entry.id]); if (!entry) return null; const a = entry; const d = a.details || {}; const tone = actionTone(a.action); const when = new Date(a.occurred_at); const kindName = { intent: "Rule", agent: "Server", group: "Group", application: "Application", alert: "Alert", detector: "Detection check", user: "User" }[a.target_kind] || a.target_kind || ""; // A rule is named by its title (its technical name in a row of its own). const ruleName = a.target_kind === "intent" ? d.intent_name || d.name || "" : ""; const targetName = a.target_kind === "agent" ? hostName(a.target_id) : ruleName ? T.ruleTitle(ruleName) : d.intent_name || d.name || d.group_name || (a.target_kind === "application" ? appName(a.target_id) : "") || ""; const canOpen = openTarget && ["intent", "agent", "alert"].includes(a.target_kind); const fields = auditFields(d, "", []); const raw = JSON.stringify(a, null, 2); return (
Audit log entry
{text}
{agoLong(a.occurred_at)}
Entry
{a.target_kind && } {ruleName && ruleName !== targetName && } {d.reason && } {d.rejection_reason && } {d.error && } {canOpen && ( )} {fields.length > 0 && (
Recorded details
{fields.map(([k, v]) => )}
)}
Raw record
{raw}
); } Object.assign(window, { IntentsPage, AlertsPage, GroupsPage, ApplicationsPage, AuditPage, RuleFacts });