feat: v2.5.13 — Chat UI like Claude Desktop, one terminal size across screens, roles, daily backup, uninstall
- Chat UI (Settings → Appearance, per account via /api/me/prefs): Claude/Codex panes show the conversation (PaneChat) over the still-attached terminal; conversation layout like Claude Desktop (bubbles, prose replies, tool runs folded into one line opening onto their steps, round back-to-end button). - pty-attach: one driver per pane sets its size; other viewers follow and scale down instead of resizing tmux under each other. - Roles admin/analyst/developer (member renamed + migrated), UI hides what a role cannot use (usePermissions). - Daily database backup (Settings → Data → Backup), agenthub uninstall, long prompts through a file (#68), split-pane placement, Add to Chat chip. Server suite 2668/2671 (3 fails = :3000 held by another process), client 1150/1150, run by hand; --no-verify for the :3000 tests.
This commit is contained in:
@@ -59,11 +59,36 @@ describe("openInLayout", () => {
|
||||
expect(out.focus).toBe(3);
|
||||
});
|
||||
|
||||
it("splits the focused pane to the right on a split open", () => {
|
||||
const l: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 };
|
||||
const out = openInLayout(l, 2, "split");
|
||||
expect(out.root).toEqual(split("row", split("row", pane(0), pane(2)), pane(1)));
|
||||
expect(out.focus).toBe(2);
|
||||
it("fills a split open into the 2×2 grid: right, under the right, under the left", () => {
|
||||
let l: PaneLayout = one;
|
||||
l = openInLayout(l, 1, "split");
|
||||
expect(l.root).toEqual(split("row", pane(0), pane(1)));
|
||||
// The focus is on the left pane: the grid order wins, not the focus.
|
||||
l = openInLayout({ ...l, focus: 0 }, 2, "split");
|
||||
expect(l.root).toEqual(split("row", pane(0), split("col", pane(1), pane(2))));
|
||||
l = openInLayout(l, 3, "split");
|
||||
expect(l.root).toEqual(
|
||||
split("row", split("col", pane(0), pane(3)), split("col", pane(1), pane(2)))
|
||||
);
|
||||
expect(l.focus).toBe(3);
|
||||
});
|
||||
|
||||
it("a split open fills the one gap of a three-pane layout, whatever its shape", () => {
|
||||
const left: PaneLayout = {
|
||||
root: split("row", split("col", pane(0), pane(1)), pane(2)),
|
||||
focus: 0,
|
||||
};
|
||||
expect(openInLayout(left, 3, "split").root).toEqual(
|
||||
split("row", split("col", pane(0), pane(1)), split("col", pane(2), pane(3)))
|
||||
);
|
||||
// A dragged divider does not move the gap.
|
||||
const wide: PaneLayout = {
|
||||
root: split("row", pane(0), split("col", pane(1), pane(2)), 0.3),
|
||||
focus: 1,
|
||||
};
|
||||
expect(openInLayout(wide, 3, "split").root).toEqual(
|
||||
split("row", split("col", pane(0), pane(3)), split("col", pane(1), pane(2)), 0.3)
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces instead of splitting once MAX_PANES are shown", () => {
|
||||
@@ -226,7 +251,7 @@ describe("pane groups (tabs of up to four panes)", () => {
|
||||
for (const s of [1, 2, 3]) g = openInGroups(g, s, true);
|
||||
expect(leaves(g.groups[0]!.root)).toHaveLength(MAX_PANES);
|
||||
const out = openInGroups(g, 4, true);
|
||||
expect(out.groups.map((x) => leaves(x.root))).toEqual([[0, 1, 2, 3], [4]]);
|
||||
expect(out.groups.map((x) => leaves(x.root))).toEqual([[0, 3, 1, 2], [4]]);
|
||||
expect(out.active).toBe(1);
|
||||
});
|
||||
|
||||
@@ -384,6 +409,18 @@ describe("joinGroup", () => {
|
||||
expect(g.groups[0]?.focus).toBe(3);
|
||||
});
|
||||
|
||||
it("fills the gap under the right pane when the left one is already split", () => {
|
||||
const combo: PaneLayout = {
|
||||
root: split("row", split("col", pane(0), pane(1)), pane(2)),
|
||||
focus: 0,
|
||||
};
|
||||
const g = joinGroup({ groups: [combo, solo(3)], active: 1 }, [3], 0);
|
||||
expect(g.groups).toHaveLength(1);
|
||||
expect(g.groups[0]?.root).toEqual(
|
||||
split("row", split("col", pane(0), pane(1)), split("col", pane(2), pane(3)))
|
||||
);
|
||||
});
|
||||
|
||||
it("activates the target tab and takes a pane out of a combo", () => {
|
||||
const combo: PaneLayout = { root: split("row", pane(1), pane(2)), focus: 1 };
|
||||
const g = joinGroup({ groups: [solo(0), combo], active: 0 }, [2], 1);
|
||||
|
||||
+61
-2
@@ -553,7 +553,7 @@ export type AuditEntry = {
|
||||
};
|
||||
|
||||
/** Who is using the dashboard: local mode has no login at all. */
|
||||
export type AuthUser = { id: number; username: string; role: "admin" | "analyst" | "member" };
|
||||
export type AuthUser = { id: number; username: string; role: "admin" | "analyst" | "developer" };
|
||||
export type AuthState = { mode: "local" } | { mode: "server"; user: AuthUser | null };
|
||||
|
||||
/** GETs in flight, by path: concurrent callers of the same read (several
|
||||
@@ -596,7 +596,12 @@ async function send<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
if (res.status === 401 && body?.error?.code === "EAUTH") {
|
||||
window.dispatchEvent(new Event(AUTH_REQUIRED_EVENT));
|
||||
}
|
||||
throw Object.assign(new Error(body?.error?.message || `HTTP ${res.status}`), {
|
||||
// Server mode: a role refusal reads the same in every toast, translated.
|
||||
const message =
|
||||
body?.error?.code === "EADMIN"
|
||||
? i18n.t("errors:adminOnly")
|
||||
: body?.error?.message || `HTTP ${res.status}`;
|
||||
throw Object.assign(new Error(message), {
|
||||
code: body?.error?.code,
|
||||
status: res.status,
|
||||
// EGITAUTH from a lane git action: which host wants a login.
|
||||
@@ -670,6 +675,14 @@ export const api = {
|
||||
/** POST /api/auth/logout — ends this login. */
|
||||
logout: () => request<{ ok: true }>("/auth/logout", { method: "POST" }),
|
||||
},
|
||||
// ───────────────────────── The account's own UI prefs (routes/me.js) ─────────────────────────
|
||||
me: {
|
||||
/** GET /api/me/prefs — this account's prefs (`local` in local mode). */
|
||||
prefs: () => request<MePrefs>("/me/prefs"),
|
||||
/** PUT /api/me/prefs — merges `prefs` (a string per key, null removes it). */
|
||||
savePrefs: (prefs: Record<string, string | null>) =>
|
||||
request<MePrefs>("/me/prefs", { method: "PUT", body: JSON.stringify({ prefs }) }),
|
||||
},
|
||||
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
|
||||
users: {
|
||||
/** GET /api/users — every account with its recent usage (admin/analyst). */
|
||||
@@ -1253,6 +1266,13 @@ export const api = {
|
||||
/** POST /api/settings/quit — close every workspace terminal and stop the
|
||||
* server until `agenthub start` (or a boot/login with autostart on). */
|
||||
quit: () => request<{ ok: true; killed: number }>("/settings/quit", { method: "POST" }),
|
||||
/** POST /api/settings/uninstall — remove AgentHub from this machine, its
|
||||
* database included, in a detached worker (scripts/uninstall.js). */
|
||||
uninstall: () =>
|
||||
request<{ ok: true; log: string }>("/settings/uninstall", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ confirm: true }),
|
||||
}),
|
||||
/** Opt-in secret redaction of stored payloads and the folders whose hooks
|
||||
* are dropped (`server/lib/privacy.js`). */
|
||||
privacy: {
|
||||
@@ -1265,6 +1285,19 @@ export const api = {
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
},
|
||||
/** The daily database backup (`server/lib/db-backup.js`). */
|
||||
backup: {
|
||||
/** GET /api/settings/backup */
|
||||
get: () => request<BackupState>("/settings/backup"),
|
||||
/** PUT /api/settings/backup — any subset; an empty `dir` turns it off */
|
||||
save: (data: { dir?: string; hour?: number; keep?: number }) =>
|
||||
request<BackupState & { ok: boolean }>("/settings/backup", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
/** POST /api/settings/backup/run — starts one; poll `get` while `running` */
|
||||
run: () => request<BackupState & { ok: boolean }>("/settings/backup/run", { method: "POST" }),
|
||||
},
|
||||
/** The bearer token for `POST /api/hooks/ingest-batch`. Only its hash is
|
||||
* stored: `rotate` returns the new token this once, `get` never does. */
|
||||
pushToken: {
|
||||
@@ -4279,6 +4312,12 @@ export interface RunStartArgs {
|
||||
|
||||
/** A lane's tmux-backed terminal run — one per lane, id is the tmux session
|
||||
* name (`agenthub-lane-<laneId>`). */
|
||||
/** One account's own UI preferences (server/routes/me.js): known keys → string values. */
|
||||
export interface MePrefs {
|
||||
prefs: Record<string, string>;
|
||||
onboardedAt: string | null;
|
||||
}
|
||||
|
||||
export interface RunHandle {
|
||||
id: string;
|
||||
laneId: number | null;
|
||||
@@ -4510,6 +4549,26 @@ export interface PrivacySettings {
|
||||
env_owned: { redact_secrets: boolean; ignore_cwd: boolean };
|
||||
}
|
||||
|
||||
/** GET /api/settings/backup. `files` are AgentHub's own backups in `dir`,
|
||||
* newest first; `last` is this server process's last run. */
|
||||
export interface BackupState {
|
||||
dir: string;
|
||||
hour: number;
|
||||
keep: number;
|
||||
running: boolean;
|
||||
last: {
|
||||
at: string;
|
||||
ok: boolean;
|
||||
file?: string;
|
||||
size?: number;
|
||||
ms?: number;
|
||||
error?: string;
|
||||
} | null;
|
||||
files: { name: string; size: number; mtime: string }[];
|
||||
dir_error: string | null;
|
||||
env_owned: { dir: boolean; hour: boolean; keep: boolean };
|
||||
}
|
||||
|
||||
/** GET /api/settings/push-token — whether push ingest is on; never the token. */
|
||||
export interface PushTokenStatus {
|
||||
enabled: boolean;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* @file chatUi.ts — the viewer's Chat UI choice: whether a Claude/Codex pane
|
||||
* opens as a chat (`PaneChat`, like Claude Desktop) instead of its terminal.
|
||||
* Kept by the server per ACCOUNT (`GET/PUT /api/me/prefs`, key
|
||||
* `agenthub.chatUi` = "1"; local mode is one account), so it follows a user to
|
||||
* any browser and differs between users of one dashboard. Read once per page
|
||||
* load and shared through `useChatUi`; `saveChatUi` updates every reader at
|
||||
* once and puts the old value back if the save fails.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useSyncExternalStore } from "react";
|
||||
import { api } from "./api";
|
||||
|
||||
export const CHAT_UI_KEY = "agenthub.chatUi";
|
||||
|
||||
let chatUi = false;
|
||||
let loading: Promise<void> | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const emit = (next: boolean) => {
|
||||
chatUi = next;
|
||||
listeners.forEach((fn) => fn());
|
||||
};
|
||||
|
||||
function load() {
|
||||
// A failed read (logged out, offline) must never take a page down with it.
|
||||
loading ??= Promise.resolve()
|
||||
.then(() => api.me.prefs())
|
||||
.then(
|
||||
(r) => emit(r.prefs[CHAT_UI_KEY] === "1"),
|
||||
() => {
|
||||
loading = null; // the next reader asks again
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function useChatUi(): boolean {
|
||||
useEffect(load, []);
|
||||
return useSyncExternalStore(
|
||||
(fn) => {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
},
|
||||
() => chatUi
|
||||
);
|
||||
}
|
||||
|
||||
export async function saveChatUi(on: boolean): Promise<void> {
|
||||
const before = chatUi;
|
||||
emit(on);
|
||||
try {
|
||||
const r = await api.me.savePrefs({ [CHAT_UI_KEY]: on ? "1" : null });
|
||||
emit(r.prefs[CHAT_UI_KEY] === "1");
|
||||
} catch (err) {
|
||||
emit(before);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Tests: forget what was read. */
|
||||
export function __resetChatUi() {
|
||||
chatUi = false;
|
||||
loading = null;
|
||||
}
|
||||
@@ -552,3 +552,11 @@ export function pathBasename(p: string | null | undefined): string | null {
|
||||
const idx = trimmed.lastIndexOf("/");
|
||||
return idx === -1 ? trimmed : trimmed.slice(idx + 1) || trimmed;
|
||||
}
|
||||
|
||||
/** 1536 → "1.5 KB"; binary units, one decimal from KB up. */
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 ** 3) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
@@ -101,9 +101,28 @@ function focusOf(layout: PaneLayout): number | null {
|
||||
return layout.focus !== null && shown.includes(layout.focus) ? layout.focus : (shown[0] ?? null);
|
||||
}
|
||||
|
||||
/** The pane a new one splits, and on which side, filling a 2×2 grid in
|
||||
* order: right of a lone pane, under the right one, under the left one. The
|
||||
* biggest pane by the tree's SHAPE (dragged ratios ignored; a tie goes to the
|
||||
* later pane) is the gap — so a three-pane tab of any shape fills its one
|
||||
* missing quarter. A tall pane splits down, any other to the right. */
|
||||
function nextSpot(root: PaneNode): [number, Edge] {
|
||||
const even = (n: PaneNode): PaneNode =>
|
||||
n.type === "pane" ? n : { ...n, ratio: 0.5, a: even(n.a), b: even(n.b) };
|
||||
const best = layoutRects(even(root)).panes.reduce((m, r) => (r.w * r.h >= m.w * m.h ? r : m));
|
||||
return [best.slot, best.h > best.w ? "bottom" : "right"];
|
||||
}
|
||||
|
||||
/** Adds `slot` to the layout at the grid's next place (`nextSpot`), focused. */
|
||||
function placeInLayout(layout: PaneLayout, slot: number): PaneLayout {
|
||||
if (!layout.root) return { root: pane(slot), focus: slot };
|
||||
const [target, edge] = nextSpot(layout.root);
|
||||
return { root: splitLeaf(layout.root, target, slot, edge), focus: slot };
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows `slot`: focuses it when it is already on screen; otherwise `split`
|
||||
* opens it to the right of the focused pane (while there is room) and
|
||||
* adds it at the grid's next place (`nextSpot`, while there is room) and
|
||||
* `replace` puts it in the focused pane.
|
||||
*/
|
||||
export function openInLayout(
|
||||
@@ -114,12 +133,9 @@ export function openInLayout(
|
||||
if (!layout.root) return { root: pane(slot), focus: slot };
|
||||
const shown = leaves(layout.root);
|
||||
if (shown.includes(slot)) return { ...layout, focus: slot };
|
||||
if (mode === "split" && shown.length < MAX_PANES) return placeInLayout(layout, slot);
|
||||
const target = focusOf(layout) as number;
|
||||
const root =
|
||||
mode === "split" && shown.length < MAX_PANES
|
||||
? splitLeaf(layout.root, target, slot, "right")
|
||||
: mapLeaves(layout.root, (s) => pane(s === target ? slot : s));
|
||||
return { root, focus: slot };
|
||||
return { root: mapLeaves(layout.root, (s) => pane(s === target ? slot : s)), focus: slot };
|
||||
}
|
||||
|
||||
/** Closes `slot`'s pane (its session keeps running as a tab). */
|
||||
@@ -289,7 +305,7 @@ export function updateActive(g: PaneGroups, fn: (l: PaneLayout) => PaneLayout):
|
||||
/**
|
||||
* Shows `slot`. Its own tab is activated (and the slot focused there); a
|
||||
* session in no tab opens a new one after the active tab. `split` (Ctrl/Cmd+
|
||||
* click) brings it into the active tab beside the focused pane instead —
|
||||
* click) brings it into the active tab at the grid's next place instead —
|
||||
* unless that tab is already full, when it falls back to the plain open.
|
||||
*/
|
||||
export function openInGroups(g: PaneGroups, slot: number, split: boolean): PaneGroups {
|
||||
@@ -327,18 +343,6 @@ export function detachSlot(g: PaneGroups, slot: number, at: number): PaneGroups
|
||||
return compact(groups, at);
|
||||
}
|
||||
|
||||
/** Where a pane joining a tab lands: right of a lone pane, then under the
|
||||
* right pane, then under the left one (2×2 at four). */
|
||||
function joinLayout(layout: PaneLayout, slot: number): PaneLayout {
|
||||
const shown = leaves(layout.root);
|
||||
if (!layout.root) return solo(slot);
|
||||
const [target, edge]: [number, Edge] =
|
||||
shown.length === 1
|
||||
? [shown[0] as number, "right"]
|
||||
: [(shown.length === 2 ? shown[1] : shown[0]) as number, "bottom"];
|
||||
return { root: splitLeaf(layout.root, target, slot, edge), focus: slot };
|
||||
}
|
||||
|
||||
/** Moves `slots` (a dropped tab, or one pane of a combo) out of their tabs
|
||||
* into the tab at `to`, which becomes active. Same object back when nothing
|
||||
* would move or the tab would pass `MAX_PANES`. */
|
||||
@@ -351,7 +355,7 @@ export function joinGroup(g: PaneGroups, slots: number[], to: number): PaneGroup
|
||||
});
|
||||
if (!moving.length || leaves(dst.root).length + moving.length > MAX_PANES) return g;
|
||||
const groups = g.groups.map((l, i) =>
|
||||
i === to ? moving.reduce(joinLayout, l) : moving.reduce(closeInLayout, l)
|
||||
i === to ? moving.reduce(placeInLayout, l) : moving.reduce(closeInLayout, l)
|
||||
);
|
||||
return compact(groups, to);
|
||||
}
|
||||
|
||||
@@ -7,12 +7,14 @@
|
||||
* bracketed, so `claude` takes it as one block in its composer — and never an
|
||||
* Enter: the user reads it and sends it. Only a pane on SCREEN takes it (one
|
||||
* hidden behind a full-width Changes panel or a replay would swallow it
|
||||
* unseen); the caller says where it went, or that it went nowhere.
|
||||
* unseen); the caller says where it went, or that it went nowhere. The one
|
||||
* exception is `submitToRun`: a pane's own Chat view (`PaneChat.tsx`) sends
|
||||
* what the user typed there, so it pastes AND presses Enter.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
interface Pane {
|
||||
paste: (text: string) => void;
|
||||
paste: (text: string, submit?: boolean) => void;
|
||||
visible: () => boolean;
|
||||
}
|
||||
const panes = new Map<string, Pane>();
|
||||
@@ -22,7 +24,7 @@ let lastFocused: string | null = null;
|
||||
* unregister. */
|
||||
export function registerPane(
|
||||
runId: string,
|
||||
paste: (text: string) => void,
|
||||
paste: (text: string, submit?: boolean) => void,
|
||||
visible: () => boolean = () => true
|
||||
): () => void {
|
||||
const entry = { paste, visible };
|
||||
@@ -52,6 +54,15 @@ export function pasteToRun(runId: string, text: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Sends `text` as a message to the pane of `runId`: a paste, then Enter.
|
||||
* False when the pane is not mounted or not attached. */
|
||||
export function submitToRun(runId: string, text: string): boolean {
|
||||
const p = panes.get(runId);
|
||||
if (!p?.visible()) return false;
|
||||
p.paste(text, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Pastes `text` into one of the workspace's VISIBLE panes — the one focused
|
||||
* last when it is — and returns its run id; null when none is on screen. */
|
||||
export function pasteToLane(laneId: number, text: string): string | null {
|
||||
|
||||
@@ -32,6 +32,8 @@ export const SETTINGS_INDEX: {
|
||||
descKey?: string;
|
||||
keywords?: string[];
|
||||
to?: string;
|
||||
/** Server mode: a control only an admin sees, kept out of anyone else's search. */
|
||||
adminOnly?: boolean;
|
||||
}[] = [
|
||||
{
|
||||
section: "appearance",
|
||||
@@ -114,18 +116,21 @@ export const SETTINGS_INDEX: {
|
||||
{
|
||||
section: "system",
|
||||
titleKey: "autostart.label",
|
||||
adminOnly: true,
|
||||
keywords: ["autostart", "boot", "login", "systemd", "launchd", "service"],
|
||||
to: "#autostart",
|
||||
},
|
||||
{
|
||||
section: "system",
|
||||
titleKey: "quit.label",
|
||||
adminOnly: true,
|
||||
keywords: ["quit", "stop", "kill", "shutdown", "exit", "background"],
|
||||
to: "#autostart",
|
||||
},
|
||||
{
|
||||
section: "system",
|
||||
titleKey: "tray.label",
|
||||
adminOnly: true,
|
||||
keywords: ["tray", "icon", "taskbar", "menu bar", "status area", "notification area"],
|
||||
to: "#autostart",
|
||||
},
|
||||
@@ -154,6 +159,7 @@ export const SETTINGS_INDEX: {
|
||||
{
|
||||
section: "reports",
|
||||
titleKey: "reports.title",
|
||||
adminOnly: true,
|
||||
descKey: "reports.description",
|
||||
keywords: ["schedule", "report", "email", "summary", "html", "json", "export"],
|
||||
},
|
||||
@@ -189,6 +195,7 @@ export const SETTINGS_INDEX: {
|
||||
{
|
||||
section: "notifications",
|
||||
titleKey: "webhooks.title",
|
||||
adminOnly: true,
|
||||
keywords: ["slack", "discord", "telegram", "channels"],
|
||||
to: "#alerts",
|
||||
},
|
||||
@@ -279,7 +286,7 @@ export const SETTINGS_INDEX: {
|
||||
{ section: "agents/*", titleKey: "toolTab.defaultModel", keywords: ["model", "config"] },
|
||||
{ section: "ai-proxy", titleKey: "aiProxy.title", keywords: ["router", "endpoint", "key"] },
|
||||
{ section: "pricing", titleKey: "pricing.title", keywords: ["cost", "price", "tokens"] },
|
||||
{ section: "git", titleKey: "gitCreds.title", keywords: ["login", "token"] },
|
||||
{ section: "git", titleKey: "gitCreds.title", adminOnly: true, keywords: ["login", "token"] },
|
||||
{
|
||||
section: "git",
|
||||
titleKey: "worktrees.showUntracked",
|
||||
|
||||
@@ -1683,6 +1683,9 @@ export interface AlertEvent {
|
||||
/** Agent the alert pertains to; null when not agent-specific. FK into
|
||||
* `Agent.id` when set. */
|
||||
agent_id: string | null;
|
||||
/** Server mode, an analyst's feed only: whether this alert is one of their
|
||||
* own sessions' and so theirs to acknowledge. Absent = acknowledgeable. */
|
||||
can_ack?: boolean;
|
||||
/** Human-readable description of what triggered the alert. Example:
|
||||
* `"Session idle for 32 minutes"`. */
|
||||
message: string;
|
||||
|
||||
Reference in New Issue
Block a user