feat: remove legacy alert rules, superseded by automations

Alert rules already auto-migrate into automations on boot and the old
engine stands down after. Deletes the rule/firing tables, routes, UI,
and AI tools; keeps notification channels since automations still use
them. Also fixes automations' notify step, which still called the
deleted repository.
This commit is contained in:
LukeGus committed 2026-09-22 10:45:12 -05:00
1 parent e0f14f2ecf
commit e372b0eda7
101 files changed
+29118 -5539

No files matched your search

-18
View File
@@ -242,15 +242,6 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location ~ ^/alert-rules(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location ~ ^/notification-channels(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
@@ -260,15 +251,6 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location ~ ^/alert-firings(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location ~ ^/rbac(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
-18
View File
@@ -222,15 +222,6 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location ~ ^/alert-rules(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location ~ ^/notification-channels(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
@@ -240,15 +231,6 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location ~ ^/alert-firings(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location ~ ^/rbac(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
+3
View File
@@ -0,0 +1,3 @@
DROP TABLE `alert_firings`;--> statement-breakpoint
DROP TABLE `alert_rule_channels`;--> statement-breakpoint
DROP TABLE `alert_rules`;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -176,6 +176,13 @@
"when": 1789991404402,
"tag": "0024_thin_colleen_wing",
"breakpoints": true
},
{
"idx": 25,
"version": "5",
"when": 1790083819606,
"tag": "0025_wet_master_mold",
"breakpoints": true
}
]
}
+3
View File
@@ -0,0 +1,3 @@
DROP TABLE "alert_firings" CASCADE;--> statement-breakpoint
DROP TABLE "alert_rule_channels" CASCADE;--> statement-breakpoint
DROP TABLE "alert_rules" CASCADE;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -183,6 +183,13 @@
"when": 1789991403847,
"tag": "0025_purple_squirrel_girl",
"breakpoints": true
},
{
"idx": 26,
"version": "7",
"when": 1790083819084,
"tag": "0026_perfect_masque",
"breakpoints": true
}
]
}
+3
View File
@@ -0,0 +1,3 @@
DROP TABLE `alert_firings`;--> statement-breakpoint
DROP TABLE `alert_rule_channels`;--> statement-breakpoint
DROP TABLE `alert_rules`;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -155,6 +155,13 @@
"when": 1789991403283,
"tag": "0021_dashing_lester",
"breakpoints": true
},
{
"idx": 22,
"version": "6",
"when": 1790083818524,
"tag": "0022_steady_the_hood",
"breakpoints": true
}
]
}
+2 -2
View File
@@ -13,7 +13,7 @@ export function buildSystemPrompt(options: {
}): string {
const lines: string[] = [
"You are the assistant built into Termix, a self-hosted server management app.",
"You help the user manage their servers, snippets, automations, fleets and alerts.",
"You help the user manage their servers, snippets, automations and fleets.",
"",
"How you work:",
"- You start with no knowledge of this user's setup. Call a read tool to find out anything you need.",
@@ -24,7 +24,7 @@ export function buildSystemPrompt(options: {
"- Do exactly what was asked, and nothing beyond it. A question is a request for an answer, not for changes.",
"- Questions like 'what is running on this server', 'check this host' or 'why is this slow' are answered with information. Read, then report. Do not propose anything.",
"- Only propose a change when the user asked for one, in words like 'add', 'create', 'set up', 'fix' or 'change'.",
"- Do not propose follow-up work you thought of yourself: no monitoring, no alert rules, no scripts, no snippets, no cleanup, unless that is what was requested.",
"- Do not propose follow-up work you thought of yourself: no monitoring automations, no scripts, no snippets, no cleanup, unless that is what was requested.",
"- If you think something is worth doing, say so in one sentence and stop. Let the user ask.",
"- One request means one proposal at most. Do not bundle extras alongside it.",
"",
+2 -2
View File
@@ -4,11 +4,11 @@
* This plugin runs on the main thread rather than in a worker for the same
* reason ssh-terminal, docker and host-metrics do (see
* src/backend/plugins/first-party.ts): it writes directly across hosts,
* snippets, fleets, alert rules and automations through their repositories,
* snippets, fleets and automations through their repositories,
* runs approved commands over the shared SSH pool via resolveHostById and
* withConnection, and streams its chat replies as server-sent events. None
* of that fits the worker ctx -- ctx.hosts is a 13-field read-only view with
* no write methods and no snippets/fleets/alerts/automations surface at all,
* no write methods and no snippets/fleets/automations surface at all,
* and ctx.http.route replies with a single postMessage value rather than a
* stream.
*
-30
View File
@@ -1,5 +1,4 @@
import {
createCurrentAlertRepository,
createCurrentAutomationRepository,
createCurrentFleetRepository,
createCurrentHostRepository,
@@ -217,35 +216,6 @@ export async function applyProposal(
};
}
case "propose_create_alert_rule": {
const created = await createCurrentAlertRepository().createAlertRule({
userId,
name: requireString(payload.name, "name"),
hostId:
payload.hostId === null || payload.hostId === undefined
? null
: requireNumber(payload.hostId, "hostId"),
enabled: true,
triggerType: requireString(payload.triggerType, "triggerType"),
thresholdValue:
payload.thresholdValue === null ||
payload.thresholdValue === undefined
? null
: Number(payload.thresholdValue),
thresholdDurationSeconds:
payload.thresholdDurationSeconds === null ||
payload.thresholdDurationSeconds === undefined
? null
: Number(payload.thresholdDurationSeconds),
cooldownMinutes: Number(payload.cooldownMinutes) || 15,
channelIds: [],
} as any);
return {
ok: true,
summary: `Created alert rule ${(created as any)?.name ?? ""}`.trim(),
};
}
case "propose_create_automation": {
// Reuses the same validator the automations route runs, so an
// LLM-authored definition is held to exactly the human standard.
-34
View File
@@ -216,40 +216,6 @@ export const proposeTools: AiTool[] = [
hostIds: args.hostIds ?? [],
}),
},
{
name: "propose_create_alert_rule",
description:
"Propose a new alert rule that fires when a host metric crosses a threshold.",
category: "propose",
parameters: objectSchema(
{
name: str("Rule name"),
hostId: num("Host id to watch, or omit to watch all hosts"),
triggerType: str(
"What to watch, for example cpu, memory, disk or host_status",
),
thresholdValue: num("Threshold to compare against"),
thresholdDurationSeconds: num(
"How long the breach must persist before firing",
),
cooldownMinutes: num("Minimum minutes between repeat firings"),
},
["name", "triggerType"],
),
handler: async (args) =>
proposal(
"propose_create_alert_rule",
`Create alert rule ${String(args.name)}`,
{
name: args.name,
hostId: args.hostId ?? null,
triggerType: args.triggerType,
thresholdValue: args.thresholdValue ?? null,
thresholdDurationSeconds: args.thresholdDurationSeconds ?? null,
cooldownMinutes: args.cooldownMinutes ?? 15,
},
),
},
{
name: "propose_run_command",
description:
+2 -55
View File
@@ -1,5 +1,5 @@
import {
createCurrentAlertRepository,
createCurrentNotificationChannelRepository,
createCurrentAutomationRepository,
createCurrentCommandHistoryRepository,
createCurrentFleetRepository,
@@ -190,30 +190,6 @@ export const readTools: AiTool[] = [
};
},
},
{
name: "list_alert_rules",
description:
"List the user's alert rules with their thresholds and enabled state.",
category: "read",
parameters: objectSchema({}),
handler: async (_args, context) => {
const rules = await createCurrentAlertRepository().listAlertRules(
context.userId,
);
return {
rules: rules.map((rule) => ({
id: rule.id,
name: rule.name,
hostId: rule.host_id,
enabled: rule.enabled === 1,
triggerType: rule.trigger_type,
thresholdValue: rule.threshold_value,
thresholdDurationSeconds: rule.threshold_duration_seconds,
cooldownMinutes: rule.cooldown_minutes,
})),
};
},
},
{
name: "list_notification_channels",
description:
@@ -222,7 +198,7 @@ export const readTools: AiTool[] = [
parameters: objectSchema({}),
handler: async (_args, context) => {
const channels =
await createCurrentAlertRepository().listNotificationChannels(
await createCurrentNotificationChannelRepository().listNotificationChannels(
context.userId,
);
return {
@@ -235,35 +211,6 @@ export const readTools: AiTool[] = [
};
},
},
{
name: "get_alert_firings",
description:
"Recent alert firings, newest first. Use this to answer questions about what has been alerting.",
category: "read",
parameters: objectSchema({
limit: num("How many firings to return (default 25, max 100)"),
}),
handler: async (args, context) => {
const limit = Math.min(Math.max(Number(args.limit) || 25, 1), 100);
const result = await createCurrentAlertRepository().listAlertFirings({
userId: context.userId,
limit,
offset: 0,
});
return {
firings: (result.firings ?? []).map((firing) => ({
id: firing.id,
ruleName: firing.rule_name,
hostName: firing.host_name,
firedAt: firing.fired_at,
resolvedAt: firing.resolved_at,
severity: firing.severity,
message: firing.message,
acknowledged: firing.acknowledged === 1,
})),
};
},
},
{
name: "list_homepage_items",
description: "List the user's homepage service-link tiles.",
+2 -2
View File
@@ -14,7 +14,7 @@ import {
import { withConnection } from "../../../../src/backend/hosts/ssh-connection-pool.js";
import { resolveSnippetCommand } from "../../../../src/backend/database/routes/snippets-execution.js";
import {
createCurrentAlertRepository,
createCurrentNotificationChannelRepository,
createCurrentSnippetRepository,
} from "../../../../src/backend/database/repositories/factory.js";
import { sendAutomationNotification } from "../notify.js";
@@ -86,7 +86,7 @@ async function runNotify(
);
}
const repository = createCurrentAlertRepository();
const repository = createCurrentNotificationChannelRepository();
const channels = await repository.listNotificationChannels(context.userId);
const selected = channels.filter((channel) =>
step.channelIds.includes(channel.id),
@@ -31,7 +31,7 @@ import {
getNotificationChannels,
testNotificationChannel,
type NotificationChannel,
} from "@/api/alerts-api";
} from "@/api/notification-channels-api";
import { NotificationChannelDialog } from "@/sidebar/NotificationChannelDialog";
import { getSnippets } from "@/api/snippets-api";
import { getSSHHosts } from "@/api/ssh-host-management-api";
@@ -1,373 +0,0 @@
import { createCurrentAlertRepository } from "../../../src/backend/database/repositories/factory.js";
import { statsLogger } from "../../../src/backend/utils/logger.js";
import {
sendNotification,
type AlertPayload,
type NotificationChannel,
} from "../../../src/backend/utils/notification-sender.js";
import { sendDiscord } from "../../../src/backend/utils/discord-sender.js";
type AlertTriggerType =
| "host_offline"
| "host_online"
| "cpu_threshold"
| "memory_threshold"
| "disk_threshold"
| "health_check_failure"
| "health_check_recovery"
| "user_login";
interface AlertRule {
id: number;
userId: string;
hostId: number | null;
name: string;
enabled: boolean;
triggerType: AlertTriggerType;
thresholdValue: number | null;
thresholdDurationSeconds: number | null;
cooldownMinutes: number;
}
/**
* Set once the alert rules have been copied into automations. From then on the
* automations engine owns evaluation, and this one stands down rather than
* sending a second notification for every rule.
*
* The class is left in place so an install that has not migrated yet, or one
* rolled back, still alerts exactly as before.
*/
let supersededByAutomations = false;
export function markAlertEngineSuperseded(): void {
supersededByAutomations = true;
}
export function isAlertEngineSuperseded(): boolean {
return supersededByAutomations;
}
export class AlertEngine {
private static instance: AlertEngine;
// "${ruleId}:${hostId}" → lastFiredAt ms
private cooldownMap = new Map<string, number>();
// "${ruleId}:${hostId}" → breachStartTime ms
private breachStartMap = new Map<string, number>();
// hostId → last known status
private lastStatusMap = new Map<number, "online" | "offline">();
// "${hostId}:${checkId}" → last known ok state
private healthCheckStateMap = new Map<string, boolean>();
static getInstance(): AlertEngine {
if (!AlertEngine.instance) {
AlertEngine.instance = new AlertEngine();
}
return AlertEngine.instance;
}
async evaluateMetrics(
hostId: number,
metrics: {
cpu?: { percent: number | null } | null;
memory?: { percent: number | null } | null;
disk?: { percent: number | null } | null;
},
): Promise<void> {
if (supersededByAutomations) return;
const rules = (await this.loadRulesForHost(hostId)).filter((r) =>
["cpu_threshold", "memory_threshold", "disk_threshold"].includes(
r.triggerType,
),
);
if (rules.length === 0) return;
const now = Date.now();
for (const rule of rules) {
let currentValue: number | null | undefined;
if (rule.triggerType === "cpu_threshold")
currentValue = metrics.cpu?.percent;
else if (rule.triggerType === "memory_threshold")
currentValue = metrics.memory?.percent;
else if (rule.triggerType === "disk_threshold")
currentValue = metrics.disk?.percent;
if (currentValue == null || rule.thresholdValue == null) continue;
const key = `${rule.id}:${hostId}`;
const isBreaching = currentValue >= rule.thresholdValue;
if (isBreaching) {
if (!this.breachStartMap.has(key)) {
this.breachStartMap.set(key, now);
}
const breachStart = this.breachStartMap.get(key)!;
const durationMs = (rule.thresholdDurationSeconds ?? 0) * 1000;
if (
now - breachStart >= durationMs &&
!(await this.isCoolingDown(rule.id, hostId))
) {
const hostName = await this.getHostName(hostId);
await this.fireAlert(rule, hostId, hostName, {
value: currentValue,
message: `${rule.triggerType.replace("_threshold", "").toUpperCase()} usage at ${currentValue.toFixed(1)}% (threshold: ${rule.thresholdValue}%)`,
severity: currentValue >= 95 ? "critical" : "warning",
});
}
} else {
this.breachStartMap.delete(key);
}
}
}
async evaluateStatus(hostId: number, isOnline: boolean): Promise<void> {
if (supersededByAutomations) return;
const currentStatus = isOnline ? "online" : "offline";
const lastStatus = this.lastStatusMap.get(hostId);
if (lastStatus === currentStatus) return;
this.lastStatusMap.set(hostId, currentStatus);
if (lastStatus === undefined) return;
const triggerType: AlertTriggerType = isOnline
? "host_online"
: "host_offline";
const rules = (await this.loadRulesForHost(hostId)).filter(
(r) => r.triggerType === triggerType,
);
for (const rule of rules) {
if (!(await this.isCoolingDown(rule.id, hostId))) {
const hostName = await this.getHostName(hostId);
await this.fireAlert(rule, hostId, hostName, {
message: isOnline
? `Host "${hostName}" is back online`
: `Host "${hostName}" is offline`,
severity: isOnline ? "info" : "critical",
});
}
}
}
async evaluateHealthCheck(
hostId: number,
userId: string,
checkId: string,
ok: boolean,
detail?: string,
): Promise<void> {
if (supersededByAutomations) return;
const stateKey = `${hostId}:${checkId}`;
const lastOk = this.healthCheckStateMap.get(stateKey);
this.healthCheckStateMap.set(stateKey, ok);
if (lastOk === undefined) return;
let triggerType: AlertTriggerType | null = null;
if (!ok && lastOk) triggerType = "health_check_failure";
else if (ok && !lastOk) triggerType = "health_check_recovery";
if (!triggerType) return;
const rules = (await this.loadRulesForHostUser(hostId, userId)).filter(
(r) => r.triggerType === triggerType,
);
for (const rule of rules) {
if (!(await this.isCoolingDown(rule.id, hostId))) {
const hostName = await this.getHostName(hostId);
await this.fireAlert(rule, hostId, hostName, {
message: ok
? `Health check recovered on "${hostName}"${detail ? `: ${detail}` : ""}`
: `Health check failed on "${hostName}"${detail ? `: ${detail}` : ""}`,
severity: ok ? "info" : "warning",
});
}
}
}
async evaluateUserLogin(
hostId: number,
userId: string,
sshUser: string,
fromIp: string,
): Promise<void> {
if (supersededByAutomations) return;
const rules = (await this.loadRulesForHostUser(hostId, userId)).filter(
(r) => r.triggerType === "user_login",
);
for (const rule of rules) {
if (!(await this.isCoolingDown(rule.id, hostId))) {
const hostName = await this.getHostName(hostId);
await this.fireAlert(rule, hostId, hostName, {
message: `User "${sshUser}" logged in to "${hostName}" from ${fromIp}`,
severity: "info",
});
}
}
}
private async fireAlert(
rule: AlertRule,
hostId: number,
hostName: string,
context: {
value?: number;
message: string;
severity: "info" | "warning" | "critical";
},
): Promise<void> {
this.markCooldown(rule.id, hostId);
const payload: AlertPayload = {
hostName,
hostId,
triggerType: rule.triggerType,
value: context.value,
threshold: rule.thresholdValue ?? undefined,
message: context.message,
severity: context.severity,
timestamp: new Date().toISOString(),
ruleId: rule.id,
ruleName: rule.name,
};
try {
const repository = createCurrentAlertRepository();
await repository.createFiring({
userId: rule.userId,
ruleId: rule.id,
hostId,
hostName,
value: context.value ?? null,
message: context.message,
severity: context.severity,
});
await repository.pruneFiringsOlderThan(rule.userId, 30);
} catch (err) {
statsLogger.warn("Failed to write alert firing", {
operation: "alert_firing_insert_error",
ruleId: rule.id,
error: err instanceof Error ? err.message : String(err),
});
}
const channels = await this.loadChannelsForRule(rule.id);
for (const channel of channels) {
if (channel.type === "discord") {
// parse config and send via Discord sender directly
try {
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(channel.config) as Record<string, unknown>;
} catch {
statsLogger.warn("Failed to parse discord channel config", {
operation: "discord_config_parse_error",
channelId: channel.id,
});
continue;
}
sendDiscord(parsed as any, payload).catch((err) => {
statsLogger.warn("Failed to send discord notification", {
operation: "discord_notification_error",
channelId: channel.id,
error: err instanceof Error ? err.message : String(err),
});
});
} catch (err) {
statsLogger.warn("Failed to send discord notification", {
operation: "discord_notification_error",
channelId: channel.id,
error: err instanceof Error ? err.message : String(err),
});
}
} else {
sendNotification(channel, payload).catch((err) => {
statsLogger.warn("Failed to send notification", {
operation: "notification_delivery_error",
channelId: channel.id,
error: err instanceof Error ? err.message : String(err),
});
});
}
}
}
private async isCoolingDown(
ruleId: number,
hostId: number,
): Promise<boolean> {
const key = `${ruleId}:${hostId}`;
const lastFired = this.cooldownMap.get(key);
if (!lastFired) return false;
const rule = await this.loadRuleById(ruleId);
const cooldownMs = (rule?.cooldownMinutes ?? 15) * 60 * 1000;
return Date.now() - lastFired < cooldownMs;
}
private markCooldown(ruleId: number, hostId: number): void {
this.cooldownMap.set(`${ruleId}:${hostId}`, Date.now());
}
private async loadRulesForHost(hostId: number): Promise<AlertRule[]> {
try {
return (await createCurrentAlertRepository().listEnabledRulesForHost(
hostId,
)) as AlertRule[];
} catch {
return [];
}
}
private async loadRulesForHostUser(
hostId: number,
userId: string,
): Promise<AlertRule[]> {
try {
return (await createCurrentAlertRepository().listEnabledRulesForHostUser(
hostId,
userId,
)) as AlertRule[];
} catch {
return [];
}
}
private async loadRuleById(ruleId: number): Promise<AlertRule | null> {
try {
return (await createCurrentAlertRepository().findRuleById(
ruleId,
)) as AlertRule | null;
} catch {
return null;
}
}
private async loadChannelsForRule(
ruleId: number,
): Promise<NotificationChannel[]> {
try {
return await createCurrentAlertRepository().listEnabledChannelsForRule(
ruleId,
);
} catch {
return [];
}
}
private async getHostName(hostId: number): Promise<string> {
try {
return (
(await createCurrentAlertRepository().getHostDisplayName(hostId)) ??
`Host #${hostId}`
);
} catch {
return `Host #${hostId}`;
}
}
}
@@ -7,7 +7,6 @@ import { managerHandler, ManagerInputError } from "./route-helpers.js";
import { shellSingleQuote } from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { isValidPort } from "../../../../src/backend/hosts/metrics-shared/validation.js";
import type { ManagerRoutesDeps } from "./types.js";
import { AlertEngine } from "../alert-engine.js";
import { notifyAutomationHealthCheck } from "../automation-bridge.js";
export interface HealthCheck {
@@ -157,15 +156,6 @@ export function registerHealthRoutes(
if (results.length) {
await recordHistory(userId, host.id, results);
for (const r of results) {
AlertEngine.getInstance()
.evaluateHealthCheck(
host.id,
userId,
r.checkId,
r.ok,
r.detail ?? undefined,
)
.catch(() => {});
notifyAutomationHealthCheck(
host.id,
userId,
@@ -243,15 +233,6 @@ export function registerHealthRoutes(
if (results.length) {
await recordHistory(userId, host.id, results);
for (const r of results) {
AlertEngine.getInstance()
.evaluateHealthCheck(
host.id,
userId,
r.checkId,
r.ok,
r.detail ?? undefined,
)
.catch(() => {});
notifyAutomationHealthCheck(
host.id,
userId,
-13
View File
@@ -69,7 +69,6 @@ import { registerProxmoxStatsHistoryRoutes } from "../../../src/backend/hosts/me
import { ProxmoxPollingManager } from "../../../src/backend/hosts/metrics/proxmox-stats-polling.js";
import type { HostSessionStatusPayload } from "../../../src/backend/hosts/host-session-status.js";
import { pluginEvents, TOPICS } from "../../../src/backend/plugins/events.js";
import { AlertEngine } from "./alert-engine.js";
import {
notifyAutomationMetrics,
notifyAutomationStatus,
@@ -662,9 +661,6 @@ class PollingManager {
this.scheduleInitialMetricsPoll(config.host, config.viewerUserId);
}
}
AlertEngine.getInstance()
.evaluateStatus(refreshedHost.id, isOnline)
.catch(() => {});
notifyAutomationStatus(refreshedHost.id, refreshedHost.userId, isOnline);
} catch {
const statusEntry: StatusEntry = {
@@ -672,9 +668,6 @@ class PollingManager {
lastChecked: new Date().toISOString(),
};
this.statusStore.set(refreshedHost.id, statusEntry);
AlertEngine.getInstance()
.evaluateStatus(refreshedHost.id, false)
.catch(() => {});
notifyAutomationStatus(refreshedHost.id, refreshedHost.userId, false);
}
}
@@ -730,9 +723,6 @@ class PollingManager {
timestamp: Date.now(),
});
await this.insertMetricsHistory(refreshedHost.id, metrics);
AlertEngine.getInstance()
.evaluateMetrics(refreshedHost.id, metrics)
.catch(() => {});
notifyAutomationMetrics(refreshedHost.id, refreshedHost.userId, metrics);
pollingBackoff.reset(refreshedHost.id);
authFailureTracker.reset(refreshedHost.id);
@@ -1124,9 +1114,6 @@ app.post("/internal/login-alert", async (req, res) => {
sshUser,
fromIp,
});
AlertEngine.getInstance()
.evaluateUserLogin(hostId, userId, sshUser, fromIp)
.catch(() => {});
res.json({ ok: true });
});
+2 -4
View File
@@ -31,7 +31,7 @@ import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
import { registerTailscaleRoutes } from "./routes/tailscale-routes.js";
import vaultRoutes from "./routes/vault.js";
import secretSourceRoutes from "./routes/secret-sources.js";
import alertRulesRoutes from "./routes/alert-rules-routes.js";
import notificationChannelsRoutes from "./routes/notification-channels-routes.js";
import aiDispatch from "./routes/ai-dispatch.js";
import automationsDispatch from "./routes/automation-dispatch.js";
import syncRoutes from "./routes/sync.js";
@@ -1774,11 +1774,9 @@ registerAuditLogRoutes(app, authenticateJWT);
registerTailscaleRoutes(app, authenticateJWT);
app.use("/vault", vaultRoutes);
app.use("/secret-sources", secretSourceRoutes);
// Before the alert routes, which are mounted at the root and would otherwise
// have first claim on the path.
app.use("/automations", automationsDispatch);
app.use("/ai", aiDispatch);
app.use("/", alertRulesRoutes);
app.use("/", notificationChannelsRoutes);
app.use("/sync", syncRoutes);
app.use("/plugins", pluginRoutes);
app.use("/plugin-api", authenticateJWT, pluginApiRoutes);
@@ -207,18 +207,6 @@ export const PERFORMANCE_INDEXES: PerformanceIndex[] = [
columns: "user_id",
},
// Alerting.
{
name: "idx_alert_firings_rule",
table: "alert_firings",
columns: "rule_id, fired_at",
},
{
name: "idx_alert_firings_host",
table: "alert_firings",
columns: "host_id",
},
// Automations.
{
name: "idx_automations_user",
-60
View File
@@ -1440,26 +1440,6 @@ export const proxmoxNodeHistory = mysqlTable("proxmox_node_history", {
// --- proxmox-node-history end ---
// --- alerts begin ---
export const alertRules = mysqlTable("alert_rules", {
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: int("host_id").references(() => hosts.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
enabled: boolean("enabled").notNull().default(true),
triggerType: text("trigger_type").notNull(),
thresholdValue: double("threshold_value"),
thresholdDurationSeconds: int("threshold_duration_seconds"),
cooldownMinutes: int("cooldown_minutes").notNull().default(15),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
});
export const notificationChannels = mysqlTable("notification_channels", {
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
@@ -1473,46 +1453,6 @@ export const notificationChannels = mysqlTable("notification_channels", {
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
});
export const alertRuleChannels = mysqlTable("alert_rule_channels", {
id: int("id").autoincrement().primaryKey(),
ruleId: int("rule_id")
.notNull()
.references(() => alertRules.id, { onDelete: "cascade" }),
channelId: int("channel_id")
.notNull()
.references(() => notificationChannels.id, { onDelete: "cascade" }),
});
export const alertFirings = mysqlTable(
"alert_firings",
{
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
ruleId: int("rule_id")
.notNull()
.references(() => alertRules.id, { onDelete: "cascade" }),
hostId: int("host_id").notNull(),
hostName: text("host_name").notNull(),
firedAt: varchar("fired_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
resolvedAt: text("resolved_at"),
value: double("value"),
message: text("message").notNull(),
severity: text("severity").notNull().default("warning"),
acknowledged: boolean("acknowledged")
.notNull()
.default(false),
},
// A rule's history is read newest-first; host_id is filtered on its own.
(table) => [
index("idx_alert_firings_rule").on(table.ruleId, table.firedAt),
index("idx_alert_firings_host").on(table.hostId),
],
);
// --- alerts end ---
// --- automations begin ---
-60
View File
@@ -1441,26 +1441,6 @@ export const proxmoxNodeHistory = pgTable("proxmox_node_history", {
// --- proxmox-node-history end ---
// --- alerts begin ---
export const alertRules = pgTable("alert_rules", {
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id").references(() => hosts.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
enabled: boolean("enabled").notNull().default(true),
triggerType: text("trigger_type").notNull(),
thresholdValue: doublePrecision("threshold_value"),
thresholdDurationSeconds: integer("threshold_duration_seconds"),
cooldownMinutes: integer("cooldown_minutes").notNull().default(15),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const notificationChannels = pgTable("notification_channels", {
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
@@ -1474,46 +1454,6 @@ export const notificationChannels = pgTable("notification_channels", {
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const alertRuleChannels = pgTable("alert_rule_channels", {
id: serial("id").primaryKey(),
ruleId: integer("rule_id")
.notNull()
.references(() => alertRules.id, { onDelete: "cascade" }),
channelId: integer("channel_id")
.notNull()
.references(() => notificationChannels.id, { onDelete: "cascade" }),
});
export const alertFirings = pgTable(
"alert_firings",
{
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
ruleId: integer("rule_id")
.notNull()
.references(() => alertRules.id, { onDelete: "cascade" }),
hostId: integer("host_id").notNull(),
hostName: text("host_name").notNull(),
firedAt: varchar("fired_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
resolvedAt: text("resolved_at"),
value: doublePrecision("value"),
message: text("message").notNull(),
severity: text("severity").notNull().default("warning"),
acknowledged: boolean("acknowledged")
.notNull()
.default(false),
},
// A rule's history is read newest-first; host_id is filtered on its own.
(table) => [
index("idx_alert_firings_rule").on(table.ruleId, table.firedAt),
index("idx_alert_firings_host").on(table.hostId),
],
);
// --- alerts end ---
// --- automations begin ---
-60
View File
@@ -1439,26 +1439,6 @@ export const proxmoxNodeHistory = sqliteTable("proxmox_node_history", {
// --- proxmox-node-history end ---
// --- alerts begin ---
export const alertRules = sqliteTable("alert_rules", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id").references(() => hosts.id, { onDelete: "cascade" }),
name: text("name").notNull(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
triggerType: text("trigger_type").notNull(),
thresholdValue: real("threshold_value"),
thresholdDurationSeconds: integer("threshold_duration_seconds"),
cooldownMinutes: integer("cooldown_minutes").notNull().default(15),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const notificationChannels = sqliteTable("notification_channels", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
@@ -1472,46 +1452,6 @@ export const notificationChannels = sqliteTable("notification_channels", {
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const alertRuleChannels = sqliteTable("alert_rule_channels", {
id: integer("id").primaryKey({ autoIncrement: true }),
ruleId: integer("rule_id")
.notNull()
.references(() => alertRules.id, { onDelete: "cascade" }),
channelId: integer("channel_id")
.notNull()
.references(() => notificationChannels.id, { onDelete: "cascade" }),
});
export const alertFirings = sqliteTable(
"alert_firings",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
ruleId: integer("rule_id")
.notNull()
.references(() => alertRules.id, { onDelete: "cascade" }),
hostId: integer("host_id").notNull(),
hostName: text("host_name").notNull(),
firedAt: text("fired_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
resolvedAt: text("resolved_at"),
value: real("value"),
message: text("message").notNull(),
severity: text("severity").notNull().default("warning"),
acknowledged: integer("acknowledged", { mode: "boolean" })
.notNull()
.default(false),
},
// A rule's history is read newest-first; host_id is filtered on its own.
(table) => [
index("idx_alert_firings_rule").on(table.ruleId, table.firedAt),
index("idx_alert_firings_host").on(table.hostId),
],
);
// --- alerts end ---
// --- automations begin ---
@@ -47,7 +47,7 @@ export class AiRepository {
* Provider API keys are field-encrypted like any other credential. The key is
* derived from the row id, which does not exist until after the insert, so a
* new provider is written once and then re-encrypted in place with its real
* id -- the same approach AlertRepository uses for channel configs.
* id -- the same approach NotificationChannelRepository uses for channel configs.
*/
private userDataKey(userId: string): Buffer | null {
try {
@@ -1,712 +0,0 @@
import { and, count, desc, eq, inArray, isNull, lt, or } from "drizzle-orm";
import {
alertFirings,
alertRuleChannels,
alertRules,
hosts,
notificationChannels,
} from "../db/schema.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import type { DatabaseContext } from "./database-context.js";
import { sqlTimestampDaysAgo } from "./sql-timestamp.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning, updateReturning } from "./returning.js";
type AlertRuleRecord = typeof alertRules.$inferSelect;
type NotificationChannelRecord = typeof notificationChannels.$inferSelect;
type AlertFiringRecord = typeof alertFirings.$inferSelect;
export interface NotificationChannelRow {
id: number;
user_id: string;
name: string;
type: string;
config: string;
enabled: number;
created_at: string;
}
export interface AlertRuleRow {
id: number;
user_id: string;
host_id: number | null;
name: string;
enabled: number;
trigger_type: string;
threshold_value: number | null;
threshold_duration_seconds: number | null;
cooldown_minutes: number;
created_at: string;
updated_at: string;
}
export interface AlertRuleWithChannelsRow extends AlertRuleRow {
channels: number[];
}
export interface AlertFiringRow {
id: number;
user_id: string;
rule_id: number;
host_id: number;
host_name: string;
fired_at: string;
resolved_at: string | null;
value: number | null;
message: string;
severity: string;
acknowledged: number;
rule_name: string | null;
}
export interface AlertEngineRule {
id: number;
userId: string;
hostId: number | null;
name: string;
enabled: boolean;
triggerType: string;
thresholdValue: number | null;
thresholdDurationSeconds: number | null;
cooldownMinutes: number;
}
export interface AlertEngineChannel {
id: number;
type: string;
config: string;
enabled: boolean;
}
export class AlertRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
/**
* Channel configs carry ntfy tokens and webhook auth headers, so they are
* field-encrypted like any other secret. The key is derived from the row id,
* which does not exist until after the insert, so a new channel is written
* once and then re-encrypted in place with its real id.
*/
private userDataKey(userId: string): Buffer | null {
try {
return DataCrypto.getUserDataKey(userId);
} catch {
// Crypto is not initialized (tests, early boot); leave the value as is.
return null;
}
}
private encryptConfig(
config: string,
userId: string,
recordId: number | string,
): string {
const userDataKey = this.userDataKey(userId);
if (!userDataKey) return config;
return DataCrypto.encryptRecord(
"notification_channels",
{ id: recordId, config },
userId,
userDataKey,
).config;
}
private decryptConfig(
config: string,
userId: string,
recordId: number | string,
): string {
const userDataKey = this.userDataKey(userId);
if (!userDataKey) return config;
try {
return DataCrypto.decryptRecord(
"notification_channels",
{ id: recordId, config },
userId,
userDataKey,
).config;
} catch {
// Rows written before channel configs were encrypted are still plaintext.
return config;
}
}
async listNotificationChannels(
userId: string,
): Promise<NotificationChannelRow[]> {
const rows = await this.context.drizzle
.select()
.from(notificationChannels)
.where(eq(notificationChannels.userId, userId))
.orderBy(notificationChannels.id);
return rows.map((row) => {
const mapped = mapChannelRow(row);
mapped.config = this.decryptConfig(mapped.config, userId, mapped.id);
return mapped;
});
}
async findNotificationChannelForUser(
id: number,
userId: string,
): Promise<NotificationChannelRow | null> {
const rows = await this.context.drizzle
.select()
.from(notificationChannels)
.where(
and(
eq(notificationChannels.id, id),
eq(notificationChannels.userId, userId),
),
)
.limit(1);
if (!rows[0]) return null;
const mapped = mapChannelRow(rows[0]);
mapped.config = this.decryptConfig(mapped.config, userId, mapped.id);
return mapped;
}
async createNotificationChannel(input: {
userId: string;
name: string;
type: string;
config: string;
enabled: boolean;
}): Promise<NotificationChannelRow> {
const [created] = await insertReturning(
this.context,
notificationChannels,
{
userId: input.userId,
name: input.name,
type: input.type,
config: input.config,
enabled: input.enabled,
},
);
const encrypted = this.encryptConfig(
input.config,
input.userId,
created.id,
);
if (encrypted !== input.config) {
await this.context.drizzle
.update(notificationChannels)
.set({ config: encrypted })
.where(eq(notificationChannels.id, created.id));
}
await this.afterWrite();
const mapped = mapChannelRow(created);
mapped.config = input.config;
return mapped;
}
async updateNotificationChannel(
id: number,
userId: string,
input: {
name?: string;
type?: string;
config?: string;
enabled?: boolean;
},
): Promise<NotificationChannelRow | null> {
if (Object.keys(input).length === 0) {
return this.findNotificationChannelForUser(id, userId);
}
const values =
input.config !== undefined
? { ...input, config: this.encryptConfig(input.config, userId, id) }
: input;
const [updated] = await updateReturning(
this.context,
notificationChannels,
values,
and(
eq(notificationChannels.id, id),
eq(notificationChannels.userId, userId),
),
);
if (!updated) return null;
await this.afterWrite();
const mapped = mapChannelRow(updated);
mapped.config = this.decryptConfig(mapped.config, userId, mapped.id);
return mapped;
}
async deleteNotificationChannel(
id: number,
userId: string,
): Promise<boolean> {
const result = await this.context.drizzle
.delete(notificationChannels)
.where(
and(
eq(notificationChannels.id, id),
eq(notificationChannels.userId, userId),
),
);
if (rowsAffected(result) === 0) return false;
await this.afterWrite();
return true;
}
async listAlertRules(userId: string): Promise<AlertRuleWithChannelsRow[]> {
const rules = await this.context.drizzle
.select()
.from(alertRules)
.where(eq(alertRules.userId, userId))
.orderBy(alertRules.id);
const result: AlertRuleWithChannelsRow[] = [];
for (const rule of rules) {
result.push({
...mapRuleRow(rule),
channels: await this.listChannelIdsForRule(rule.id),
});
}
return result;
}
async createAlertRule(input: {
userId: string;
hostId: number | null;
name: string;
enabled: boolean;
triggerType: string;
thresholdValue: number | null;
thresholdDurationSeconds: number | null;
cooldownMinutes: number;
channels: number[];
now: string;
}): Promise<AlertRuleWithChannelsRow> {
const [created] = await insertReturning(this.context, alertRules, {
userId: input.userId,
hostId: input.hostId,
name: input.name,
enabled: input.enabled,
triggerType: input.triggerType,
thresholdValue: input.thresholdValue,
thresholdDurationSeconds: input.thresholdDurationSeconds,
cooldownMinutes: input.cooldownMinutes,
createdAt: input.now,
updatedAt: input.now,
});
const channels = await this.replaceRuleChannels(
created.id,
input.userId,
input.channels,
);
await this.afterWrite();
return { ...mapRuleRow(created), channels };
}
async findAlertRuleForUser(
id: number,
userId: string,
): Promise<AlertRuleRow | null> {
const rows = await this.context.drizzle
.select()
.from(alertRules)
.where(and(eq(alertRules.id, id), eq(alertRules.userId, userId)))
.limit(1);
return rows[0] ? mapRuleRow(rows[0]) : null;
}
async updateAlertRule(
id: number,
userId: string,
input: {
name?: string;
hostId?: number | null;
enabled?: boolean;
triggerType?: string;
thresholdValue?: number | null;
thresholdDurationSeconds?: number | null;
cooldownMinutes?: number;
channels?: number[];
now: string;
},
): Promise<AlertRuleWithChannelsRow | null> {
const [updated] = await updateReturning(
this.context,
alertRules,
{
...(input.name !== undefined ? { name: input.name } : {}),
...(input.hostId !== undefined ? { hostId: input.hostId } : {}),
...(input.enabled !== undefined ? { enabled: input.enabled } : {}),
...(input.triggerType !== undefined
? { triggerType: input.triggerType }
: {}),
...(input.thresholdValue !== undefined
? { thresholdValue: input.thresholdValue }
: {}),
...(input.thresholdDurationSeconds !== undefined
? { thresholdDurationSeconds: input.thresholdDurationSeconds }
: {}),
...(input.cooldownMinutes !== undefined
? { cooldownMinutes: input.cooldownMinutes }
: {}),
updatedAt: input.now,
},
and(eq(alertRules.id, id), eq(alertRules.userId, userId)),
);
if (!updated) return null;
const channels =
input.channels === undefined
? await this.listChannelIdsForRule(id)
: await this.replaceRuleChannels(id, userId, input.channels);
await this.afterWrite();
return { ...mapRuleRow(updated), channels };
}
async deleteAlertRule(id: number, userId: string): Promise<boolean> {
const result = await this.context.drizzle
.delete(alertRules)
.where(and(eq(alertRules.id, id), eq(alertRules.userId, userId)));
if (rowsAffected(result) === 0) return false;
await this.afterWrite();
return true;
}
async listAlertFirings(input: {
userId: string;
acknowledged?: boolean;
limit: number;
offset: number;
}): Promise<{ firings: AlertFiringRow[]; total: number }> {
const filters = [eq(alertFirings.userId, input.userId)];
if (input.acknowledged !== undefined) {
filters.push(eq(alertFirings.acknowledged, input.acknowledged));
}
const where = and(...filters);
const rows = await this.context.drizzle
.select({
firing: alertFirings,
ruleName: alertRules.name,
})
.from(alertFirings)
.leftJoin(alertRules, eq(alertRules.id, alertFirings.ruleId))
.where(where)
.orderBy(desc(alertFirings.firedAt))
.limit(input.limit)
.offset(input.offset);
const totalRows = await this.context.drizzle
.select({ total: count() })
.from(alertFirings)
.where(where);
return {
firings: rows.map((row) => mapFiringRow(row.firing, row.ruleName)),
total: totalRows[0]?.total ?? 0,
};
}
async acknowledgeFiring(id: number, userId: string): Promise<void> {
await this.context.drizzle
.update(alertFirings)
.set({ acknowledged: true })
.where(and(eq(alertFirings.id, id), eq(alertFirings.userId, userId)));
await this.afterWrite();
}
async acknowledgeAllFirings(userId: string): Promise<void> {
await this.context.drizzle
.update(alertFirings)
.set({ acknowledged: true })
.where(eq(alertFirings.userId, userId));
await this.afterWrite();
}
// A rule with host_id IS NULL means "all my hosts", not "all hosts on the
// server". Without joining the host back to its owner, every user's wildcard
// rule fired for every polled host and leaked other users' host names into
// their alerts.
async listEnabledRulesForHost(hostId: number): Promise<AlertEngineRule[]> {
const rows = await this.context.drizzle
.select({ rule: alertRules })
.from(alertRules)
.innerJoin(hosts, eq(hosts.id, hostId))
.where(
and(
eq(alertRules.enabled, true),
or(
eq(alertRules.hostId, hostId),
and(isNull(alertRules.hostId), eq(alertRules.userId, hosts.userId)),
),
),
);
return rows.map((row) => mapEngineRule(row.rule));
}
async listEnabledRulesForHostUser(
hostId: number,
userId: string,
): Promise<AlertEngineRule[]> {
const rows = await this.context.drizzle
.select()
.from(alertRules)
.where(
and(
eq(alertRules.enabled, true),
eq(alertRules.userId, userId),
or(eq(alertRules.hostId, hostId), isNull(alertRules.hostId)),
),
);
return rows.map(mapEngineRule);
}
async findRuleById(id: number): Promise<AlertEngineRule | null> {
const rows = await this.context.drizzle
.select()
.from(alertRules)
.where(eq(alertRules.id, id))
.limit(1);
return rows[0] ? mapEngineRule(rows[0]) : null;
}
async createFiring(input: {
userId: string;
ruleId: number;
hostId: number;
hostName: string;
value: number | null;
message: string;
severity: string;
}): Promise<void> {
await this.context.drizzle.insert(alertFirings).values(input);
await this.afterWrite();
}
async pruneFiringsOlderThan(userId: string, days: number): Promise<void> {
await this.context.drizzle
.delete(alertFirings)
.where(
and(
eq(alertFirings.userId, userId),
lt(alertFirings.firedAt, sqlTimestampDaysAgo(days)),
),
);
}
async deleteByUserId(userId: string): Promise<{
firingsDeleted: number;
ruleLinksDeleted: number;
rulesDeleted: number;
channelsDeleted: number;
}> {
const ruleIds = (
await this.context.drizzle
.select({ id: alertRules.id })
.from(alertRules)
.where(eq(alertRules.userId, userId))
).map((row) => row.id);
const channelIds = (
await this.context.drizzle
.select({ id: notificationChannels.id })
.from(notificationChannels)
.where(eq(notificationChannels.userId, userId))
).map((row) => row.id);
const firingResult = await this.context.drizzle
.delete(alertFirings)
.where(eq(alertFirings.userId, userId));
const linkFilters = [
...(ruleIds.length > 0
? [inArray(alertRuleChannels.ruleId, ruleIds)]
: []),
...(channelIds.length > 0
? [inArray(alertRuleChannels.channelId, channelIds)]
: []),
];
const linkResult =
linkFilters.length === 0
? null
: await this.context.drizzle
.delete(alertRuleChannels)
.where(or(...linkFilters));
const ruleResult = await this.context.drizzle
.delete(alertRules)
.where(eq(alertRules.userId, userId));
const result = await this.context.drizzle
.delete(notificationChannels)
.where(eq(notificationChannels.userId, userId));
if (
rowsAffected(firingResult) > 0 ||
rowsAffected(linkResult) > 0 ||
rowsAffected(ruleResult) > 0 ||
rowsAffected(result) > 0
) {
await this.afterWrite();
}
return {
firingsDeleted: rowsAffected(firingResult),
ruleLinksDeleted: rowsAffected(linkResult),
rulesDeleted: rowsAffected(ruleResult),
channelsDeleted: rowsAffected(result),
};
}
async listEnabledChannelsForRule(
ruleId: number,
): Promise<AlertEngineChannel[]> {
const rows = await this.context.drizzle
.select({
id: notificationChannels.id,
userId: notificationChannels.userId,
type: notificationChannels.type,
config: notificationChannels.config,
enabled: notificationChannels.enabled,
})
.from(notificationChannels)
.innerJoin(
alertRuleChannels,
eq(alertRuleChannels.channelId, notificationChannels.id),
)
.where(
and(
eq(alertRuleChannels.ruleId, ruleId),
eq(notificationChannels.enabled, true),
),
);
// The engine sends without a user in scope, so decrypt against the owner.
return rows.map(({ userId, ...channel }) => ({
...channel,
config: this.decryptConfig(channel.config, userId, channel.id),
}));
}
async getHostDisplayName(hostId: number): Promise<string | null> {
const rows = await this.context.drizzle
.select({ name: hosts.name, ip: hosts.ip })
.from(hosts)
.where(eq(hosts.id, hostId))
.limit(1);
const row = rows[0];
return row ? row.name || row.ip : null;
}
private async replaceRuleChannels(
ruleId: number,
userId: string,
channelIds: number[],
): Promise<number[]> {
await this.context.drizzle
.delete(alertRuleChannels)
.where(eq(alertRuleChannels.ruleId, ruleId));
const linked: number[] = [];
for (const channelId of channelIds) {
const channel = await this.findNotificationChannelForUser(
channelId,
userId,
);
if (!channel) continue;
await this.context.drizzle
.insert(alertRuleChannels)
.values({ ruleId, channelId });
linked.push(channelId);
}
return linked;
}
private async listChannelIdsForRule(ruleId: number): Promise<number[]> {
const rows = await this.context.drizzle
.select({ channelId: alertRuleChannels.channelId })
.from(alertRuleChannels)
.where(eq(alertRuleChannels.ruleId, ruleId));
return rows.map((row) => row.channelId);
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
function mapChannelRow(row: NotificationChannelRecord): NotificationChannelRow {
return {
id: row.id,
user_id: row.userId,
name: row.name,
type: row.type,
config: row.config,
enabled: row.enabled ? 1 : 0,
created_at: row.createdAt,
};
}
function mapRuleRow(row: AlertRuleRecord): AlertRuleRow {
return {
id: row.id,
user_id: row.userId,
host_id: row.hostId,
name: row.name,
enabled: row.enabled ? 1 : 0,
trigger_type: row.triggerType,
threshold_value: row.thresholdValue,
threshold_duration_seconds: row.thresholdDurationSeconds,
cooldown_minutes: row.cooldownMinutes,
created_at: row.createdAt,
updated_at: row.updatedAt,
};
}
function mapFiringRow(
row: AlertFiringRecord,
ruleName: string | null,
): AlertFiringRow {
return {
id: row.id,
user_id: row.userId,
rule_id: row.ruleId,
host_id: row.hostId,
host_name: row.hostName,
fired_at: row.firedAt,
resolved_at: row.resolvedAt,
value: row.value,
message: row.message,
severity: row.severity,
acknowledged: row.acknowledged ? 1 : 0,
rule_name: ruleName,
};
}
function mapEngineRule(row: AlertRuleRecord): AlertEngineRule {
return {
id: row.id,
userId: row.userId,
hostId: row.hostId,
name: row.name,
enabled: row.enabled,
triggerType: row.triggerType,
thresholdValue: row.thresholdValue,
thresholdDurationSeconds: row.thresholdDurationSeconds,
cooldownMinutes: row.cooldownMinutes,
};
}
+4 -4
View File
@@ -5,7 +5,7 @@ import { primeSettingsCache, readCachedSetting } from "./settings-cache.js";
import type { DatabaseContext } from "./database-context.js";
import { WebauthnCredentialRepository } from "./webauthn-credential-repository.js";
import { AiRepository } from "./ai-repository.js";
import { AlertRepository } from "./alert-repository.js";
import { NotificationChannelRepository } from "./notification-channel-repository.js";
import { AutomationRepository } from "./automation-repository.js";
import { ApiKeyRepository } from "./api-key-repository.js";
import { AuditLogRepository } from "./audit-log-repository.js";
@@ -161,10 +161,10 @@ export function createCurrentAiRepository(): AiRepository {
);
}
export function createCurrentAlertRepository(): AlertRepository {
return new AlertRepository(
export function createCurrentNotificationChannelRepository(): NotificationChannelRepository {
return new NotificationChannelRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("alert_repository_write"),
createCurrentRepositoryWriteHook("notification_channel_repository_write"),
);
}
@@ -0,0 +1,231 @@
import { and, eq } from "drizzle-orm";
import { notificationChannels } from "../db/schema.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning, updateReturning } from "./returning.js";
type NotificationChannelRecord = typeof notificationChannels.$inferSelect;
export interface NotificationChannelRow {
id: number;
user_id: string;
name: string;
type: string;
config: string;
enabled: number;
created_at: string;
}
export class NotificationChannelRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
/**
* Channel configs carry ntfy tokens and webhook auth headers, so they are
* field-encrypted like any other secret. The key is derived from the row id,
* which does not exist until after the insert, so a new channel is written
* once and then re-encrypted in place with its real id.
*/
private userDataKey(userId: string): Buffer | null {
try {
return DataCrypto.getUserDataKey(userId);
} catch {
// Crypto is not initialized (tests, early boot); leave the value as is.
return null;
}
}
private encryptConfig(
config: string,
userId: string,
recordId: number | string,
): string {
const userDataKey = this.userDataKey(userId);
if (!userDataKey) return config;
return DataCrypto.encryptRecord(
"notification_channels",
{ id: recordId, config },
userId,
userDataKey,
).config;
}
private decryptConfig(
config: string,
userId: string,
recordId: number | string,
): string {
const userDataKey = this.userDataKey(userId);
if (!userDataKey) return config;
try {
return DataCrypto.decryptRecord(
"notification_channels",
{ id: recordId, config },
userId,
userDataKey,
).config;
} catch {
// Rows written before channel configs were encrypted are still plaintext.
return config;
}
}
async listNotificationChannels(
userId: string,
): Promise<NotificationChannelRow[]> {
const rows = await this.context.drizzle
.select()
.from(notificationChannels)
.where(eq(notificationChannels.userId, userId))
.orderBy(notificationChannels.id);
return rows.map((row) => {
const mapped = mapChannelRow(row);
mapped.config = this.decryptConfig(mapped.config, userId, mapped.id);
return mapped;
});
}
async findNotificationChannelForUser(
id: number,
userId: string,
): Promise<NotificationChannelRow | null> {
const rows = await this.context.drizzle
.select()
.from(notificationChannels)
.where(
and(
eq(notificationChannels.id, id),
eq(notificationChannels.userId, userId),
),
)
.limit(1);
if (!rows[0]) return null;
const mapped = mapChannelRow(rows[0]);
mapped.config = this.decryptConfig(mapped.config, userId, mapped.id);
return mapped;
}
async createNotificationChannel(input: {
userId: string;
name: string;
type: string;
config: string;
enabled: boolean;
}): Promise<NotificationChannelRow> {
const [created] = await insertReturning(
this.context,
notificationChannels,
{
userId: input.userId,
name: input.name,
type: input.type,
config: input.config,
enabled: input.enabled,
},
);
const encrypted = this.encryptConfig(
input.config,
input.userId,
created.id,
);
if (encrypted !== input.config) {
await this.context.drizzle
.update(notificationChannels)
.set({ config: encrypted })
.where(eq(notificationChannels.id, created.id));
}
await this.afterWrite();
const mapped = mapChannelRow(created);
mapped.config = input.config;
return mapped;
}
async updateNotificationChannel(
id: number,
userId: string,
input: {
name?: string;
type?: string;
config?: string;
enabled?: boolean;
},
): Promise<NotificationChannelRow | null> {
if (Object.keys(input).length === 0) {
return this.findNotificationChannelForUser(id, userId);
}
const values =
input.config !== undefined
? { ...input, config: this.encryptConfig(input.config, userId, id) }
: input;
const [updated] = await updateReturning(
this.context,
notificationChannels,
values,
and(
eq(notificationChannels.id, id),
eq(notificationChannels.userId, userId),
),
);
if (!updated) return null;
await this.afterWrite();
const mapped = mapChannelRow(updated);
mapped.config = this.decryptConfig(mapped.config, userId, mapped.id);
return mapped;
}
async deleteNotificationChannel(
id: number,
userId: string,
): Promise<boolean> {
const result = await this.context.drizzle
.delete(notificationChannels)
.where(
and(
eq(notificationChannels.id, id),
eq(notificationChannels.userId, userId),
),
);
if (rowsAffected(result) === 0) return false;
await this.afterWrite();
return true;
}
async deleteByUserId(userId: string): Promise<{ channelsDeleted: number }> {
const result = await this.context.drizzle
.delete(notificationChannels)
.where(eq(notificationChannels.userId, userId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return { channelsDeleted: rowsAffected(result) };
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
function mapChannelRow(row: NotificationChannelRecord): NotificationChannelRow {
return {
id: row.id,
user_id: row.userId,
name: row.name,
type: row.type,
config: row.config,
enabled: row.enabled ? 1 : 0,
created_at: row.createdAt,
};
}
@@ -1,534 +0,0 @@
import express, { type Request, type Response } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { createCurrentAlertRepository } from "../repositories/factory.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { databaseLogger } from "../../utils/logger.js";
import { sendWebhook, sendNtfy } from "../../utils/notification-sender.js";
import { sendDiscord } from "../../utils/discord-sender.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const VALID_TRIGGER_TYPES = new Set([
"host_offline",
"host_online",
"cpu_threshold",
"memory_threshold",
"disk_threshold",
"health_check_failure",
"health_check_recovery",
"user_login",
]);
router.use(authenticateJWT);
// ---- Notification Channels ----
/**
* @openapi
* /notification-channels:
* get:
* summary: List notification channels for the current user
* tags:
* - Alerts
* responses:
* 200:
* description: List of notification channels.
*/
router.get("/notification-channels", async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const rows =
await createCurrentAlertRepository().listNotificationChannels(userId);
res.json(rows);
} catch (err) {
databaseLogger.error("Failed to list notification channels", {
operation: "list_channels",
error: err,
});
res.status(500).json({ error: "Failed to list channels" });
}
});
/**
* @openapi
* /notification-channels:
* post:
* summary: Create a notification channel
* tags:
* - Alerts
*/
router.post("/notification-channels", async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const { name, type, config, enabled } = req.body as {
name: string;
type: string;
config: unknown;
enabled?: boolean;
};
if (!name || typeof name !== "string" || !name.trim()) {
return res.status(400).json({ error: "name is required" });
}
if (type !== "webhook" && type !== "ntfy" && type !== "discord") {
return res
.status(400)
.json({ error: "type must be 'webhook', 'ntfy' or 'discord'" });
}
if (!config || typeof config !== "object") {
return res.status(400).json({ error: "config is required" });
}
if (type === "ntfy") {
const c = config as Record<string, unknown>;
if (!c.url || typeof c.url !== "string")
return res.status(400).json({ error: "ntfy config requires url" });
if (!c.topic || typeof c.topic !== "string")
return res.status(400).json({ error: "ntfy config requires topic" });
}
if (type === "webhook") {
const c = config as Record<string, unknown>;
if (!c.url || typeof c.url !== "string")
return res.status(400).json({ error: "webhook config requires url" });
}
if (type === "discord") {
const c = config as Record<string, unknown>;
if (!c.url || typeof c.url !== "string")
return res.status(400).json({ error: "discord config requires url" });
if (
!/^https:\/\/(?:canary\.|ptb\.)?(?:discord\.com|discordapp\.com)\/api\/webhooks\/.+/i.test(
c.url,
)
) {
return res.status(400).json({
error:
"discord config requires a valid Discord webhook URL (https://discord.com/api/webhooks/...)",
});
}
}
try {
const row = await createCurrentAlertRepository().createNotificationChannel({
userId,
name: name.trim(),
type,
config: JSON.stringify(config),
enabled: enabled !== false,
});
res.status(201).json(row);
} catch (err) {
databaseLogger.error("Failed to create notification channel", {
operation: "create_channel",
error: err,
});
res.status(500).json({ error: "Failed to create channel" });
}
});
/**
* @openapi
* /notification-channels/{id}:
* put:
* summary: Update a notification channel
* tags:
* - Alerts
*/
router.put(
"/notification-channels/:id",
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const channelId = Number(req.params.id);
const { name, type, config, enabled } = req.body as {
name?: string;
type?: string;
config?: unknown;
enabled?: boolean;
};
const repository = createCurrentAlertRepository();
const existing = await repository.findNotificationChannelForUser(
channelId,
userId,
);
if (!existing) return res.status(404).json({ error: "Channel not found" });
if (type && type !== "webhook" && type !== "ntfy" && type !== "discord") {
return res
.status(400)
.json({ error: "type must be 'webhook', 'ntfy' or 'discord'" });
}
if (
name === undefined &&
type === undefined &&
config === undefined &&
enabled === undefined
) {
return res.json({ success: true });
}
try {
const row = await repository.updateNotificationChannel(
channelId,
userId,
{
...(name !== undefined ? { name: name.trim() } : {}),
...(type !== undefined ? { type } : {}),
...(config !== undefined ? { config: JSON.stringify(config) } : {}),
...(enabled !== undefined ? { enabled } : {}),
},
);
if (!row) return res.status(404).json({ error: "Channel not found" });
res.json(row);
} catch (err) {
databaseLogger.error("Failed to update notification channel", {
operation: "update_channel",
error: err,
});
res.status(500).json({ error: "Failed to update channel" });
}
},
);
/**
* @openapi
* /notification-channels/{id}:
* delete:
* summary: Delete a notification channel
* tags:
* - Alerts
*/
router.delete(
"/notification-channels/:id",
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const channelId = Number(req.params.id);
const deleted =
await createCurrentAlertRepository().deleteNotificationChannel(
channelId,
userId,
);
if (!deleted) return res.status(404).json({ error: "Channel not found" });
res.json({ success: true });
},
);
/**
* @openapi
* /notification-channels/{id}/test:
* post:
* summary: Send a test notification
* tags:
* - Alerts
*/
router.post(
"/notification-channels/:id/test",
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const channelId = Number(req.params.id);
const row =
await createCurrentAlertRepository().findNotificationChannelForUser(
channelId,
userId,
);
if (!row) return res.status(404).json({ error: "Channel not found" });
const testPayload = {
hostName: "Test Host",
hostId: 0,
triggerType: "test",
message: "This is a test notification from Termix",
severity: "info" as const,
timestamp: new Date().toISOString(),
ruleId: 0,
ruleName: "Test",
};
try {
let config: Record<string, unknown>;
try {
config = JSON.parse(row.config) as Record<string, unknown>;
} catch {
return res
.status(400)
.json({ success: false, error: "Invalid channel config" });
}
if (row.type === "webhook") {
await sendWebhook(
config as unknown as Parameters<typeof sendWebhook>[0],
testPayload,
);
} else if (row.type === "ntfy") {
await sendNtfy(
config as unknown as Parameters<typeof sendNtfy>[0],
testPayload,
);
} else if (row.type === "discord") {
await sendDiscord(
config as unknown as Parameters<typeof sendDiscord>[0],
testPayload,
);
}
res.json({ success: true });
} catch (err) {
res.json({
success: false,
error: err instanceof Error ? err.message : String(err),
});
}
},
);
// ---- Alert Rules ----
/**
* @openapi
* /alert-rules:
* get:
* summary: List alert rules for the current user
* tags:
* - Alerts
*/
router.get("/alert-rules", async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
res.json(await createCurrentAlertRepository().listAlertRules(userId));
} catch (err) {
databaseLogger.error("Failed to list alert rules", {
operation: "list_alert_rules",
error: err,
});
res.status(500).json({ error: "Failed to list alert rules" });
}
});
/**
* @openapi
* /alert-rules:
* post:
* summary: Create an alert rule
* tags:
* - Alerts
*/
router.post("/alert-rules", async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const {
name,
hostId,
enabled,
triggerType,
thresholdValue,
thresholdDurationSeconds,
cooldownMinutes,
channels = [],
} = req.body as {
name: string;
hostId?: number | null;
enabled?: boolean;
triggerType: string;
thresholdValue?: number | null;
thresholdDurationSeconds?: number | null;
cooldownMinutes?: number;
channels?: number[];
};
if (!name || typeof name !== "string" || !name.trim()) {
return res.status(400).json({ error: "name is required" });
}
if (!VALID_TRIGGER_TYPES.has(triggerType)) {
return res.status(400).json({ error: "Invalid triggerType" });
}
if (thresholdValue != null && (thresholdValue < 0 || thresholdValue > 100)) {
return res
.status(400)
.json({ error: "thresholdValue must be between 0 and 100" });
}
if (thresholdDurationSeconds != null && thresholdDurationSeconds < 0) {
return res
.status(400)
.json({ error: "thresholdDurationSeconds must be >= 0" });
}
try {
const now = new Date().toISOString();
const row = await createCurrentAlertRepository().createAlertRule({
userId,
hostId: hostId ?? null,
name: name.trim(),
enabled: enabled !== false,
triggerType,
thresholdValue: thresholdValue ?? null,
thresholdDurationSeconds: thresholdDurationSeconds ?? null,
cooldownMinutes: cooldownMinutes ?? 15,
channels,
now,
});
res.status(201).json({ ...row, channels });
} catch (err) {
databaseLogger.error("Failed to create alert rule", {
operation: "create_alert_rule",
error: err,
});
res.status(500).json({ error: "Failed to create alert rule" });
}
});
/**
* @openapi
* /alert-rules/{id}:
* put:
* summary: Update an alert rule
* tags:
* - Alerts
*/
router.put("/alert-rules/:id", async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const ruleId = Number(req.params.id);
const repository = createCurrentAlertRepository();
const existing = await repository.findAlertRuleForUser(ruleId, userId);
if (!existing) return res.status(404).json({ error: "Alert rule not found" });
const {
name,
hostId,
enabled,
triggerType,
thresholdValue,
thresholdDurationSeconds,
cooldownMinutes,
channels,
} = req.body as {
name?: string;
hostId?: number | null;
enabled?: boolean;
triggerType?: string;
thresholdValue?: number | null;
thresholdDurationSeconds?: number | null;
cooldownMinutes?: number;
channels?: number[];
};
if (triggerType && !VALID_TRIGGER_TYPES.has(triggerType)) {
return res.status(400).json({ error: "Invalid triggerType" });
}
try {
const row = await repository.updateAlertRule(ruleId, userId, {
...(name !== undefined ? { name: name.trim() } : {}),
...(hostId !== undefined ? { hostId: hostId ?? null } : {}),
...(enabled !== undefined ? { enabled } : {}),
...(triggerType !== undefined ? { triggerType } : {}),
...(thresholdValue !== undefined
? { thresholdValue: thresholdValue ?? null }
: {}),
...(thresholdDurationSeconds !== undefined
? { thresholdDurationSeconds: thresholdDurationSeconds ?? null }
: {}),
...(cooldownMinutes !== undefined ? { cooldownMinutes } : {}),
...(channels !== undefined ? { channels } : {}),
now: new Date().toISOString(),
});
if (!row) return res.status(404).json({ error: "Alert rule not found" });
res.json(row);
} catch (err) {
databaseLogger.error("Failed to update alert rule", {
operation: "update_alert_rule",
error: err,
});
res.status(500).json({ error: "Failed to update alert rule" });
}
});
/**
* @openapi
* /alert-rules/{id}:
* delete:
* summary: Delete an alert rule
* tags:
* - Alerts
*/
router.delete("/alert-rules/:id", async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const ruleId = Number(req.params.id);
const deleted = await createCurrentAlertRepository().deleteAlertRule(
ruleId,
userId,
);
if (!deleted) return res.status(404).json({ error: "Alert rule not found" });
res.json({ success: true });
});
// ---- Alert Firings ----
/**
* @openapi
* /alert-firings:
* get:
* summary: List alert firings for the current user
* tags:
* - Alerts
*/
router.get("/alert-firings", async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const limit = Math.min(Number(req.query.limit) || 50, 200);
const offset = Number(req.query.offset) || 0;
const acknowledgedParam = req.query.acknowledged;
try {
const acknowledged =
acknowledgedParam === "true"
? true
: acknowledgedParam === "false"
? false
: undefined;
res.json(
await createCurrentAlertRepository().listAlertFirings({
userId,
acknowledged,
limit,
offset,
}),
);
} catch (err) {
databaseLogger.error("Failed to list alert firings", {
operation: "list_alert_firings",
error: err,
});
res.status(500).json({ error: "Failed to list alert firings" });
}
});
/**
* @openapi
* /alert-firings/{id}/acknowledge:
* post:
* summary: Acknowledge an alert firing
* tags:
* - Alerts
*/
router.post(
"/alert-firings/:id/acknowledge",
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const firingId = Number(req.params.id);
await createCurrentAlertRepository().acknowledgeFiring(firingId, userId);
res.json({ success: true });
},
);
/**
* @openapi
* /alert-firings/acknowledge-all:
* post:
* summary: Acknowledge all alert firings for the current user
* tags:
* - Alerts
*/
router.post("/alert-firings/acknowledge-all", async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
await createCurrentAlertRepository().acknowledgeAllFirings(userId);
res.json({ success: true });
});
export default router;
@@ -1,7 +1,7 @@
import { authLogger } from "../../utils/logger.js";
import {
createCurrentAiRepository,
createCurrentAlertRepository,
createCurrentNotificationChannelRepository,
createCurrentApiKeyRepository,
createCurrentAuditLogRepository,
createCurrentC2sTunnelPresetRepository,
@@ -82,7 +82,7 @@ export async function deleteUserAndRelatedData(
await createCurrentRoleRepository().removeAllRolesFromUser(userId);
await createCurrentAiRepository().deleteByUserId(userId);
await createCurrentAlertRepository().deleteByUserId(userId);
await createCurrentNotificationChannelRepository().deleteByUserId(userId);
await createCurrentAuditLogRepository().anonymizeByUserId(userId);
await createCurrentSshCredentialUsageRepository().deleteByUserId(userId);
@@ -0,0 +1,274 @@
import express, { type Request, type Response } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { createCurrentNotificationChannelRepository } from "../repositories/factory.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { databaseLogger } from "../../utils/logger.js";
import { sendWebhook, sendNtfy } from "../../utils/notification-sender.js";
import { sendDiscord } from "../../utils/discord-sender.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
router.use(authenticateJWT);
/**
* @openapi
* /notification-channels:
* get:
* summary: List notification channels for the current user
* tags:
* - Notifications
* responses:
* 200:
* description: List of notification channels.
*/
router.get("/notification-channels", async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const rows =
await createCurrentNotificationChannelRepository().listNotificationChannels(
userId,
);
res.json(rows);
} catch (err) {
databaseLogger.error("Failed to list notification channels", {
operation: "list_channels",
error: err,
});
res.status(500).json({ error: "Failed to list channels" });
}
});
/**
* @openapi
* /notification-channels:
* post:
* summary: Create a notification channel
* tags:
* - Notifications
*/
router.post("/notification-channels", async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const { name, type, config, enabled } = req.body as {
name: string;
type: string;
config: unknown;
enabled?: boolean;
};
if (!name || typeof name !== "string" || !name.trim()) {
return res.status(400).json({ error: "name is required" });
}
if (type !== "webhook" && type !== "ntfy" && type !== "discord") {
return res
.status(400)
.json({ error: "type must be 'webhook', 'ntfy' or 'discord'" });
}
if (!config || typeof config !== "object") {
return res.status(400).json({ error: "config is required" });
}
if (type === "ntfy") {
const c = config as Record<string, unknown>;
if (!c.url || typeof c.url !== "string")
return res.status(400).json({ error: "ntfy config requires url" });
if (!c.topic || typeof c.topic !== "string")
return res.status(400).json({ error: "ntfy config requires topic" });
}
if (type === "webhook") {
const c = config as Record<string, unknown>;
if (!c.url || typeof c.url !== "string")
return res.status(400).json({ error: "webhook config requires url" });
}
if (type === "discord") {
const c = config as Record<string, unknown>;
if (!c.url || typeof c.url !== "string")
return res.status(400).json({ error: "discord config requires url" });
if (
!/^https:\/\/(?:canary\.|ptb\.)?(?:discord\.com|discordapp\.com)\/api\/webhooks\/.+/i.test(
c.url,
)
) {
return res.status(400).json({
error:
"discord config requires a valid Discord webhook URL (https://discord.com/api/webhooks/...)",
});
}
}
try {
const row =
await createCurrentNotificationChannelRepository().createNotificationChannel(
{
userId,
name: name.trim(),
type,
config: JSON.stringify(config),
enabled: enabled !== false,
},
);
res.status(201).json(row);
} catch (err) {
databaseLogger.error("Failed to create notification channel", {
operation: "create_channel",
error: err,
});
res.status(500).json({ error: "Failed to create channel" });
}
});
/**
* @openapi
* /notification-channels/{id}:
* put:
* summary: Update a notification channel
* tags:
* - Notifications
*/
router.put(
"/notification-channels/:id",
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const channelId = Number(req.params.id);
const { name, type, config, enabled } = req.body as {
name?: string;
type?: string;
config?: unknown;
enabled?: boolean;
};
const repository = createCurrentNotificationChannelRepository();
const existing = await repository.findNotificationChannelForUser(
channelId,
userId,
);
if (!existing) return res.status(404).json({ error: "Channel not found" });
if (type && type !== "webhook" && type !== "ntfy" && type !== "discord") {
return res
.status(400)
.json({ error: "type must be 'webhook', 'ntfy' or 'discord'" });
}
if (
name === undefined &&
type === undefined &&
config === undefined &&
enabled === undefined
) {
return res.json({ success: true });
}
try {
const row = await repository.updateNotificationChannel(
channelId,
userId,
{
...(name !== undefined ? { name: name.trim() } : {}),
...(type !== undefined ? { type } : {}),
...(config !== undefined ? { config: JSON.stringify(config) } : {}),
...(enabled !== undefined ? { enabled } : {}),
},
);
if (!row) return res.status(404).json({ error: "Channel not found" });
res.json(row);
} catch (err) {
databaseLogger.error("Failed to update notification channel", {
operation: "update_channel",
error: err,
});
res.status(500).json({ error: "Failed to update channel" });
}
},
);
/**
* @openapi
* /notification-channels/{id}:
* delete:
* summary: Delete a notification channel
* tags:
* - Notifications
*/
router.delete(
"/notification-channels/:id",
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const channelId = Number(req.params.id);
const deleted =
await createCurrentNotificationChannelRepository().deleteNotificationChannel(
channelId,
userId,
);
if (!deleted) return res.status(404).json({ error: "Channel not found" });
res.json({ success: true });
},
);
/**
* @openapi
* /notification-channels/{id}/test:
* post:
* summary: Send a test notification
* tags:
* - Notifications
*/
router.post(
"/notification-channels/:id/test",
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const channelId = Number(req.params.id);
const row =
await createCurrentNotificationChannelRepository().findNotificationChannelForUser(
channelId,
userId,
);
if (!row) return res.status(404).json({ error: "Channel not found" });
const testPayload = {
hostName: "Test Host",
hostId: 0,
triggerType: "test",
message: "This is a test notification from Termix",
severity: "info" as const,
timestamp: new Date().toISOString(),
ruleId: 0,
ruleName: "Test",
};
try {
let config: Record<string, unknown>;
try {
config = JSON.parse(row.config) as Record<string, unknown>;
} catch {
return res
.status(400)
.json({ success: false, error: "Invalid channel config" });
}
if (row.type === "webhook") {
await sendWebhook(
config as unknown as Parameters<typeof sendWebhook>[0],
testPayload,
);
} else if (row.type === "ntfy") {
await sendNtfy(
config as unknown as Parameters<typeof sendNtfy>[0],
testPayload,
);
} else if (row.type === "discord") {
await sendDiscord(
config as unknown as Parameters<typeof sendDiscord>[0],
testPayload,
);
}
res.json({ success: true });
} catch (err) {
res.json({
success: false,
error: err instanceof Error ? err.message : String(err),
});
}
},
);
export default router;
+2 -2
View File
@@ -57,7 +57,7 @@ import {
} from "../terminal-auth-helpers.js";
import { isWindowsSftpPath, sftpPathToLocalPath } from "../transfer-paths.js";
import { preparePrivateKeyForSSH2 } from "../../utils/ssh-key-utils.js";
import { triggerLoginAlert } from "../../utils/alert-trigger.js";
import { dispatchLoginEvent } from "../../utils/login-event-dispatch.js";
import { getClientIp } from "../../utils/request-origin.js";
import { isRetriableDnsError, resolveHostForSshConnect } from "../ssh-dns.js";
import { resolveSshKeepalive } from "../ssh-keepalive.js";
@@ -2483,7 +2483,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
);
if (id && hostConfig.userId) {
triggerLoginAlert(
dispatchLoginEvent(
id,
hostConfig.userId,
username,
-4
View File
@@ -265,10 +265,6 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
await import("./utils/crypto-migration/channel-config-encryption.js");
await runChannelConfigEncryptionMigration();
const { runAutomationsMigration } =
await import("./utils/crypto-migration/automations-migration.js");
await runAutomationsMigration();
if (process.env.ELECTRON_EMBEDDED === "true") {
await provisionLocalDesktopUserIfNeeded();
}
@@ -1,471 +0,0 @@
import crypto from "node:crypto";
import { sql } from "drizzle-orm";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { AlertRepository } from "../../../database/repositories/alert-repository.js";
import { DataCrypto } from "../../../utils/data-crypto.js";
describe("AlertRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<AlertRepository> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
VALUES (1, 'user-1', 'alpha', '127.0.0.1', 22, 'root', 'password');
`);
return new AlertRepository(context, onWrite);
}
it("manages notification channels", async () => {
let writes = 0;
const repo = await createRepository(() => {
writes += 1;
});
const created = await repo.createNotificationChannel({
userId: "user-1",
name: "Ops",
type: "webhook",
config: '{"url":"https://example.test"}',
enabled: true,
});
expect(created).toMatchObject({
user_id: "user-1",
name: "Ops",
type: "webhook",
enabled: 1,
});
const updated = await repo.updateNotificationChannel(created.id, "user-1", {
name: "Ops disabled",
enabled: false,
});
expect(updated).toMatchObject({ name: "Ops disabled", enabled: 0 });
expect(await repo.listNotificationChannels("user-1")).toHaveLength(1);
expect(await repo.deleteNotificationChannel(created.id, "user-2")).toBe(
false,
);
expect(await repo.deleteNotificationChannel(created.id, "user-1")).toBe(
true,
);
expect(await repo.listNotificationChannels("user-1")).toHaveLength(0);
expect(writes).toBe(3);
});
it("manages alert rules and linked channels", async () => {
const repo = await createRepository();
const ownedChannel = await repo.createNotificationChannel({
userId: "user-1",
name: "Owned",
type: "ntfy",
config: '{"url":"https://ntfy.test","topic":"termix"}',
enabled: true,
});
const foreignChannel = await repo.createNotificationChannel({
userId: "user-2",
name: "Foreign",
type: "webhook",
config: '{"url":"https://example.test"}',
enabled: true,
});
const created = await repo.createAlertRule({
userId: "user-1",
hostId: null,
name: "CPU high",
enabled: true,
triggerType: "cpu_threshold",
thresholdValue: 80,
thresholdDurationSeconds: 30,
cooldownMinutes: 5,
channels: [ownedChannel.id, foreignChannel.id],
now: "2026-01-01T00:00:00.000Z",
});
expect(created).toMatchObject({
user_id: "user-1",
host_id: null,
name: "CPU high",
trigger_type: "cpu_threshold",
threshold_value: 80,
channels: [ownedChannel.id],
});
const updated = await repo.updateAlertRule(created.id, "user-1", {
name: "CPU very high",
hostId: 1,
channels: [],
now: "2026-01-02T00:00:00.000Z",
});
expect(updated).toMatchObject({
name: "CPU very high",
host_id: 1,
channels: [],
updated_at: "2026-01-02T00:00:00.000Z",
});
const rules = await repo.listAlertRules("user-1");
expect(rules).toHaveLength(1);
expect(rules[0].channels).toEqual([]);
expect(await repo.deleteAlertRule(created.id, "user-2")).toBe(false);
expect(await repo.deleteAlertRule(created.id, "user-1")).toBe(true);
});
it("lists, acknowledges, and prunes firings", async () => {
const repo = await createRepository();
const rule = await repo.createAlertRule({
userId: "user-1",
hostId: 1,
name: "Host offline",
enabled: true,
triggerType: "host_offline",
thresholdValue: null,
thresholdDurationSeconds: null,
cooldownMinutes: 15,
channels: [],
now: "2026-01-01T00:00:00.000Z",
});
await repo.createFiring({
userId: "user-1",
ruleId: rule.id,
hostId: 1,
hostName: "alpha",
value: null,
message: "down",
severity: "critical",
});
const listed = await repo.listAlertFirings({
userId: "user-1",
limit: 10,
offset: 0,
});
expect(listed.total).toBe(1);
expect(listed.firings[0]).toMatchObject({
rule_id: rule.id,
host_name: "alpha",
acknowledged: 0,
rule_name: "Host offline",
});
await repo.acknowledgeFiring(listed.firings[0].id, "user-1");
const unacknowledged = await repo.listAlertFirings({
userId: "user-1",
acknowledged: false,
limit: 10,
offset: 0,
});
expect(unacknowledged.total).toBe(0);
await repo.acknowledgeAllFirings("user-1");
await repo.pruneFiringsOlderThan("user-1", 0);
});
it("loads enabled rules and notification channels for the alert engine", async () => {
const repo = await createRepository();
const channel = await repo.createNotificationChannel({
userId: "user-1",
name: "Ops",
type: "webhook",
config: '{"url":"https://example.test"}',
enabled: true,
});
const disabledChannel = await repo.createNotificationChannel({
userId: "user-1",
name: "Disabled",
type: "webhook",
config: '{"url":"https://disabled.test"}',
enabled: false,
});
const rule = await repo.createAlertRule({
userId: "user-1",
hostId: null,
name: "CPU high",
enabled: true,
triggerType: "cpu_threshold",
thresholdValue: 90,
thresholdDurationSeconds: 0,
cooldownMinutes: 15,
channels: [channel.id, disabledChannel.id],
now: "2026-01-01T00:00:00.000Z",
});
expect(await repo.listEnabledRulesForHost(1)).toMatchObject([
{
id: rule.id,
userId: "user-1",
triggerType: "cpu_threshold",
enabled: true,
},
]);
expect(await repo.findRuleById(rule.id)).toMatchObject({
id: rule.id,
cooldownMinutes: 15,
});
expect(await repo.listEnabledChannelsForRule(rule.id)).toEqual([
{
id: channel.id,
type: "webhook",
config: '{"url":"https://example.test"}',
enabled: true,
},
]);
});
it("keeps another user's wildcard rules off a host they do not own", async () => {
const repo = await createRepository();
await adapter!.exec(`
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
VALUES (2, 'user-2', 'bravo', '127.0.0.2', 22, 'root', 'password');
`);
const ownerRule = await repo.createAlertRule({
userId: "user-1",
hostId: null,
name: "Owner wildcard",
enabled: true,
triggerType: "cpu_threshold",
thresholdValue: 90,
thresholdDurationSeconds: 0,
cooldownMinutes: 15,
channels: [],
now: "2026-01-01T00:00:00.000Z",
});
const otherRule = await repo.createAlertRule({
userId: "user-2",
hostId: null,
name: "Other wildcard",
enabled: true,
triggerType: "cpu_threshold",
thresholdValue: 90,
thresholdDurationSeconds: 0,
cooldownMinutes: 15,
channels: [],
now: "2026-01-01T00:00:00.000Z",
});
// Host 1 belongs to user-1, so only user-1's wildcard rule may fire.
const forHostOne = await repo.listEnabledRulesForHost(1);
expect(forHostOne.map((rule) => rule.id)).toEqual([ownerRule.id]);
const forHostTwo = await repo.listEnabledRulesForHost(2);
expect(forHostTwo.map((rule) => rule.id)).toEqual([otherRule.id]);
});
it("still matches a rule pinned to a specific host", async () => {
const repo = await createRepository();
const pinned = await repo.createAlertRule({
userId: "user-1",
hostId: 1,
name: "Pinned",
enabled: true,
triggerType: "disk_threshold",
thresholdValue: 90,
thresholdDurationSeconds: 0,
cooldownMinutes: 15,
channels: [],
now: "2026-01-01T00:00:00.000Z",
});
expect((await repo.listEnabledRulesForHost(1)).map((r) => r.id)).toEqual([
pinned.id,
]);
});
it("encrypts channel configs at rest and returns them decrypted", async () => {
const key = crypto.randomBytes(32);
const spy = vi
.spyOn(DataCrypto, "getUserDataKey")
.mockImplementation(() => key);
try {
const repo = await createRepository();
const secret = '{"url":"https://ntfy.test","token":"super-secret"}';
const created = await repo.createNotificationChannel({
userId: "user-1",
name: "Ntfy",
type: "ntfy",
config: secret,
enabled: true,
});
expect(created.config).toBe(secret);
// The stored bytes must not contain the token in the clear.
const stored = await adapter!.query<{ config: string }>(
sql`SELECT config FROM notification_channels WHERE id = ${created.id}`,
);
expect(stored[0].config).not.toContain("super-secret");
// Every read path hands back plaintext.
const listed = await repo.listNotificationChannels("user-1");
expect(listed[0].config).toBe(secret);
expect(
(await repo.findNotificationChannelForUser(created.id, "user-1"))
?.config,
).toBe(secret);
const rule = await repo.createAlertRule({
userId: "user-1",
hostId: null,
name: "CPU",
enabled: true,
triggerType: "cpu_threshold",
thresholdValue: 90,
thresholdDurationSeconds: 0,
cooldownMinutes: 15,
channels: [created.id],
now: "2026-01-01T00:00:00.000Z",
});
const engineChannels = await repo.listEnabledChannelsForRule(rule.id);
expect(engineChannels[0].config).toBe(secret);
const rotated = '{"url":"https://ntfy.test","token":"rotated"}';
const updated = await repo.updateNotificationChannel(
created.id,
"user-1",
{ config: rotated },
);
expect(updated?.config).toBe(rotated);
} finally {
spy.mockRestore();
}
});
it("still reads channel configs written before encryption", async () => {
const repo = await createRepository();
const plaintext = '{"url":"https://legacy.test"}';
const created = await repo.createNotificationChannel({
userId: "user-1",
name: "Legacy",
type: "webhook",
config: plaintext,
enabled: true,
});
const key = crypto.randomBytes(32);
const spy = vi
.spyOn(DataCrypto, "getUserDataKey")
.mockImplementation(() => key);
try {
const found = await repo.findNotificationChannelForUser(
created.id,
"user-1",
);
expect(found?.config).toBe(plaintext);
} finally {
spy.mockRestore();
}
});
it("loads host display names for alert payloads", async () => {
const repo = await createRepository();
expect(await repo.getHostDisplayName(1)).toBe("alpha");
expect(await repo.getHostDisplayName(999)).toBeNull();
});
it("deletes all alert data for a user", async () => {
let writes = 0;
const repo = await createRepository(() => {
writes += 1;
});
const userChannel = await repo.createNotificationChannel({
userId: "user-1",
name: "Ops",
type: "webhook",
config: '{"url":"https://example.test"}',
enabled: true,
});
const otherChannel = await repo.createNotificationChannel({
userId: "user-2",
name: "Other",
type: "webhook",
config: '{"url":"https://other.test"}',
enabled: true,
});
const userRule = await repo.createAlertRule({
userId: "user-1",
hostId: 1,
name: "CPU high",
enabled: true,
triggerType: "cpu_threshold",
thresholdValue: 80,
thresholdDurationSeconds: 30,
cooldownMinutes: 5,
channels: [userChannel.id],
now: "2026-01-01T00:00:00.000Z",
});
const otherRule = await repo.createAlertRule({
userId: "user-2",
hostId: null,
name: "Memory high",
enabled: true,
triggerType: "memory_threshold",
thresholdValue: 90,
thresholdDurationSeconds: 60,
cooldownMinutes: 10,
channels: [otherChannel.id],
now: "2026-01-01T00:00:00.000Z",
});
await repo.createFiring({
userId: "user-1",
ruleId: userRule.id,
hostId: 1,
hostName: "alpha",
value: 95,
message: "high",
severity: "warning",
});
await repo.createFiring({
userId: "user-2",
ruleId: otherRule.id,
hostId: 1,
hostName: "alpha",
value: 91,
message: "other",
severity: "warning",
});
await expect(repo.deleteByUserId("user-1")).resolves.toEqual({
firingsDeleted: 1,
ruleLinksDeleted: 1,
rulesDeleted: 1,
channelsDeleted: 1,
});
await expect(repo.deleteByUserId("missing")).resolves.toEqual({
firingsDeleted: 0,
ruleLinksDeleted: 0,
rulesDeleted: 0,
channelsDeleted: 0,
});
expect(await repo.listNotificationChannels("user-1")).toEqual([]);
expect(await repo.listAlertRules("user-1")).toEqual([]);
expect(
await repo.listAlertFirings({ userId: "user-1", limit: 10, offset: 0 }),
).toEqual({ firings: [], total: 0 });
expect(await repo.listNotificationChannels("user-2")).toHaveLength(1);
expect(await repo.listAlertRules("user-2")).toHaveLength(1);
expect(await repo.listEnabledChannelsForRule(otherRule.id)).toHaveLength(1);
expect(writes).toBe(7);
});
});
+2 -2
View File
@@ -6,12 +6,12 @@ const BASE = { hostCount: 3, allowReadOnlyCommands: false };
describe("buildSystemPrompt", () => {
it("tells the assistant to stay inside what was asked", () => {
// Without these the assistant answered "what is running on this server"
// by also proposing a monitoring script and an alert rule nobody wanted.
// by also proposing a monitoring script and an automation nobody wanted.
const prompt = buildSystemPrompt(BASE);
expect(prompt).toContain("nothing beyond it");
expect(prompt).toMatch(/Read, then report\. Do not propose anything\./);
expect(prompt).toMatch(/no monitoring, no alert rules, no scripts/);
expect(prompt).toMatch(/no monitoring automations, no scripts/);
expect(prompt).toMatch(/One request means one proposal at most/);
});
@@ -13,14 +13,12 @@ const snippetRepository = {
deleteSnippet: vi.fn(),
};
const fleetRepository = { create: vi.fn(), addMember: vi.fn() };
const alertRepository = { createAlertRule: vi.fn() };
const automationRepository = { create: vi.fn() };
vi.mock("../../../../database/repositories/factory.js", () => ({
createCurrentHostRepository: () => hostRepository,
createCurrentSnippetRepository: () => snippetRepository,
createCurrentFleetRepository: () => fleetRepository,
createCurrentAlertRepository: () => alertRepository,
createCurrentAutomationRepository: () => automationRepository,
}));
@@ -1,86 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* Once the alert rules have been migrated into automations, both systems hold
* a copy of every rule. If the old engine kept evaluating, every alert would
* be delivered twice.
*/
const listEnabledRulesForHost = vi.fn();
const listEnabledRulesForHostUser = vi.fn();
const createFiring = vi.fn();
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentAlertRepository: () => ({
listEnabledRulesForHost,
listEnabledRulesForHostUser,
createFiring,
pruneFiringsOlderThan: vi.fn(),
listEnabledChannelsForRule: vi.fn(async () => []),
findRuleById: vi.fn(async () => null),
getHostDisplayName: vi.fn(async () => "host"),
}),
}));
vi.mock("../../../utils/logger.js", () => ({
statsLogger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() },
}));
vi.mock("../../../utils/notification-sender.js", () => ({
sendNotification: vi.fn(async () => undefined),
}));
vi.mock("../../../utils/discord-sender.js", () => ({
sendDiscord: vi.fn(async () => undefined),
}));
const alertEngineModule =
await import("../../../../../plugins/host-metrics/backend/alert-engine.js");
const cpuRule = {
id: 1,
userId: "user-1",
hostId: null,
name: "CPU",
enabled: true,
triggerType: "cpu_threshold",
thresholdValue: 50,
thresholdDurationSeconds: 0,
cooldownMinutes: 0,
};
beforeEach(() => {
vi.clearAllMocks();
listEnabledRulesForHost.mockResolvedValue([cpuRule]);
listEnabledRulesForHostUser.mockResolvedValue([cpuRule]);
});
describe("AlertEngine before migration", () => {
it("still evaluates rules", async () => {
expect(alertEngineModule.isAlertEngineSuperseded()).toBe(false);
await alertEngineModule.AlertEngine.getInstance().evaluateMetrics(7, {
cpu: { percent: 90 },
});
expect(listEnabledRulesForHost).toHaveBeenCalled();
});
});
describe("AlertEngine after migration", () => {
it("stops evaluating every trigger type", async () => {
alertEngineModule.markAlertEngineSuperseded();
expect(alertEngineModule.isAlertEngineSuperseded()).toBe(true);
const engine = alertEngineModule.AlertEngine.getInstance();
await engine.evaluateMetrics(7, { cpu: { percent: 99 } });
await engine.evaluateStatus(7, false);
await engine.evaluateHealthCheck(7, "user-1", "web", false);
await engine.evaluateUserLogin(7, "user-1", "root", "10.0.0.1");
// Nothing is even loaded, so nothing can be delivered a second time.
expect(listEnabledRulesForHost).not.toHaveBeenCalled();
expect(listEnabledRulesForHostUser).not.toHaveBeenCalled();
expect(createFiring).not.toHaveBeenCalled();
});
});
@@ -26,7 +26,6 @@ const route = ast.statements.find(
);
if (!route) throw new Error("Login event route is missing");
const notify = vi.fn();
const legacy = vi.fn().mockResolvedValue(undefined);
let handler: (req: unknown, res: unknown) => Promise<void>;
runInNewContext(
ts.transpileModule(route.getText(ast), {
@@ -49,7 +48,6 @@ runInNewContext(
},
}),
notifyAutomationInternalEvent: notify,
AlertEngine: { getInstance: () => ({ evaluateUserLogin: legacy }) },
},
);
const body = {
@@ -61,7 +59,7 @@ const body = {
const response = () => ({ status: vi.fn().mockReturnThis(), json: vi.fn() });
beforeEach(() => vi.clearAllMocks());
it("delivers successful SSH login details to automations while retaining the legacy hook", async () => {
it("delivers successful SSH login details to automations", async () => {
const res = response();
await handler(
{
@@ -75,12 +73,6 @@ it("delivers successful SSH login details to automations while retaining the leg
sshUser: "alice",
fromIp: "192.0.2.4",
});
expect(legacy).toHaveBeenCalledExactlyOnceWith(
42,
"owner",
"alice",
"192.0.2.4",
);
expect(res.json).toHaveBeenCalledWith({ ok: true });
});
@@ -101,6 +93,5 @@ it.each([
);
expect(res.status).toHaveBeenCalledWith(403);
expect(notify).not.toHaveBeenCalled();
expect(legacy).not.toHaveBeenCalled();
},
);
@@ -1,9 +1,9 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { triggerLoginAlert } from "../../utils/alert-trigger.js";
import { dispatchLoginEvent } from "../../utils/login-event-dispatch.js";
import { SystemCrypto } from "../../utils/system-crypto.js";
import { sshLogger } from "../../utils/logger.js";
describe("triggerLoginAlert", () => {
describe("dispatchLoginEvent", () => {
afterEach(() => {
vi.restoreAllMocks();
});
@@ -19,12 +19,12 @@ describe("triggerLoginAlert", () => {
);
const warn = vi.spyOn(sshLogger, "warn").mockImplementation(() => {});
await triggerLoginAlert(7, "user-1", "root", "192.0.2.1");
await dispatchLoginEvent(7, "user-1", "root", "192.0.2.1");
expect(warn).toHaveBeenCalledWith(
"Failed to trigger login alert",
"Failed to dispatch login event",
expect.objectContaining({
operation: "login_alert_trigger_error",
operation: "login_event_dispatch_error",
hostId: 7,
error:
'Metrics service returned 401: {"error":"Missing authentication token"}',
@@ -41,7 +41,7 @@ describe("triggerLoginAlert", () => {
);
const warn = vi.spyOn(sshLogger, "warn").mockImplementation(() => {});
await triggerLoginAlert(7, "user-1", "root", "192.0.2.1");
await dispatchLoginEvent(7, "user-1", "root", "192.0.2.1");
expect(warn).not.toHaveBeenCalled();
});
@@ -54,7 +54,7 @@ describe("triggerLoginAlert", () => {
.spyOn(globalThis, "fetch")
.mockResolvedValue(new Response('{"ok":true}', { status: 200 }));
await triggerLoginAlert(42, "user-1", "root", "10.0.0.5");
await dispatchLoginEvent(42, "user-1", "root", "10.0.0.5");
expect(fetchSpy).toHaveBeenCalledWith(
"http://localhost:30005/internal/login-alert",
@@ -83,11 +83,11 @@ describe("triggerLoginAlert", () => {
const warn = vi.spyOn(sshLogger, "warn").mockImplementation(() => {});
await expect(
triggerLoginAlert(1, "user-1", "root", "127.0.0.1"),
dispatchLoginEvent(1, "user-1", "root", "127.0.0.1"),
).resolves.toBeUndefined();
expect(warn).toHaveBeenCalledWith(
"Failed to trigger login alert",
"Failed to dispatch login event",
expect.objectContaining({ hostId: 1 }),
);
});
@@ -1,234 +0,0 @@
import type {
AutomationDefinition,
Step,
Trigger,
} from "../../../types/automations.js";
import { AUTOMATION_DEFINITION_VERSION } from "../../../types/automations.js";
import { databaseLogger } from "../logger.js";
import {
createCurrentAutomationRepository,
createCurrentRepositoryContext,
createCurrentSettingsRepository,
} from "../../database/repositories/factory.js";
import { alertRuleChannels, alertRules } from "../../database/db/schema.js";
import { eq } from "drizzle-orm";
const MIGRATION_FLAG = "alert_rules_to_automations_v1";
export interface AutomationsMigrationResult {
migrated: number;
skipped: number;
}
/**
* Carries existing alert rules over into automations.
*
* Each rule becomes an automation whose trigger is the rule's threshold or
* state change and whose only step notifies the channels that rule was linked
* to, which is exactly what the alert engine did with it.
*
* The alert_* tables are deliberately left in place. They are the rollback
* path, and alert_firings is user-visible history; nothing writes to them once
* the automations engine takes over.
*/
export async function runAutomationsMigration(): Promise<AutomationsMigrationResult | null> {
const settingsRepository = createCurrentSettingsRepository();
try {
if ((await settingsRepository.get(MIGRATION_FLAG)) === "done") {
// Already migrated on an earlier boot; the automations engine owns
// evaluation from here, so the old one must stay quiet.
await standDownAlertEngine();
return null;
}
const { drizzle } = createCurrentRepositoryContext();
const rules = await drizzle.select().from(alertRules);
if (rules.length === 0) {
await settingsRepository.set(MIGRATION_FLAG, "done");
await standDownAlertEngine();
return { migrated: 0, skipped: 0 };
}
const repository = createCurrentAutomationRepository();
let migrated = 0;
let skipped = 0;
for (const rule of rules) {
const trigger = triggerForRule(rule);
if (!trigger) {
skipped++;
continue;
}
const links = await drizzle
.select({ channelId: alertRuleChannels.channelId })
.from(alertRuleChannels)
.where(eq(alertRuleChannels.ruleId, rule.id));
const channelIds = links.map((link) => link.channelId);
const steps: Step[] = [
{
id: "notify",
type: "notify",
channelIds,
title: `${rule.name}`,
body: messageTemplateFor(rule.triggerType),
severity: "warning",
},
];
const definition: AutomationDefinition = {
version: AUTOMATION_DEFINITION_VERSION,
trigger,
steps,
};
await repository.create({
userId: rule.userId,
name: rule.name,
description: "Migrated from an alert rule",
enabled: !!rule.enabled,
definition: JSON.stringify(definition),
channels: channelIds,
});
migrated++;
}
await settingsRepository.set(MIGRATION_FLAG, "done");
await standDownAlertEngine();
if (migrated > 0) {
databaseLogger.info(`Migrated ${migrated} alert rule(s) to automations`, {
operation: "automations_migration",
migrated,
skipped,
});
}
return { migrated, skipped };
} catch (error) {
databaseLogger.warn("Alert rule migration failed", {
operation: "automations_migration_error",
error: error instanceof Error ? error.message : String(error),
});
return null;
}
}
/**
* Imported lazily: this migration runs before the metrics subsystem is loaded,
* and pulling that module in early would drag its timers in with it.
*
* alert-engine now lives in the host-metrics plugin rather than core, so it is
* resolved through getBundledPluginsDir() the same way the plugin loader finds
* any other bundled plugin file, instead of a relative import into a module
* this file no longer sits next to.
*/
async function standDownAlertEngine(): Promise<void> {
try {
const { getBundledPluginsDir } = await import("../../plugins/paths.js");
const path = await import("node:path");
const { pathToFileURL } = await import("node:url");
const entry = path.join(
getBundledPluginsDir(),
"host-metrics",
"backend",
"alert-engine.js",
);
const { markAlertEngineSuperseded } = await import(
pathToFileURL(entry).href
);
markAlertEngineSuperseded();
} catch {
// If it cannot be loaded there is nothing running to stand down.
}
}
type AlertRuleRecord = typeof alertRules.$inferSelect;
function triggerForRule(rule: AlertRuleRecord): Trigger | null {
const hostSelector =
rule.hostId === null
? ({ kind: "all" } as const)
: ({ kind: "host", hostId: rule.hostId } as const);
const cooldownMinutes = rule.cooldownMinutes ?? 15;
switch (rule.triggerType) {
case "cpu_threshold":
case "memory_threshold":
case "disk_threshold": {
const path =
rule.triggerType === "cpu_threshold"
? "cpu.percent"
: rule.triggerType === "memory_threshold"
? "memory.percent"
: "disk.percent";
return {
kind: "metric_threshold",
hostSelector,
metric: { path } as Extract<
Trigger,
{ kind: "metric_threshold" }
>["metric"],
// The old engine only ever compared with >=.
operator: ">=",
value: rule.thresholdValue ?? 0,
forSeconds: rule.thresholdDurationSeconds ?? undefined,
cooldownMinutes,
};
}
case "host_offline":
return {
kind: "host_status",
hostSelector,
to: "offline",
cooldownMinutes,
};
case "host_online":
return {
kind: "host_status",
hostSelector,
to: "online",
cooldownMinutes,
};
case "health_check_failure":
return {
kind: "health_check",
hostSelector,
to: "failing",
cooldownMinutes,
};
case "health_check_recovery":
return {
kind: "health_check",
hostSelector,
to: "recovered",
cooldownMinutes,
};
case "user_login":
return {
kind: "internal_event",
event: "user_login",
hostSelector,
cooldownMinutes,
};
default:
return null;
}
}
function messageTemplateFor(triggerType: string): string {
if (triggerType.endsWith("_threshold")) {
return "{{trigger.metric}} on host {{trigger.hostId}} is at {{trigger.value}} (threshold {{trigger.threshold}})";
}
if (triggerType.startsWith("host_")) {
return "Host {{trigger.hostId}} is {{trigger.status}}";
}
if (triggerType.startsWith("health_check")) {
return "Health check {{trigger.checkId}} on host {{trigger.hostId}} is {{trigger.state}}";
}
return "Triggered on host {{trigger.hostId}}";
}
@@ -3,7 +3,7 @@ import { sshLogger } from "./logger.js";
const METRICS_SERVICE_URL = "http://localhost:30005";
export async function triggerLoginAlert(
export async function dispatchLoginEvent(
hostId: number,
userId: string,
sshUser: string,
@@ -29,8 +29,8 @@ export async function triggerLoginAlert(
);
}
} catch (err) {
sshLogger.warn("Failed to trigger login alert", {
operation: "login_alert_trigger_error",
sshLogger.warn("Failed to dispatch login event", {
operation: "login_event_dispatch_error",
hostId,
error: err instanceof Error ? err.message : String(err),
});
-6
View File
@@ -15,7 +15,6 @@ export type WidgetTypeId =
| "rss_feed"
| "metrics_chart"
| "host_grid"
| "alert_feed"
| "ping_status"
| "recent_activity"
| "termix_uptime"
@@ -167,11 +166,6 @@ export interface HostGridConfig {
columns: 2 | 3 | 4;
}
export interface AlertFeedConfig {
maxItems: number;
showAcknowledged: boolean;
}
export interface PingUrl {
label: string;
url: string;
-1
View File
@@ -157,7 +157,6 @@ const ALL_HIDEABLE_RAIL_VIEWS = [
"split-screen",
"connections",
"session-logs",
"alerts",
"fleets",
"workspaces",
"network_graph",
-1
View File
@@ -271,7 +271,6 @@ export type KnownTabType =
| "fleet-inventory"
// Rail panels that can also open full-width in the main area.
| "termix-id"
| "alerts"
| "session-logs"
| "snippets"
| "macros"
-9
View File
@@ -129,9 +129,6 @@ const AdminSettingsPanel = lazy(() =>
default: m.AdminSettingsPanel,
})),
);
const AlertsPanel = lazy(() =>
import("@/sidebar/AlertsPanel").then((m) => ({ default: m.AlertsPanel })),
);
const CredentialsPanel = lazy(() =>
import("@/sidebar/CredentialsPanel").then((m) => ({
default: m.CredentialsPanel,
@@ -2898,12 +2895,6 @@ export function AppShell({
/>
</div>
)}
{railView === "alerts" && (
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
<AlertsPanel />
</div>
)}
</div>
</Suspense>
);
-192
View File
@@ -1,192 +0,0 @@
import { rbacApi } from "@/main-axios";
export interface NotificationChannel {
id: number;
userId: string;
name: string;
type: "webhook" | "ntfy" | "discord";
config: string;
enabled: boolean;
createdAt: string;
}
export interface AlertRule {
id: number;
userId: string;
hostId: number | null;
name: string;
enabled: boolean;
triggerType: string;
thresholdValue: number | null;
thresholdDurationSeconds: number | null;
cooldownMinutes: number;
createdAt: string;
updatedAt: string;
channelIds: number[];
}
export interface AlertFiring {
id: number;
userId: string;
ruleId: number;
hostId: number;
hostName: string;
firedAt: string;
resolvedAt: string | null;
value: number | null;
message: string;
severity: "info" | "warning" | "critical";
acknowledged: boolean;
ruleName?: string;
}
function stringValue(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
function numberValue(value: unknown, fallback = 0): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function nullableNumberValue(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function boolValue(value: unknown): boolean {
return value === true || value === 1 || value === "1";
}
function severityValue(value: unknown): AlertFiring["severity"] {
return value === "info" || value === "critical" || value === "warning"
? value
: "warning";
}
export async function getNotificationChannels(): Promise<
NotificationChannel[]
> {
const res = await rbacApi.get("/notification-channels");
return res.data;
}
export type NotificationChannelPayload = Partial<
Omit<NotificationChannel, "config">
> & {
// When creating/updating the channel the UI may pass a parsed object
// for `config` (e.g., { url, username }) — the backend stores it as
// a JSON string. Accept either a string or any structured object here.
// Use `unknown` to allow the component-local config types to be passed
// without importing them into this module.
config: string | unknown;
};
export async function createNotificationChannel(
data: NotificationChannelPayload,
): Promise<NotificationChannel> {
const res = await rbacApi.post("/notification-channels", data);
return res.data;
}
export async function updateNotificationChannel(
id: number,
data: NotificationChannelPayload,
): Promise<NotificationChannel> {
const res = await rbacApi.put(`/notification-channels/${id}`, data);
return res.data;
}
export async function deleteNotificationChannel(id: number): Promise<void> {
await rbacApi.delete(`/notification-channels/${id}`);
}
export async function testNotificationChannel(id: number): Promise<void> {
const res = await rbacApi.post(`/notification-channels/${id}/test`);
const data = res.data as { success?: boolean; error?: string };
if (data && data.success === false) {
throw new Error(data.error || "Test notification failed");
}
}
function mapRule(r: Record<string, unknown>): AlertRule {
return {
id: r.id as number,
userId: (r.user_id ?? r.userId) as string,
hostId: (r.host_id ?? r.hostId ?? null) as number | null,
name: r.name as string,
enabled: Boolean(r.enabled),
triggerType: (r.trigger_type ?? r.triggerType) as string,
thresholdValue: (r.threshold_value ?? r.thresholdValue ?? null) as
number | null,
thresholdDurationSeconds: (r.threshold_duration_seconds ??
r.thresholdDurationSeconds ??
null) as number | null,
cooldownMinutes: (r.cooldown_minutes ?? r.cooldownMinutes) as number,
createdAt: (r.created_at ?? r.createdAt) as string,
updatedAt: (r.updated_at ?? r.updatedAt) as string,
channelIds: Array.isArray(r.channels) ? (r.channels as number[]) : [],
};
}
export function mapAlertFiring(r: Record<string, unknown>): AlertFiring {
return {
id: numberValue(r.id),
userId: stringValue(r.userId ?? r.user_id),
ruleId: numberValue(r.ruleId ?? r.rule_id),
hostId: numberValue(r.hostId ?? r.host_id),
hostName: stringValue(r.hostName ?? r.host_name),
firedAt: stringValue(r.firedAt ?? r.fired_at, new Date(0).toISOString()),
resolvedAt: stringValue(r.resolvedAt ?? r.resolved_at) || null,
value: nullableNumberValue(r.value),
message: stringValue(r.message),
severity: severityValue(r.severity),
acknowledged: boolValue(r.acknowledged),
ruleName: stringValue(r.ruleName ?? r.rule_name) || undefined,
};
}
export async function getAlertRules(): Promise<AlertRule[]> {
const res = await rbacApi.get("/alert-rules");
return (res.data as Record<string, unknown>[]).map(mapRule);
}
export async function createAlertRule(
data: Partial<AlertRule> & { channels?: number[] },
): Promise<AlertRule> {
const res = await rbacApi.post("/alert-rules", data);
return mapRule(res.data as Record<string, unknown>);
}
export async function updateAlertRule(
id: number,
data: Partial<AlertRule> & { channels?: number[] },
): Promise<AlertRule> {
const res = await rbacApi.put(`/alert-rules/${id}`, data);
return mapRule(res.data as Record<string, unknown>);
}
export async function deleteAlertRule(id: number): Promise<void> {
await rbacApi.delete(`/alert-rules/${id}`);
}
export async function getAlertFirings(opts?: {
limit?: number;
offset?: number;
acknowledged?: boolean;
}): Promise<AlertFiring[]> {
const res = await rbacApi.get("/alert-firings", { params: opts });
const data = res.data as { firings?: unknown } | unknown[];
const rows = Array.isArray(data)
? data
: Array.isArray(data.firings)
? data.firings
: [];
return rows.map((row) => mapAlertFiring(row as Record<string, unknown>));
}
export async function acknowledgeAlertFiring(id: number): Promise<void> {
await rbacApi.post(`/alert-firings/${id}/acknowledge`);
}
export async function acknowledgeAllAlertFirings(): Promise<void> {
await rbacApi.post("/alert-firings/acknowledge-all");
}
+56
View File
@@ -0,0 +1,56 @@
import { rbacApi } from "@/main-axios";
export interface NotificationChannel {
id: number;
userId: string;
name: string;
type: "webhook" | "ntfy" | "discord";
config: string;
enabled: boolean;
createdAt: string;
}
export async function getNotificationChannels(): Promise<
NotificationChannel[]
> {
const res = await rbacApi.get("/notification-channels");
return res.data;
}
export type NotificationChannelPayload = Partial<
Omit<NotificationChannel, "config">
> & {
// When creating/updating the channel the UI may pass a parsed object
// for `config` (e.g., { url, username }) — the backend stores it as
// a JSON string. Accept either a string or any structured object here.
// Use `unknown` to allow the component-local config types to be passed
// without importing them into this module.
config: string | unknown;
};
export async function createNotificationChannel(
data: NotificationChannelPayload,
): Promise<NotificationChannel> {
const res = await rbacApi.post("/notification-channels", data);
return res.data;
}
export async function updateNotificationChannel(
id: number,
data: NotificationChannelPayload,
): Promise<NotificationChannel> {
const res = await rbacApi.put(`/notification-channels/${id}`, data);
return res.data;
}
export async function deleteNotificationChannel(id: number): Promise<void> {
await rbacApi.delete(`/notification-channels/${id}`);
}
export async function testNotificationChannel(id: number): Promise<void> {
const res = await rbacApi.post(`/notification-channels/${id}/test`);
const data = res.data as { success?: boolean; error?: string };
if (data && data.success === false) {
throw new Error(data.error || "Test notification failed");
}
}
+1 -4
View File
@@ -1,7 +1,7 @@
/**
* Turns the internal identifiers the assistant works in into text a user can
* read. Tool names, proposal kinds and payload keys all surface in the panel,
* and raw ids like "propose_create_alert_rule" or "thresholddurationseconds"
* and raw ids like "propose_create_automation" or "thresholddurationseconds"
* are not something to show anyone.
*/
@@ -14,9 +14,7 @@ const TOOL_LABELS: Record<string, string> = {
list_automations: "Reading automations",
get_automation: "Reading automation",
list_workspaces: "Reading workspaces",
list_alert_rules: "Reading alert rules",
list_notification_channels: "Reading notification channels",
get_alert_firings: "Reading recent alerts",
list_homepage_items: "Reading homepage items",
get_command_history: "Reading command history",
get_network_topology: "Reading network topology",
@@ -28,7 +26,6 @@ const TOOL_LABELS: Record<string, string> = {
propose_delete_snippet: "Delete snippet",
propose_create_automation: "Create automation",
propose_create_fleet: "Create fleet",
propose_create_alert_rule: "Create alert rule",
propose_run_command: "Run command",
};
@@ -39,7 +39,6 @@ import "./widgets/IframeWidget";
import "./widgets/RssFeedWidget";
import "./widgets/MetricsChartWidget";
import "./widgets/HostGridWidget";
import "./widgets/AlertFeedWidget";
import "./widgets/PingStatusWidget";
import "./widgets/RecentActivityWidget";
import "./widgets/TermixUptimeWidget";
@@ -1,46 +0,0 @@
import { useTranslation } from "react-i18next";
import type {
AlertFeedConfig,
WidgetEditFormProps,
} from "@/types/homepage-types";
import { Input } from "@/components/input";
export function AlertFeedEditForm({
config,
onChange,
}: WidgetEditFormProps<AlertFeedConfig>) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.maxItems")}
</label>
<Input
type="number"
min={1}
max={50}
value={config.maxItems}
onChange={(e) =>
onChange({
...config,
maxItems: Math.max(1, Number(e.target.value)),
})
}
className="h-8 text-xs"
/>
</div>
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={config.showAcknowledged}
onChange={(e) =>
onChange({ ...config, showAcknowledged: e.target.checked })
}
className="accent-accent-brand"
/>
{t("homepage.showAcknowledged")}
</label>
</div>
);
}
@@ -22,7 +22,6 @@ import type {
RssFeedConfig,
MetricsChartConfig,
HostGridConfig,
AlertFeedConfig,
PingStatusConfig,
RecentActivityConfig,
TermixUptimeConfig,
@@ -55,7 +54,6 @@ import { IframeEditForm } from "./IframeEditForm";
import { RssFeedEditForm } from "./RssFeedEditForm";
import { MetricsChartEditForm } from "./MetricsChartEditForm";
import { HostGridEditForm } from "./HostGridEditForm";
import { AlertFeedEditForm } from "./AlertFeedEditForm";
import { PingStatusEditForm } from "./PingStatusEditForm";
import { RecentActivityEditForm } from "./RecentActivityEditForm";
import { TermixUptimeEditForm } from "./TermixUptimeEditForm";
@@ -197,13 +195,6 @@ export function WidgetEditDialog({
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "alert_feed":
return (
<AlertFeedEditForm
config={config as unknown as AlertFeedConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "ping_status":
return (
<PingStatusEditForm
@@ -1,180 +0,0 @@
import { useEffect, useState } from "react";
import {
Bell,
CheckCircle,
AlertTriangle,
AlertCircle,
Info,
Check,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { registerWidget } from "./WidgetRegistry";
import type {
AlertFeedConfig,
WidgetComponentProps,
} from "@/types/homepage-types";
import { GRID_SIZE } from "@/types/homepage-types";
import {
acknowledgeAlertFiring,
getAlertFirings,
type AlertFiring,
} from "@/api/alerts-api";
import { WidgetTitle } from "./WidgetTitle";
function relativeTime(ts: string): string {
const diff = Date.now() - new Date(ts).getTime();
const m = Math.floor(diff / 60_000);
if (m < 1) return "just now";
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
function SeverityIcon({ severity }: { severity: AlertFiring["severity"] }) {
if (severity === "critical")
return <AlertCircle size={11} className="shrink-0 text-red-500" />;
if (severity === "warning")
return <AlertTriangle size={11} className="shrink-0 text-amber-500" />;
return <Info size={11} className="shrink-0 text-blue-400" />;
}
function AlertFeedWidget({
widget,
config,
}: WidgetComponentProps<AlertFeedConfig>) {
const { t } = useTranslation();
const { maxItems, showAcknowledged } = config;
const [firings, setFirings] = useState<AlertFiring[]>([]);
const [loading, setLoading] = useState(true);
const fetchData = async () => {
try {
const data = await getAlertFirings({ limit: maxItems * 2 });
const filtered = showAcknowledged
? data
: data.filter((f) => !f.acknowledged);
setFirings(filtered.slice(0, maxItems));
} catch {
/* ignore */
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
let intervalId: ReturnType<typeof setInterval> | null = null;
const start = () => {
if (intervalId !== null) return;
intervalId = setInterval(() => {
if (document.visibilityState === "hidden") return;
void fetchData();
}, 30_000);
};
const stop = () => {
if (intervalId === null) return;
clearInterval(intervalId);
intervalId = null;
};
const onVisibility = () => {
if (document.visibilityState === "hidden") {
stop();
return;
}
void fetchData();
start();
};
if (document.visibilityState !== "hidden") start();
document.addEventListener("visibilitychange", onVisibility);
return () => {
stop();
document.removeEventListener("visibilitychange", onVisibility);
};
}, [maxItems, showAcknowledged]);
const handleAck = async (id: number) => {
try {
await acknowledgeAlertFiring(id);
setFirings((prev) =>
prev.map((f) => (f.id === id ? { ...f, acknowledged: true } : f)),
);
} catch {
/* ignore */
}
};
if (loading) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
{t("homepage.loading")}
</div>
);
}
if (firings.length === 0) {
return (
<div className="flex flex-col items-center justify-center w-full h-full gap-2">
<CheckCircle size={20} className="text-green-500" />
<span className="text-xs text-muted-foreground">
{t("homepage.allClear")}
</span>
</div>
);
}
return (
<div className="flex flex-col w-full h-full overflow-hidden">
<WidgetTitle
title={widget.title || t("homepage.widgetAlertFeedName")}
icon={<Bell size={11} />}
/>
<div className="flex-1 overflow-auto">
{firings.map((f) => (
<div
key={f.id}
className={`flex items-start gap-2 px-2 py-1.5 border-b border-border/30 ${f.acknowledged ? "opacity-50" : ""}`}
>
<SeverityIcon severity={f.severity} />
<div className="flex flex-col min-w-0 flex-1">
<span className="text-[10px] font-semibold text-foreground truncate">
{f.ruleName ?? f.message}
</span>
<span className="text-[9px] text-muted-foreground truncate">
{f.hostName} · {relativeTime(f.firedAt)}
</span>
</div>
{!f.acknowledged && (
<button
onClick={(e) => {
e.stopPropagation();
handleAck(f.id);
}}
className="p-0.5 text-muted-foreground hover:text-green-500 transition-colors shrink-0"
title={t("homepage.acknowledgeAlert")}
>
<Check size={10} />
</button>
)}
</div>
))}
</div>
</div>
);
}
registerWidget<AlertFeedConfig>({
id: "alert_feed",
name: "Alert Feed",
description: "Live feed of active alert firings",
category: "monitoring",
icon: <Bell size={14} />,
defaultConfig: { maxItems: 10, showAcknowledged: false },
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
component: AlertFeedWidget,
});
export { AlertFeedWidget };
+4 -34
View File
@@ -287,7 +287,6 @@
"loading": "Loading...",
"noData": "No data available",
"allClear": "All clear",
"acknowledgeAlert": "Acknowledge",
"noPingUrls": "No URLs configured",
"pingLabel": "Label",
"addPingUrl": "Add URL",
@@ -324,8 +323,6 @@
"widgetMetricsChartDesc": "Historical CPU, memory, disk or network chart for a host",
"widgetHostGridName": "Host Grid",
"widgetHostGridDesc": "Grid view of SSH host statuses",
"widgetAlertFeedName": "Alert Feed",
"widgetAlertFeedDesc": "Live alert firings with acknowledge support",
"widgetPingStatusName": "Ping Status",
"widgetPingStatusDesc": "HTTP ping status for one or more URLs",
"widgetRecentActivityName": "Recent Activity",
@@ -747,7 +744,6 @@
"portForwarding": "Forwarding",
"sftp": "SFTP",
"connections": "Connections",
"alerts": "Alerts",
"serial": "Serial",
"keepSideMenuOpen": "Keep Menu Open",
"collapseSideMenu": "Collapse Menu",
@@ -4950,21 +4946,6 @@
"viewHistory": "View History"
},
"alerts": {
"tabFirings": "Alerts",
"tabRules": "Rules",
"tabChannels": "Channels",
"noFirings": "No unacknowledged alerts",
"noRules": "No alert rules configured",
"noChannels": "No notification channels configured",
"rulesDesc": "Alert rules trigger notifications",
"channelsDesc": "Where alert notifications are sent",
"acknowledge": "Acknowledge",
"ackAll": "Ack All",
"allAcknowledged": "All alerts acknowledged",
"ackFailed": "Failed to acknowledge alert",
"ackAllFailed": "Failed to acknowledge all alerts",
"showAcknowledged": "Show All",
"hideAcknowledged": "Hide Acked",
"addChannel": "Add Channel",
"editChannel": "Edit Channel",
"channelName": "Name",
@@ -4978,21 +4959,10 @@
"ntfyTopicRequired": "Topic is required",
"ntfyToken": "Access Token (optional)",
"channelSaveFailed": "Failed to save channel",
"test": "Test",
"testSent": "Test notification sent",
"testFailed": "Test notification failed",
"testFailedWithReason": "Test notification failed: {{reason}}",
"addRule": "Add Alert Rule",
"editRule": "Edit Alert Rule",
"ruleName": "Rule Name",
"ruleNameRequired": "Name is required",
"triggerType": "Trigger",
"thresholdValue": "Threshold (%)",
"durationSeconds": "Duration (seconds, 0 = fire immediately)",
"cooldownMinutes": "Cooldown (minutes)",
"channels": "Notification Channels",
"noChannelsHint": "Add channels in the Channels tab first",
"ruleSaveFailed": "Failed to save rule"
"discordUrlInvalid": "Discord URL must be a valid Discord webhook URL",
"discordWebhookDesc": "POST a Discord embed to this webhook URL on each alert firing",
"discordUsername": "Display name (optional)",
"discordAvatar": "Avatar URL (optional)"
},
"ai": {
"title": "Assistant",
+1 -35
View File
@@ -287,7 +287,6 @@
"loading": "Laai tans...",
"noData": "Geen data beskikbaar nie",
"allClear": "Alles duidelik",
"acknowledgeAlert": "Erken",
"noPingUrls": "Geen URL'e gekonfigureer nie",
"pingLabel": "Etiket",
"addPingUrl": "Voeg URL by",
@@ -324,8 +323,6 @@
"widgetMetricsChartDesc": "Historiese SVE-, geheue-, skyf- of netwerkgrafiek vir 'n gasheer",
"widgetHostGridName": "Gasheerrooster",
"widgetHostGridDesc": "Roosteraansig van SSH-gasheerstatusse",
"widgetAlertFeedName": "Waarskuwingsvoer",
"widgetAlertFeedDesc": "Regstreekse waarskuwingsvuurwerke met erkenningsondersteuning",
"widgetPingStatusName": "Ping-status",
"widgetPingStatusDesc": "HTTP-pingstatus vir een of meer URL'e",
"widgetRecentActivityName": "Onlangse Aktiwiteit",
@@ -747,7 +744,6 @@
"portForwarding": "Aanstuur",
"sftp": "SFTP",
"connections": "Verbindings",
"alerts": "Waarskuwings",
"serial": "Reeks",
"keepSideMenuOpen": "Hou Kieslys Oop",
"collapseSideMenu": "Vou Kieslys in",
@@ -4890,21 +4886,6 @@
"viewHistory": "Bekyk Geskiedenis"
},
"alerts": {
"tabFirings": "Waarskuwings",
"tabRules": "Reëls",
"tabChannels": "Kanale",
"noFirings": "Geen onerkende waarskuwings nie",
"noRules": "Geen waarskuwingsreëls opgestel nie",
"noChannels": "Geen kennisgewingkanale gekonfigureer nie",
"rulesDesc": "Waarskuwingsreëls aktiveer kennisgewings",
"channelsDesc": "Waar waarskuwingskennisgewings gestuur word",
"acknowledge": "Erken",
"ackAll": "Bevestig alles",
"allAcknowledged": "Alle waarskuwings erken",
"ackFailed": "Kon nie waarskuwing erken nie",
"ackAllFailed": "Kon nie alle waarskuwings erken nie",
"showAcknowledged": "Wys alles",
"hideAcknowledged": "Versteek Goedgekeur",
"addChannel": "Voeg kanaal by",
"editChannel": "Wysig kanaal",
"channelName": "Naam",
@@ -4917,22 +4898,7 @@
"ntfyTopic": "Onderwerp",
"ntfyTopicRequired": "Onderwerp is vereis",
"ntfyToken": "Toegangstoken (opsioneel)",
"channelSaveFailed": "Kon nie kanaal stoor nie",
"test": "Toets",
"testSent": "Toetskennisgewing gestuur",
"testFailed": "Toetskennisgewing het misluk",
"testFailedWithReason": "Toetskennisgewing het misluk: {{reason}}",
"addRule": "Voeg waarskuwingsreël by",
"editRule": "Wysig waarskuwingsreël",
"ruleName": "Reëlnaam",
"ruleNameRequired": "Naam word vereis",
"triggerType": "Sneller",
"thresholdValue": "Drempel (%)",
"durationSeconds": "Duur (sekondes, 0 = onmiddellike vuur)",
"cooldownMinutes": "Afkoeltyd (minute)",
"channels": "Kennisgewingskanale",
"noChannelsHint": "Voeg eers kanale by die Kanale-oortjie",
"ruleSaveFailed": "Kon nie reël stoor nie"
"channelSaveFailed": "Kon nie kanaal stoor nie"
},
"ai": {
"title": "Assistent",
+1 -35
View File
@@ -287,7 +287,6 @@
"loading": "جار التحميل...",
"noData": "لا توجد بيانات",
"allClear": "كل شيء طبيعي",
"acknowledgeAlert": "إقرار",
"noPingUrls": "لم يتم تكوين عناوين URL",
"pingLabel": "التسمية",
"addPingUrl": "إضافة URL",
@@ -324,8 +323,6 @@
"widgetMetricsChartDesc": "مخطط تاريخي لوحدة المعالجة المركزية أو الذاكرة أو القرص أو الشبكة لمضيف",
"widgetHostGridName": "شبكة المضيفات",
"widgetHostGridDesc": "عرض شبكي لحالات مضيفات SSH",
"widgetAlertFeedName": "موجز التنبيهات",
"widgetAlertFeedDesc": "تنبيهات حية مع دعم الإقرار",
"widgetPingStatusName": "حالة Ping",
"widgetPingStatusDesc": "حالة ping HTTP لعنوان URL واحد أو أكثر",
"widgetRecentActivityName": "النشاط الأخير",
@@ -747,7 +744,6 @@
"portForwarding": "إعادة التوجيه",
"sftp": "SFTP",
"connections": "الاتصالات",
"alerts": "التنبيهات",
"serial": "تسلسلي",
"keepSideMenuOpen": "أبقِ قائمة الطعام مفتوحة",
"collapseSideMenu": "قائمة قابلة للطي",
@@ -4890,21 +4886,6 @@
"viewHistory": "عرض السجل"
},
"alerts": {
"tabFirings": "التنبيهات",
"tabRules": "القواعد",
"tabChannels": "القنوات",
"noFirings": "لا توجد تنبيهات غير مُقَرَّة",
"noRules": "لم يتم تكوين قواعد تنبيه",
"noChannels": "لم يتم تكوين قنوات إشعار",
"rulesDesc": "تقوم قواعد التنبيه بتشغيل الإشعارات",
"channelsDesc": "مكان إرسال إشعارات التنبيه",
"acknowledge": "إقرار",
"ackAll": "إقرار الكل",
"allAcknowledged": "تم إقرار جميع التنبيهات",
"ackFailed": "فشل في إقرار التنبيه",
"ackAllFailed": "فشل في إقرار جميع التنبيهات",
"showAcknowledged": "إظهار الكل",
"hideAcknowledged": "إخفاء المُقَرَّة",
"addChannel": "إضافة قناة",
"editChannel": "تعديل القناة",
"channelName": "الاسم",
@@ -4917,22 +4898,7 @@
"ntfyTopic": "الموضوع",
"ntfyTopicRequired": "الموضوع مطلوب",
"ntfyToken": "رمز الوصول (اختياري)",
"channelSaveFailed": "فشل حفظ القناة",
"test": "اختبار",
"testSent": "تم إرسال إشعار الاختبار",
"testFailed": "فشل إشعار الاختبار",
"testFailedWithReason": "فشل إشعار الاختبار: {{reason}}",
"addRule": "إضافة قاعدة تنبيه",
"editRule": "تعديل قاعدة تنبيه",
"ruleName": "اسم القاعدة",
"ruleNameRequired": "الاسم مطلوب",
"triggerType": "المشغّل",
"thresholdValue": "الحد (%)",
"durationSeconds": "المدة (بالثواني، 0 = إطلاق فوري)",
"cooldownMinutes": "فترة التهدئة (بالدقائق)",
"channels": "قنوات الإشعارات",
"noChannelsHint": "أضف قنوات في تبويب القنوات أولاً",
"ruleSaveFailed": "فشل حفظ القاعدة"
"channelSaveFailed": "فشل حفظ القناة"
},
"ai": {
"title": "مساعد",
+1 -35
View File
@@ -287,7 +287,6 @@
"loading": "Зареждане...",
"noData": "Няма налични данни",
"allClear": "Всичко е чисто",
"acknowledgeAlert": "Потвърждаване",
"noPingUrls": "Няма конфигурирани URL адреси",
"pingLabel": "Етикет",
"addPingUrl": "Добавяне на URL адрес",
@@ -324,8 +323,6 @@
"widgetMetricsChartDesc": "Историческа диаграма на процесора, паметта, диска или мрежата за хост",
"widgetHostGridName": "Хост мрежа",
"widgetHostGridDesc": "Табелен изглед на състоянията на SSH хоста",
"widgetAlertFeedName": "Канал за предупреждения",
"widgetAlertFeedDesc": "Активиране на аларми в реално време с поддръжка на потвърждение",
"widgetPingStatusName": "Състояние на пинг",
"widgetPingStatusDesc": "HTTP ping статус за един или повече URL адреси",
"widgetRecentActivityName": "Последна активност",
@@ -747,7 +744,6 @@
"portForwarding": "Препращане",
"sftp": "SFTP",
"connections": "Връзки",
"alerts": "Сигнали",
"serial": "Сериен",
"keepSideMenuOpen": "Запази менюто отворено",
"collapseSideMenu": "Свиване на менюто",
@@ -4890,21 +4886,6 @@
"viewHistory": "Преглед на историята"
},
"alerts": {
"tabFirings": "Сигнали",
"tabRules": "Правила",
"tabChannels": "Канали",
"noFirings": "Няма непотвърдени сигнали",
"noRules": "Няма конфигурирани правила за предупреждения",
"noChannels": "Няма конфигурирани канали за известия",
"rulesDesc": "Правилата за известия задействат известия",
"channelsDesc": "Къде се изпращат известия за предупреждения",
"acknowledge": "Потвърждаване",
"ackAll": "Потвърждение на всички",
"allAcknowledged": "Всички сигнали са потвърдени",
"ackFailed": "Неуспешно потвърждаване на предупреждението",
"ackAllFailed": "Неуспешно потвърждаване на всички сигнали",
"showAcknowledged": "Покажи всички",
"hideAcknowledged": "Скриване на одобрено",
"addChannel": "Добавяне на канал",
"editChannel": "Редактиране на канал",
"channelName": "Име",
@@ -4917,22 +4898,7 @@
"ntfyTopic": "Тема",
"ntfyTopicRequired": "Темата е задължителна",
"ntfyToken": "Токен за достъп (по избор)",
"channelSaveFailed": "Запазването на канала не бе успешно",
"test": "Тест",
"testSent": "Тестово известие е изпратено",
"testFailed": "Тестовото известие не беше успешно",
"testFailedWithReason": "Тестовото известие не беше успешно: {{reason}}",
"addRule": "Добавяне на правило за предупреждение",
"editRule": "Редактиране на правило за предупреждение",
"ruleName": "Име на правилото",
"ruleNameRequired": "Името е задължително",
"triggerType": "Спусък",
"thresholdValue": "Праг (%)",
"durationSeconds": "Продължителност (секунди, 0 = незабавно задействане)",
"cooldownMinutes": "Охлаждане (минути)",
"channels": "Канали за известия",
"noChannelsHint": "Първо добавете канали в раздела „Канали“",
"ruleSaveFailed": "Запазването на правилото не бе успешно"
"channelSaveFailed": "Запазването на канала не бе успешно"
},
"ai": {
"title": "Асистент",
+1 -35
View File
@@ -287,7 +287,6 @@
"loading": "লোড হচ্ছে...",
"noData": "কোন ডেটা উপলব্ধ নেই",
"allClear": "সব পরিষ্কার",
"acknowledgeAlert": "স্বীকার করুন",
"noPingUrls": "কোন URL কনফিগার করা নেই",
"pingLabel": "লেবেল",
"addPingUrl": "URL যোগ করুন",
@@ -324,8 +323,6 @@
"widgetMetricsChartDesc": "একটি হোস্টের ঐতিহাসিক সিপিইউ, মেমরি, ডিস্ক বা নেটওয়ার্ক চার্ট",
"widgetHostGridName": "হোস্ট গ্রিড",
"widgetHostGridDesc": "SSH হোস্ট স্ট্যাটাসের গ্রিড ভিউ",
"widgetAlertFeedName": "সতর্কতা ফিড",
"widgetAlertFeedDesc": "স্বীকৃতি সমর্থন সহ লাইভ অ্যালার্ট চালু",
"widgetPingStatusName": "পিং স্ট্যাটাস",
"widgetPingStatusDesc": "এক বা একাধিক URL-এর জন্য HTTP পিং স্ট্যাটাস",
"widgetRecentActivityName": "সাম্প্রতিক কার্যকলাপ",
@@ -747,7 +744,6 @@
"portForwarding": "ফরোয়ার্ডিং",
"sftp": "এসএফটিপি",
"connections": "সংযোগ",
"alerts": "সতর্কতা",
"serial": "সিরিয়াল",
"keepSideMenuOpen": "মেনু খোলা রাখুন",
"collapseSideMenu": "মেনু সংকুচিত করুন",
@@ -4890,21 +4886,6 @@
"viewHistory": "ইতিহাস দেখুন"
},
"alerts": {
"tabFirings": "সতর্কতা",
"tabRules": "নিয়ম",
"tabChannels": "চ্যানেল",
"noFirings": "কোনো অস্বীকৃত সতর্কতা নেই",
"noRules": "কোনো সতর্কতা নিয়ম কনফিগার করা নেই",
"noChannels": "কোন বিজ্ঞপ্তি চ্যানেল কনফিগার করা নেই",
"rulesDesc": "সতর্কতা নিয়মগুলি বিজ্ঞপ্তি ট্রিগার করে",
"channelsDesc": "যেখানে সতর্কবার্তা পাঠানো হয়",
"acknowledge": "স্বীকার করুন",
"ackAll": "সবাইকে ধন্যবাদ",
"allAcknowledged": "সকল সতর্কতা স্বীকার করা হয়েছে",
"ackFailed": "সতর্কতা স্বীকার করতে ব্যর্থ হয়েছে",
"ackAllFailed": "সমস্ত সতর্কতা স্বীকার করতে ব্যর্থ হয়েছে",
"showAcknowledged": "সব দেখান",
"hideAcknowledged": "লুকান অ্যাকড",
"addChannel": "চ্যানেল যোগ করুন",
"editChannel": "চ্যানেল সম্পাদনা করুন",
"channelName": "নাম",
@@ -4917,22 +4898,7 @@
"ntfyTopic": "বিষয়",
"ntfyTopicRequired": "বিষয় আবশ্যক",
"ntfyToken": "অ্যাক্সেস টোকেন (ঐচ্ছিক)",
"channelSaveFailed": "চ্যানেল সংরক্ষণ করতে ব্যর্থ হয়েছে",
"test": "পরীক্ষা",
"testSent": "পরীক্ষার বিজ্ঞপ্তি পাঠানো হয়েছে",
"testFailed": "পরীক্ষার বিজ্ঞপ্তি ব্যর্থ হয়েছে",
"testFailedWithReason": "পরীক্ষার বিজ্ঞপ্তি ব্যর্থ হয়েছে: {{reason}}",
"addRule": "সতর্কতা নিয়ম যোগ করুন",
"editRule": "সতর্কতা নিয়ম সম্পাদনা করুন",
"ruleName": "নিয়মের নাম",
"ruleNameRequired": "নাম আবশ্যক",
"triggerType": "ট্রিগার",
"thresholdValue": "থ্রেশহোল্ড (%)",
"durationSeconds": "সময়কাল (সেকেন্ড, ০ = অবিলম্বে চালু হবে)",
"cooldownMinutes": "কুলডাউন (মিনিট)",
"channels": "বিজ্ঞপ্তি চ্যানেল",
"noChannelsHint": "প্রথমে চ্যানেল ট্যাবে চ্যানেল যোগ করুন।",
"ruleSaveFailed": "নিয়ম সংরক্ষণ করতে ব্যর্থ হয়েছে"
"channelSaveFailed": "চ্যানেল সংরক্ষণ করতে ব্যর্থ হয়েছে"
},
"ai": {
"title": "সহকারী",
+1 -35
View File
@@ -287,7 +287,6 @@
"loading": "S'està carregant...",
"noData": "No hi ha dades disponibles",
"allClear": "Tot clar",
"acknowledgeAlert": "Reconeix",
"noPingUrls": "No s'han configurat URL",
"pingLabel": "Etiqueta",
"addPingUrl": "Afegeix URL",
@@ -324,8 +323,6 @@
"widgetMetricsChartDesc": "Gràfic històric de CPU, memòria, disc o xarxa per a un host",
"widgetHostGridName": "Graella d'amfitrió",
"widgetHostGridDesc": "Vista de quadrícula dels estats dels hosts SSH",
"widgetAlertFeedName": "Canal d'alertes",
"widgetAlertFeedDesc": "Trets d'alerta en directe amb suport de confirmació",
"widgetPingStatusName": "Estat del ping",
"widgetPingStatusDesc": "Estat del ping HTTP per a una o més URL",
"widgetRecentActivityName": "Activitat recent",
@@ -747,7 +744,6 @@
"portForwarding": "Reenviament",
"sftp": "SFTP",
"connections": "Connexions",
"alerts": "Alertes",
"serial": "Sèrie",
"keepSideMenuOpen": "Mantenir el menú obert",
"collapseSideMenu": "Menú replegable",
@@ -4890,21 +4886,6 @@
"viewHistory": "Veure l'historial"
},
"alerts": {
"tabFirings": "Alertes",
"tabRules": "Regles",
"tabChannels": "Canals",
"noFirings": "Sense alertes no confirmades",
"noRules": "No hi ha cap regla d'alerta configurada",
"noChannels": "No hi ha canals de notificació configurats",
"rulesDesc": "Les regles d'alerta activen les notificacions",
"channelsDesc": "On s'envien les notificacions d'alerta",
"acknowledge": "Reconeix",
"ackAll": "Ack Tot",
"allAcknowledged": "Totes les alertes han estat reconegudes",
"ackFailed": "No s'ha pogut confirmar l'alerta",
"ackAllFailed": "No s'han pogut reconèixer totes les alertes",
"showAcknowledged": "Mostra-ho tot",
"hideAcknowledged": "Amaga l'acusat",
"addChannel": "Afegeix un canal",
"editChannel": "Edita el canal",
"channelName": "Nom",
@@ -4917,22 +4898,7 @@
"ntfyTopic": "Tema",
"ntfyTopicRequired": "El tema és obligatori",
"ntfyToken": "Token d'accés (opcional)",
"channelSaveFailed": "No s'ha pogut desar el canal",
"test": "Prova",
"testSent": "Notificació de prova enviada",
"testFailed": "La notificació de prova ha fallat",
"testFailedWithReason": "La notificació de prova ha fallat: {{reason}}",
"addRule": "Afegeix una regla d'alerta",
"editRule": "Edita la regla d'alerta",
"ruleName": "Nom de la regla",
"ruleNameRequired": "El nom és obligatori",
"triggerType": "Disparador",
"thresholdValue": "Llindar (%)",
"durationSeconds": "Durada (segons, 0 = dispara immediatament)",
"cooldownMinutes": "Refredament (minuts)",
"channels": "Canals de notificació",
"noChannelsHint": "Afegiu primer els canals a la pestanya Canals",
"ruleSaveFailed": "No s'ha pogut desar la regla"
"channelSaveFailed": "No s'ha pogut desar el canal"
},
"ai": {
"title": "Assistent",
+1 -35
View File
@@ -287,7 +287,6 @@
"loading": "Načítání...",
"noData": "Nejsou k dispozici žádná data",
"allClear": "Všechno čisté",
"acknowledgeAlert": "Potvrdit",
"noPingUrls": "Žádné adresy URL nejsou nakonfigurovány",
"pingLabel": "Označení",
"addPingUrl": "Přidat URL",
@@ -324,8 +323,6 @@
"widgetMetricsChartDesc": "Historický graf CPU, paměti, disku nebo sítě pro hostitele",
"widgetHostGridName": "Hostitelská mřížka",
"widgetHostGridDesc": "Mřížkové zobrazení stavů hostitelů SSH",
"widgetAlertFeedName": "Zdroj upozornění",
"widgetAlertFeedDesc": "Živé výstrahy s podporou potvrzení",
"widgetPingStatusName": "Stav pingu",
"widgetPingStatusDesc": "Stav HTTP pingu pro jednu nebo více URL adres",
"widgetRecentActivityName": "Nedávná aktivita",
@@ -747,7 +744,6 @@
"portForwarding": "Přeposílání",
"sftp": "SFTP",
"connections": "Připojení",
"alerts": "Upozornění",
"serial": "Seriál",
"keepSideMenuOpen": "Ponechat nabídku otevřenou",
"collapseSideMenu": "Sbalit nabídku",
@@ -4890,21 +4886,6 @@
"viewHistory": "Zobrazit historii"
},
"alerts": {
"tabFirings": "Upozornění",
"tabRules": "Pravidla",
"tabChannels": "Kanály",
"noFirings": "Žádná nepotvrzená upozornění",
"noRules": "Nejsou nakonfigurována žádná pravidla upozornění",
"noChannels": "Žádné nakonfigurované kanály oznámení",
"rulesDesc": "Pravidla upozornění spouštějí oznámení",
"channelsDesc": "Kam se odesílají upozornění",
"acknowledge": "Potvrdit",
"ackAll": "Potvrzení všeho",
"allAcknowledged": "Všechna upozornění potvrzena",
"ackFailed": "Nepodařilo se potvrdit upozornění",
"ackAllFailed": "Nepodařilo se potvrdit všechna upozornění",
"showAcknowledged": "Zobrazit vše",
"hideAcknowledged": "Skrýt potvrzeno",
"addChannel": "Přidat kanál",
"editChannel": "Upravit kanál",
"channelName": "Jméno",
@@ -4917,22 +4898,7 @@
"ntfyTopic": "Téma",
"ntfyTopicRequired": "Téma je povinné",
"ntfyToken": "Přístupový token (volitelné)",
"channelSaveFailed": "Uložení kanálu se nezdařilo",
"test": "Test",
"testSent": "Testovací oznámení odesláno",
"testFailed": "Testovací oznámení selhalo",
"testFailedWithReason": "Testovací oznámení selhalo: {{reason}}",
"addRule": "Přidat pravidlo upozornění",
"editRule": "Upravit pravidlo upozornění",
"ruleName": "Název pravidla",
"ruleNameRequired": "Jméno je povinné",
"triggerType": "Spoušť",
"thresholdValue": "Prahová hodnota (%)",
"durationSeconds": "Trvání (sekundy, 0 = okamžitá výstřel)",
"cooldownMinutes": "Doba ochlazování (minuty)",
"channels": "Oznamovací kanály",
"noChannelsHint": "Nejprve přidejte kanály na kartě Kanály",
"ruleSaveFailed": "Uložení pravidla se nezdařilo"
"channelSaveFailed": "Uložení kanálu se nezdařilo"
},
"ai": {
"title": "Asistent",
+1 -35
View File
@@ -287,7 +287,6 @@
"loading": "Indlæser...",
"noData": "Ingen data tilgængelige",
"allClear": "Alt klart",
"acknowledgeAlert": "Anerkende",
"noPingUrls": "Ingen URL'er konfigureret",
"pingLabel": "Mærke",
"addPingUrl": "Tilføj URL",
@@ -324,8 +323,6 @@
"widgetMetricsChartDesc": "Historisk CPU-, hukommelses-, disk- eller netværksdiagram for en vært",
"widgetHostGridName": "Værtgitter",
"widgetHostGridDesc": "Gittervisning af SSH-værtsstatusser",
"widgetAlertFeedName": "Alarmfeed",
"widgetAlertFeedDesc": "Live alarmudløsninger med bekræftelsesstøtte",
"widgetPingStatusName": "Ping-status",
"widgetPingStatusDesc": "HTTP-pingstatus for en eller flere URL'er",
"widgetRecentActivityName": "Seneste aktivitet",
@@ -747,7 +744,6 @@
"portForwarding": "Videresendelse",
"sftp": "SFTP",
"connections": "Forbindelser",
"alerts": "Advarsler",
"serial": "Seriel",
"keepSideMenuOpen": "Hold menuen åben",
"collapseSideMenu": "Skjul menu",
@@ -4890,21 +4886,6 @@
"viewHistory": "Vis historik"
},
"alerts": {
"tabFirings": "Advarsler",
"tabRules": "Regler",
"tabChannels": "Kanaler",
"noFirings": "Ingen ubekræftede advarsler",
"noRules": "Ingen alarmregler konfigureret",
"noChannels": "Ingen notifikationskanaler konfigureret",
"rulesDesc": "Alarmregler udløser notifikationer",
"channelsDesc": "Hvor alarmmeddelelser sendes",
"acknowledge": "Anerkende",
"ackAll": "Bekræft alle",
"allAcknowledged": "Alle alarmer bekræftet",
"ackFailed": "Kunne ikke bekræfte advarslen",
"ackAllFailed": "Kunne ikke bekræfte alle advarsler",
"showAcknowledged": "Vis alle",
"hideAcknowledged": "Skjul bekræftet",
"addChannel": "Tilføj kanal",
"editChannel": "Rediger kanal",
"channelName": "Navn",
@@ -4917,22 +4898,7 @@
"ntfyTopic": "Emne",
"ntfyTopicRequired": "Emne er påkrævet",
"ntfyToken": "Adgangstoken (valgfrit)",
"channelSaveFailed": "Kanalen kunne ikke gemmes",
"test": "Prøve",
"testSent": "Testnotifikation sendt",
"testFailed": "Testnotifikation mislykkedes",
"testFailedWithReason": "Testnotifikation mislykkedes: {{reason}}",
"addRule": "Tilføj alarmregel",
"editRule": "Rediger alarmregel",
"ruleName": "Regelnavn",
"ruleNameRequired": "Navn er påkrævet",
"triggerType": "Udløser",
"thresholdValue": "Tærskel (%)",
"durationSeconds": "Varighed (sekunder, 0 = affyres øjeblikkeligt)",
"cooldownMinutes": "Nedkølingstid (minutter)",
"channels": "Meddelelseskanaler",
"noChannelsHint": "Tilføj kanaler i fanen Kanaler først",
"ruleSaveFailed": "Reglen kunne ikke gemmes"
"channelSaveFailed": "Kanalen kunne ikke gemmes"
},
"ai": {
"title": "Assistent",
+1 -35
View File
@@ -287,7 +287,6 @@
"loading": "Wird geladen...",
"noData": "Keine Daten verfügbar",
"allClear": "Alles in Ordnung",
"acknowledgeAlert": "Bestätigen",
"noPingUrls": "Keine URLs konfiguriert",
"pingLabel": "Bezeichnung",
"addPingUrl": "URL hinzufügen",
@@ -324,8 +323,6 @@
"widgetMetricsChartDesc": "Historisches CPU-, Arbeitsspeicher-, Festplatten- oder Netzwerkdiagramm für einen Host",
"widgetHostGridName": "Host-Raster",
"widgetHostGridDesc": "Rasteransicht der SSH-Host-Status",
"widgetAlertFeedName": "Alarm-Feed",
"widgetAlertFeedDesc": "Live-Alarmauslösungen mit Bestätigungsfunktion",
"widgetPingStatusName": "Ping-Status",
"widgetPingStatusDesc": "HTTP-Ping-Status für eine oder mehrere URLs",
"widgetRecentActivityName": "Letzte Aktivität",
@@ -747,7 +744,6 @@
"portForwarding": "Weiterleitung",
"sftp": "SFTP",
"connections": "Verbindungen",
"alerts": "Benachrichtigungen",
"serial": "Seriell",
"keepSideMenuOpen": "Menü geöffnet lassen",
"collapseSideMenu": "Menü ausklappen",
@@ -4890,21 +4886,6 @@
"viewHistory": "Verlauf anzeigen"
},
"alerts": {
"tabFirings": "Alarme",
"tabRules": "Regeln",
"tabChannels": "Kanäle",
"noFirings": "Keine unbestätigten Alarme",
"noRules": "Keine Alarmregeln konfiguriert",
"noChannels": "Keine Benachrichtigungskanäle konfiguriert",
"rulesDesc": "Alarmregeln lösen Benachrichtigungen aus",
"channelsDesc": "Wohin Alarmbenachrichtigungen gesendet werden",
"acknowledge": "Quittieren",
"ackAll": "Alle quittieren",
"allAcknowledged": "Alle Alarme quittiert",
"ackFailed": "Quittieren des Alarms fehlgeschlagen",
"ackAllFailed": "Quittieren aller Alarme fehlgeschlagen",
"showAcknowledged": "Alle anzeigen",
"hideAcknowledged": "Quittierte ausblenden",
"addChannel": "Kanal hinzufügen",
"editChannel": "Kanal bearbeiten",
"channelName": "Name",
@@ -4917,22 +4898,7 @@
"ntfyTopic": "Topic",
"ntfyTopicRequired": "Topic ist erforderlich",
"ntfyToken": "Zugriffstoken (optional)",
"channelSaveFailed": "Kanal konnte nicht gespeichert werden",
"test": "Test",
"testSent": "Testbenachrichtigung gesendet",
"testFailed": "Testbenachrichtigung fehlgeschlagen",
"testFailedWithReason": "Testbenachrichtigung fehlgeschlagen: {{reason}}",
"addRule": "Alarmregel hinzufügen",
"editRule": "Alarmregel bearbeiten",
"ruleName": "Regelname",
"ruleNameRequired": "Name ist erforderlich",
"triggerType": "Auslöser",
"thresholdValue": "Schwellenwert (%)",
"durationSeconds": "Dauer (Sekunden, 0 = sofort auslösen)",
"cooldownMinutes": "Abklingzeit (Minuten)",
"channels": "Benachrichtigungskanäle",
"noChannelsHint": "Fügen Sie zuerst Kanäle im Tab 'Kanäle' hinzu",
"ruleSaveFailed": "Regel konnte nicht gespeichert werden"
"channelSaveFailed": "Kanal konnte nicht gespeichert werden"
},
"ai": {
"title": "Assistent",
+1 -35
View File
@@ -287,7 +287,6 @@
"loading": "Φόρτωση...",
"noData": "Δεν υπάρχουν διαθέσιμα δεδομένα",
"allClear": "Ορίζοντας είναι σαφής",
"acknowledgeAlert": "Αναγνωρίζω",
"noPingUrls": "Δεν έχουν ρυθμιστεί διευθύνσεις URL",
"pingLabel": "Επιγραφή",
"addPingUrl": "Προσθήκη διεύθυνσης URL",
@@ -324,8 +323,6 @@
"widgetMetricsChartDesc": "Ιστορικό διάγραμμα CPU, μνήμης, δίσκου ή δικτύου για έναν κεντρικό υπολογιστή",
"widgetHostGridName": "Πλέγμα κεντρικού υπολογιστή",
"widgetHostGridDesc": "Προβολή πλέγματος των καταστάσεων κεντρικού υπολογιστή SSH",
"widgetAlertFeedName": "Ροή ειδοποιήσεων",
"widgetAlertFeedDesc": "Ενεργοποιήσεις ζωντανών ειδοποιήσεων με υποστήριξη αναγνώρισης",
"widgetPingStatusName": "Κατάσταση Ping",
"widgetPingStatusDesc": "Κατάσταση ping HTTP για μία ή περισσότερες διευθύνσεις URL",
"widgetRecentActivityName": "Πρόσφατη δραστηριότητα",
@@ -747,7 +744,6 @@
"portForwarding": "Προώθηση",
"sftp": "SFTP",
"connections": "Συνδέσεις",
"alerts": "Ειδοποιήσεις",
"serial": "Σειράς",
"keepSideMenuOpen": "Διατήρηση μενού ανοιχτού",
"collapseSideMenu": "Σύμπτυξη μενού",
@@ -4890,21 +4886,6 @@
"viewHistory": "Προβολή ιστορικού"
},
"alerts": {
"tabFirings": "Ειδοποιήσεις",
"tabRules": "Κανόνες",
"tabChannels": "Κανάλια",
"noFirings": "Δεν υπάρχουν μη επιβεβαιωμένες ειδοποιήσεις",
"noRules": "Δεν έχουν ρυθμιστεί κανόνες ειδοποίησης",
"noChannels": "Δεν έχουν ρυθμιστεί κανάλια ειδοποιήσεων",
"rulesDesc": "Οι κανόνες ειδοποίησης ενεργοποιούν ειδοποιήσεις",
"channelsDesc": "Πού αποστέλλονται ειδοποιήσεις",
"acknowledge": "Αναγνωρίζω",
"ackAll": "Επιβεβαίωση όλων",
"allAcknowledged": "Όλες οι ειδοποιήσεις επιβεβαιώθηκαν",
"ackFailed": "Αποτυχία επιβεβαίωσης ειδοποίησης",
"ackAllFailed": "Αποτυχία επιβεβαίωσης όλων των ειδοποιήσεων",
"showAcknowledged": "Εμφάνιση όλων",
"hideAcknowledged": "Απόκρυψη επικυρωμένων",
"addChannel": "Προσθήκη καναλιού",
"editChannel": "Επεξεργασία καναλιού",
"channelName": "Ονομα",
@@ -4917,22 +4898,7 @@
"ntfyTopic": "Θέμα",
"ntfyTopicRequired": "Το θέμα είναι υποχρεωτικό",
"ntfyToken": "Διακριτικό πρόσβασης (προαιρετικό)",
"channelSaveFailed": "Η αποθήκευση του καναλιού απέτυχε",
"test": "Δοκιμή",
"testSent": "Αποστολή ειδοποίησης δοκιμής",
"testFailed": "Η ειδοποίηση δοκιμής απέτυχε.",
"testFailedWithReason": "Η ειδοποίηση δοκιμής απέτυχε: {{reason}}",
"addRule": "Προσθήκη κανόνα ειδοποίησης",
"editRule": "Επεξεργασία κανόνα ειδοποίησης",
"ruleName": "Όνομα κανόνα",
"ruleNameRequired": "Απαιτείται όνομα",
"triggerType": "Σκανδάλη",
"thresholdValue": "Όριο (%)",
"durationSeconds": "Διάρκεια (δευτερόλεπτα, 0 = άμεση πυροδότηση)",
"cooldownMinutes": "Χρόνος αναμονής (λεπτά)",
"channels": "Κανάλια ειδοποιήσεων",
"noChannelsHint": "Προσθέστε πρώτα κανάλια στην καρτέλα Κανάλια",
"ruleSaveFailed": "Η αποθήκευση του κανόνα απέτυχε"
"channelSaveFailed": "Η αποθήκευση του καναλιού απέτυχε"
},
"ai": {
"title": "Βοηθός",
+1 -35
View File
@@ -287,7 +287,6 @@
"loading": "Cargando...",
"noData": "Sin datos disponibles",
"allClear": "Todo correcto",
"acknowledgeAlert": "Reconocer",
"noPingUrls": "No hay URLs configuradas",
"pingLabel": "Etiqueta",
"addPingUrl": "Añadir URL",
@@ -324,8 +323,6 @@
"widgetMetricsChartDesc": "Gráfico histórico de CPU, memoria, disco o red para un host",
"widgetHostGridName": "Cuadrícula de hosts",
"widgetHostGridDesc": "Vista en cuadrícula de los estados de los hosts SSH",
"widgetAlertFeedName": "Feed de alertas",
"widgetAlertFeedDesc": "Alertas en tiempo real con soporte para reconocimiento",
"widgetPingStatusName": "Estado de ping",
"widgetPingStatusDesc": "Estado de ping HTTP para una o más URLs",
"widgetRecentActivityName": "Actividad reciente",
@@ -747,7 +744,6 @@
"portForwarding": "Reenvío",
"sftp": "SFTP",
"connections": "Conexiones",
"alerts": "Alertas",
"serial": "Serial",
"keepSideMenuOpen": "Mantener el menú abierto",
"collapseSideMenu": "Contraer menú",
@@ -4890,21 +4886,6 @@
"viewHistory": "Ver Historial"
},
"alerts": {
"tabFirings": "Alertas",
"tabRules": "Reglas",
"tabChannels": "Canales",
"noFirings": "No hay alertas sin reconocer",
"noRules": "No hay reglas de alerta configuradas",
"noChannels": "No hay canales de notificación configurados",
"rulesDesc": "Las reglas de alerta disparan notificaciones",
"channelsDesc": "Dónde se envían las notificaciones de alerta",
"acknowledge": "Reconocer",
"ackAll": "Reconocer todo",
"allAcknowledged": "Todas las alertas reconocidas",
"ackFailed": "Error al reconocer la alerta",
"ackAllFailed": "Error al reconocer todas las alertas",
"showAcknowledged": "Mostrar todas",
"hideAcknowledged": "Ocultar reconocidas",
"addChannel": "Añadir canal",
"editChannel": "Editar canal",
"channelName": "Nombre",
@@ -4917,22 +4898,7 @@
"ntfyTopic": "Tema",
"ntfyTopicRequired": "El tema es obligatorio",
"ntfyToken": "Token de acceso (opcional)",
"channelSaveFailed": "No se pudo guardar el canal",
"test": "Probar",
"testSent": "Notificación de prueba enviada",
"testFailed": "La notificación de prueba falló",
"testFailedWithReason": "La notificación de prueba falló: {{reason}}",
"addRule": "Añadir regla de alerta",
"editRule": "Editar regla de alerta",
"ruleName": "Nombre de la regla",
"ruleNameRequired": "El nombre es obligatorio",
"triggerType": "Disparador",
"thresholdValue": "Umbral (%)",
"durationSeconds": "Duración (segundos, 0 = disparar inmediatamente)",
"cooldownMinutes": "Tiempo de espera (minutos)",
"channels": "Canales de notificación",
"noChannelsHint": "Añade primero canales en la pestaña Canales",
"ruleSaveFailed": "No se pudo guardar la regla"
"channelSaveFailed": "No se pudo guardar el canal"
},
"ai": {
"title": "Asistente",
+1 -35
View File
@@ -287,7 +287,6 @@
"loading": "Ladataan...",
"noData": "Ei tietoja saatavilla",
"allClear": "Kaikki selvää",
"acknowledgeAlert": "Kuittaa",
"noPingUrls": "Ei määritettyjä URL-osoitteita",
"pingLabel": "Tarra",
"addPingUrl": "Lisää URL-osoite",
@@ -324,8 +323,6 @@
"widgetMetricsChartDesc": "Isännän historiallinen suorittimen, muistin, levyn tai verkon kaavio",
"widgetHostGridName": "Isäntäverkko",
"widgetHostGridDesc": "SSH-isäntätilojen ruudukkonäkymä",
"widgetAlertFeedName": "Hälytyssyöte",
"widgetAlertFeedDesc": "Reaaliaikaiset hälytyslaukaisut kuittaustuella",
"widgetPingStatusName": "Ping-tila",
"widgetPingStatusDesc": "Yhden tai useamman URL-osoitteen HTTP-ping-tila",
"widgetRecentActivityName": "Viimeaikaiset toiminnot",
@@ -747,7 +744,6 @@
"portForwarding": "Edelleenlähetys",
"sftp": "SFTP",
"connections": "Yhteydet",
"alerts": "Hälytykset",
"serial": "Sarjanumero",
"keepSideMenuOpen": "Pidä valikko auki",
"collapseSideMenu": "Pienennä valikko",
@@ -4890,21 +4886,6 @@
"viewHistory": "Näytä historia"
},
"alerts": {
"tabFirings": "Hälytykset",
"tabRules": "Säännöt",
"tabChannels": "Kanavat",
"noFirings": "Ei kuittaamattomia hälytyksiä",
"noRules": "Ei määritettyjä hälytyssääntöjä",
"noChannels": "Ei määritettyjä ilmoituskanavia",
"rulesDesc": "Hälytyssäännöt käynnistävät ilmoituksia",
"channelsDesc": "Minne hälytysilmoitukset lähetetään",
"acknowledge": "Kuittaa",
"ackAll": "Ack All",
"allAcknowledged": "Kaikki hälytykset kuitattu",
"ackFailed": "Hälytyksen kuittaus epäonnistui",
"ackAllFailed": "Kaikkien hälytysten kuittaus epäonnistui",
"showAcknowledged": "Näytä kaikki",
"hideAcknowledged": "Piilota kuitattu",
"addChannel": "Lisää kanava",
"editChannel": "Muokkaa kanavaa",
"channelName": "Nimi",
@@ -4917,22 +4898,7 @@
"ntfyTopic": "Aihe",
"ntfyTopicRequired": "Aihe on pakollinen",
"ntfyToken": "Käyttöoikeustunnus (valinnainen)",
"channelSaveFailed": "Kanavan tallentaminen epäonnistui",
"test": "Testata",
"testSent": "Testi-ilmoitus lähetetty",
"testFailed": "Testi-ilmoitus epäonnistui",
"testFailedWithReason": "Testi-ilmoitus epäonnistui: {{reason}}",
"addRule": "Lisää hälytyssääntö",
"editRule": "Muokkaa hälytyssääntöä",
"ruleName": "Säännön nimi",
"ruleNameRequired": "Nimi on pakollinen",
"triggerType": "Laukaista",
"thresholdValue": "Kynnysarvo (%)",
"durationSeconds": "Kesto (sekuntia, 0 = käynnistyy välittömästi)",
"cooldownMinutes": "Jäähdyttelyaika (minuuttia)",
"channels": "Ilmoituskanavat",
"noChannelsHint": "Lisää kanavat ensin Kanavat-välilehdellä",
"ruleSaveFailed": "Säännön tallentaminen epäonnistui"
"channelSaveFailed": "Kanavan tallentaminen epäonnistui"
},
"ai": {
"title": "Assistentti",
+1 -35
View File
@@ -287,7 +287,6 @@
"loading": "Chargement…",
"noData": "Aucune donnée disponible",
"allClear": "Aucune alerte",
"acknowledgeAlert": "Acquitter",
"noPingUrls": "Aucune URL configurée",
"pingLabel": "Libellé",
"addPingUrl": "Ajouter une URL",
@@ -324,8 +323,6 @@
"widgetMetricsChartDesc": "Graphique historique CPU, mémoire, disque ou réseau pour un hôte",
"widgetHostGridName": "Grille d'hôtes",
"widgetHostGridDesc": "Vue en grille des statuts des hôtes SSH",
"widgetAlertFeedName": "Flux d'alertes",
"widgetAlertFeedDesc": "Déclenchements d'alertes en direct avec acquittement",
"widgetPingStatusName": "Statut du ping",
"widgetPingStatusDesc": "Statut du ping HTTP pour une ou plusieurs URL",
"widgetRecentActivityName": "Activité récente",
@@ -747,7 +744,6 @@
"portForwarding": "Expéditeur",
"sftp": "SFTP",
"connections": "Connexions",
"alerts": "Alertes",
"serial": "Série",
"keepSideMenuOpen": "Garder le menu ouvert",
"collapseSideMenu": "Réduire le menu",
@@ -4890,21 +4886,6 @@
"viewHistory": "Voir l'historique"
},
"alerts": {
"tabFirings": "Alertes",
"tabRules": "Règles",
"tabChannels": "Canaux",
"noFirings": "Aucune alerte non acquittée",
"noRules": "Aucune règle d'alerte configurée",
"noChannels": "Aucun canal de notification configuré",
"rulesDesc": "Les règles d'alerte déclenchent des notifications",
"channelsDesc": "Où les notifications d'alerte sont envoyées",
"acknowledge": "Acquitter",
"ackAll": "Tout acquitter",
"allAcknowledged": "Toutes les alertes acquittées",
"ackFailed": "Échec de l'acquittement de l'alerte",
"ackAllFailed": "Échec de l'acquittement de toutes les alertes",
"showAcknowledged": "Tout afficher",
"hideAcknowledged": "Masquer les acquittées",
"addChannel": "Ajouter un canal",
"editChannel": "Modifier le canal",
"channelName": "Nom",
@@ -4917,22 +4898,7 @@
"ntfyTopic": "Sujet",
"ntfyTopicRequired": "Le sujet est obligatoire",
"ntfyToken": "Jeton d'accès (facultatif)",
"channelSaveFailed": "Échec de l'enregistrement du canal",
"test": "Tester",
"testSent": "Notification de test envoyée",
"testFailed": "Échec de la notification de test",
"testFailedWithReason": "Échec de la notification de test : {{reason}}",
"addRule": "Ajouter une règle d'alerte",
"editRule": "Modifier la règle d'alerte",
"ruleName": "Nom de la règle",
"ruleNameRequired": "Le nom est obligatoire",
"triggerType": "Déclencheur",
"thresholdValue": "Seuil (%)",
"durationSeconds": "Durée (secondes, 0 = déclenchement immédiat)",
"cooldownMinutes": "Temporisation (minutes)",
"channels": "Canaux de notification",
"noChannelsHint": "Ajoutez d'abord des canaux dans l'onglet Canaux",
"ruleSaveFailed": "Échec de l'enregistrement de la règle"
"channelSaveFailed": "Échec de l'enregistrement du canal"
},
"ai": {
"title": "Assistant",
+1 -35
View File
@@ -287,7 +287,6 @@
"loading": "טְעִינָה...",
"noData": "אין נתונים זמינים",
"allClear": "הכל ברור",
"acknowledgeAlert": "לְהוֹדוֹת",
"noPingUrls": "לא הוגדרו כתובות URL",
"pingLabel": "מַדבֵּקָה",
"addPingUrl": "הוסף כתובת URL",
@@ -324,8 +323,6 @@
"widgetMetricsChartDesc": "תרשים היסטורי של מעבד, זיכרון, דיסק או רשת עבור מחשב מארח",
"widgetHostGridName": "רשת מארחים",
"widgetHostGridDesc": "תצוגת רשת של סטטוסי מארח SSH",
"widgetAlertFeedName": "הזנת התראות",
"widgetAlertFeedDesc": "ירי התרעה בזמן אמת עם תמיכה באישור",
"widgetPingStatusName": "סטטוס פינג",
"widgetPingStatusDesc": "סטטוס פינג HTTP עבור כתובת URL אחת או יותר",
"widgetRecentActivityName": "פעילות אחרונה",
@@ -747,7 +744,6 @@
"portForwarding": "העברה",
"sftp": "SFTP",
"connections": "חיבורים",
"alerts": "התראות",
"serial": "סִדרָתִי",
"keepSideMenuOpen": "שמור את התפריט פתוח",
"collapseSideMenu": "כווץ תפריט",
@@ -4890,21 +4886,6 @@
"viewHistory": "הצג היסטוריה"
},
"alerts": {
"tabFirings": "התראות",
"tabRules": "כללים",
"tabChannels": "ערוצים",
"noFirings": "אין התראות שלא אושרו",
"noRules": "לא הוגדרו כללי התראות",
"noChannels": "לא הוגדרו ערוצי התראות",
"rulesDesc": "כללי התראות מפעילים התראות",
"channelsDesc": "לאן נשלחות התראות",
"acknowledge": "לְהוֹדוֹת",
"ackAll": "אשר הכל",
"allAcknowledged": "כל ההתראות אושרו",
"ackFailed": "נכשל אישור ההתראה",
"ackAllFailed": "נכשל אישור כל ההתראות",
"showAcknowledged": "הצג הכל",
"hideAcknowledged": "הסתר אישור",
"addChannel": "הוסף ערוץ",
"editChannel": "עריכת ערוץ",
"channelName": "שֵׁם",
@@ -4917,22 +4898,7 @@
"ntfyTopic": "נוֹשֵׂא",
"ntfyTopicRequired": "נושא נדרש",
"ntfyToken": "אסימון גישה (אופציונלי)",
"channelSaveFailed": "שמירת הערוץ נכשלה",
"test": "מִבְחָן",
"testSent": "הודעת בדיקה נשלחה",
"testFailed": "הודעת הבדיקה נכשלה",
"testFailedWithReason": "הודעת הבדיקה נכשלה: {{reason}}",
"addRule": "הוסף כלל התראה",
"editRule": "עריכת כלל התראה",
"ruleName": "שם הכלל",
"ruleNameRequired": "שם נדרש",
"triggerType": "לְהַפְעִיל",
"thresholdValue": "סף (%)",
"durationSeconds": "משך (שניות, 0 = ירי מיידי)",
"cooldownMinutes": "זמן קירור (דקות)",
"channels": "ערוצי התראות",
"noChannelsHint": "הוסף תחילה ערוצים בכרטיסייה ערוצים",
"ruleSaveFailed": "שמירת הכלל נכשלה"
"channelSaveFailed": "שמירת הערוץ נכשלה"
},
"ai": {
"title": "עוֹזֵר",
+1 -35
View File
@@ -287,7 +287,6 @@
"loading": "लोड हो रहा है...",
"noData": "कोई डेटा उपलब्ध नहीं",
"allClear": "सब ठीक है",
"acknowledgeAlert": "स्वीकार करें",
"noPingUrls": "कोई URL कॉन्फ़िगर नहीं किया गया",
"pingLabel": "लेबल",
"addPingUrl": "URL जोड़ें",
@@ -324,8 +323,6 @@
"widgetMetricsChartDesc": "होस्ट के लिए ऐतिहासिक CPU, मेमोरी, डिस्क या नेटवर्क चार्ट",
"widgetHostGridName": "होस्ट ग्रिड",
"widgetHostGridDesc": "SSH होस्ट की स्थितियों का ग्रिड दृश्य",
"widgetAlertFeedName": "अलर्ट फीड",
"widgetAlertFeedDesc": "स्वीकृति सहित लाइव अलर्ट फायरिंग",
"widgetPingStatusName": "पिंग स्थिति",
"widgetPingStatusDesc": "एक या अधिक URLs के लिए HTTP पिंग स्थिति",
"widgetRecentActivityName": "हाल की गतिविधि",
@@ -747,7 +744,6 @@
"portForwarding": "अग्रेषित करना",
"sftp": "एसएफटीपी",
"connections": "कनेक्शन्स",
"alerts": "अलर्ट्स",
"serial": "सीरियल",
"keepSideMenuOpen": "मेनू खुला रखें",
"collapseSideMenu": "मेनू को छोटा करें",
@@ -4890,21 +4886,6 @@
"viewHistory": "इतिहास देखें"
},
"alerts": {
"tabFirings": "अलर्ट",
"tabRules": "नियम",
"tabChannels": "चैनल",
"noFirings": "कोई अपुष्ट अलर्ट नहीं",
"noRules": "कोई अलर्ट नियम कॉन्फ़िगर नहीं",
"noChannels": "कोई सूचना चैनल कॉन्फ़िगर नहीं",
"rulesDesc": "अलर्ट नियम सूचनाएं ट्रिगर करते हैं",
"channelsDesc": "जहां अलर्ट सूचनाएं भेजी जाती हैं",
"acknowledge": "पुष्टि करें",
"ackAll": "सभी की पुष्टि करें",
"allAcknowledged": "सभी अलर्ट की पुष्टि हो गई",
"ackFailed": "अलर्ट की पुष्टि करने में विफल",
"ackAllFailed": "सभी अलर्ट की पुष्टि करने में विफल",
"showAcknowledged": "सभी दिखाएं",
"hideAcknowledged": "पुष्टि किए गए छिपाएं",
"addChannel": "चैनल जोड़ें",
"editChannel": "चैनल संपादित करें",
"channelName": "नाम",
@@ -4917,22 +4898,7 @@
"ntfyTopic": "विषय",
"ntfyTopicRequired": "विषय आवश्यक है",
"ntfyToken": "एक्सेस टोकन (वैकल्पिक)",
"channelSaveFailed": "चैनल सहेजने में विफल",
"test": "परीक्षण",
"testSent": "परीक्षण सूचना भेजी गई",
"testFailed": "परीक्षण सूचना विफल",
"testFailedWithReason": "परीक्षण अधिसूचना विफल: {{reason}}",
"addRule": "अलर्ट नियम जोड़ें",
"editRule": "अलर्ट नियम संपादित करें",
"ruleName": "नियम नाम",
"ruleNameRequired": "नाम आवश्यक है",
"triggerType": "ट्रिगर",
"thresholdValue": "सीमा (%)",
"durationSeconds": "अवधि (सेकंड, 0 = तुरंत सक्रिय करें)",
"cooldownMinutes": "कूलडाउन (मिनट)",
"channels": "अधिसूचना चैनल",
"noChannelsHint": "पहले चैनल टैब में चैनल जोड़ें",
"ruleSaveFailed": "नियम सहेजने में विफल"
"channelSaveFailed": "चैनल सहेजने में विफल"
},
"ai": {
"title": "सहायक",
+1 -35
View File
@@ -287,7 +287,6 @@
"loading": "Terhelés...",
"noData": "Nincsenek elérhető adatok",
"allClear": "Minden tiszta",
"acknowledgeAlert": "Elismerés",
"noPingUrls": "Nincsenek konfigurált URL-ek",
"pingLabel": "Címke",
"addPingUrl": "URL hozzáadása",
@@ -324,8 +323,6 @@
"widgetMetricsChartDesc": "Egy gazdagép CPU-, memória-, lemez- vagy hálózati előzménydiagramja",
"widgetHostGridName": "Host Grid",
"widgetHostGridDesc": "SSH-gazdagépek állapotainak rácsnézete",
"widgetAlertFeedName": "Értesítési hírcsatorna",
"widgetAlertFeedDesc": "Élő riasztási tüzelések nyugtázási támogatással",
"widgetPingStatusName": "Ping állapot",
"widgetPingStatusDesc": "HTTP ping állapot egy vagy több URL-hez",
"widgetRecentActivityName": "Legutóbbi tevékenységek",
@@ -747,7 +744,6 @@
"portForwarding": "Szállítmányozás",
"sftp": "SFTP",
"connections": "Kapcsolatok",
"alerts": "Riasztások",
"serial": "Sorozatszám",
"keepSideMenuOpen": "Menü nyitva tartása",
"collapseSideMenu": "Menü összecsukása",
@@ -4890,21 +4886,6 @@
"viewHistory": "Előzmények megtekintése"
},
"alerts": {
"tabFirings": "Riasztások",
"tabRules": "Szabályok",
"tabChannels": "Csatornák",
"noFirings": "Nincsenek nyugtázatlan riasztások",
"noRules": "Nincsenek riasztási szabályok konfigurálva",
"noChannels": "Nincsenek értesítési csatornák konfigurálva",
"rulesDesc": "Riasztási szabályok aktiválják az értesítéseket",
"channelsDesc": "Hová küldik a riasztási értesítéseket",
"acknowledge": "Elismerés",
"ackAll": "Mindent elfogad",
"allAcknowledged": "Minden riasztás nyugtázva",
"ackFailed": "Nem sikerült visszaigazolni a riasztást",
"ackAllFailed": "Nem sikerült nyugtázni az összes riasztást",
"showAcknowledged": "Összes megjelenítése",
"hideAcknowledged": "Elrejtés Elfogadva",
"addChannel": "Csatorna hozzáadása",
"editChannel": "Csatorna szerkesztése",
"channelName": "Név",
@@ -4917,22 +4898,7 @@
"ntfyTopic": "Téma",
"ntfyTopicRequired": "A téma megadása kötelező",
"ntfyToken": "Hozzáférési token (opcionális)",
"channelSaveFailed": "Nem sikerült menteni a csatornát",
"test": "Teszt",
"testSent": "Tesztértesítés elküldve",
"testFailed": "Tesztértesítés sikertelen",
"testFailedWithReason": "Tesztértesítés sikertelen: {{reason}}",
"addRule": "Riasztási szabály hozzáadása",
"editRule": "Riasztási szabály szerkesztése",
"ruleName": "Szabály neve",
"ruleNameRequired": "Név megadása kötelező",
"triggerType": "Trigger",
"thresholdValue": "Küszöbérték (%)",
"durationSeconds": "Időtartam (másodperc, 0 = azonnali aktiválás)",
"cooldownMinutes": "Lehűlési idő (perc)",
"channels": "Értesítési csatornák",
"noChannelsHint": "Először adj hozzá csatornákat a Csatornák lapon",
"ruleSaveFailed": "Nem sikerült menteni a szabályt"
"channelSaveFailed": "Nem sikerült menteni a csatornát"
},
"ai": {
"title": "Helyettes",
+1 -35
View File
@@ -287,7 +287,6 @@
"loading": "Memuat...",
"noData": "Tidak ada data yang tersedia",
"allClear": "Semuanya aman.",
"acknowledgeAlert": "Mengakui",
"noPingUrls": "Tidak ada URL yang dikonfigurasi",
"pingLabel": "Label",
"addPingUrl": "Tambahkan URL",
@@ -324,8 +323,6 @@
"widgetMetricsChartDesc": "Grafik historis CPU, memori, disk, atau jaringan untuk sebuah host.",
"widgetHostGridName": "Jaringan Host",
"widgetHostGridDesc": "Tampilan grid status host SSH",
"widgetAlertFeedName": "Umpan Peringatan",
"widgetAlertFeedDesc": "Peringatan penembakan langsung dengan dukungan konfirmasi.",
"widgetPingStatusName": "Status Ping",
"widgetPingStatusDesc": "Status ping HTTP untuk satu atau lebih URL",
"widgetRecentActivityName": "Aktivitas Terkini",
@@ -747,7 +744,6 @@
"portForwarding": "Meneruskan",
"sftp": "SFTP",
"connections": "Koneksi",
"alerts": "Peringatan",
"serial": "Serial",
"keepSideMenuOpen": "Biarkan Menu Tetap Terbuka",
"collapseSideMenu": "Ciutkan Menu",
@@ -4890,21 +4886,6 @@
"viewHistory": "Lihat Riwayat"
},
"alerts": {
"tabFirings": "Peringatan",
"tabRules": "Aturan",
"tabChannels": "Saluran",
"noFirings": "Tidak ada peringatan yang belum diakui",
"noRules": "Tidak ada aturan peringatan yang dikonfigurasi.",
"noChannels": "Tidak ada saluran notifikasi yang dikonfigurasi.",
"rulesDesc": "Aturan peringatan memicu notifikasi.",
"channelsDesc": "Di mana notifikasi peringatan dikirim",
"acknowledge": "Mengakui",
"ackAll": "Akui semuanya",
"allAcknowledged": "Semua peringatan telah diterima.",
"ackFailed": "Gagal menanggapi peringatan",
"ackAllFailed": "Gagal menanggapi semua peringatan",
"showAcknowledged": "Tampilkan Semua",
"hideAcknowledged": "Sembunyikan Terakui",
"addChannel": "Tambahkan Saluran",
"editChannel": "Edit Saluran",
"channelName": "Nama",
@@ -4917,22 +4898,7 @@
"ntfyTopic": "Topik",
"ntfyTopicRequired": "Topik wajib diisi",
"ntfyToken": "Token Akses (opsional)",
"channelSaveFailed": "Gagal menyimpan saluran",
"test": "Tes",
"testSent": "Notifikasi pengujian telah dikirim.",
"testFailed": "Notifikasi pengujian gagal",
"testFailedWithReason": "Notifikasi pengujian gagal: {{reason}}",
"addRule": "Tambahkan Aturan Peringatan",
"editRule": "Edit Aturan Peringatan",
"ruleName": "Nama Aturan",
"ruleNameRequired": "Nama wajib diisi.",
"triggerType": "Pemicu",
"thresholdValue": "Ambang batas (%)",
"durationSeconds": "Durasi (detik, 0 = tembak segera)",
"cooldownMinutes": "Waktu pendinginan (menit)",
"channels": "Saluran Pemberitahuan",
"noChannelsHint": "Tambahkan saluran di tab Saluran terlebih dahulu.",
"ruleSaveFailed": "Gagal menyimpan aturan"
"channelSaveFailed": "Gagal menyimpan saluran"
},
"ai": {
"title": "Asisten",
+1 -35
View File
@@ -287,7 +287,6 @@
"loading": "Caricamento...",
"noData": "Nessun dato disponibile",
"allClear": "Tutto a posto",
"acknowledgeAlert": "Riconosci",
"noPingUrls": "Nessun URL configurato",
"pingLabel": "Etichetta",
"addPingUrl": "Aggiungi URL",
@@ -324,8 +323,6 @@
"widgetMetricsChartDesc": "Grafico storico di CPU, memoria, disco o rete per un host",
"widgetHostGridName": "Griglia host",
"widgetHostGridDesc": "Vista a griglia degli stati degli host SSH",
"widgetAlertFeedName": "Feed degli avvisi",
"widgetAlertFeedDesc": "Avvisi in tempo reale con supporto di riconoscimento",
"widgetPingStatusName": "Stato ping",
"widgetPingStatusDesc": "Stato del ping HTTP per uno o più URL",
"widgetRecentActivityName": "Attività recente",
@@ -747,7 +744,6 @@
"portForwarding": "Inoltro",
"sftp": "SFTP",
"connections": "Connessioni",
"alerts": "Avvisi",
"serial": "Seriale",
"keepSideMenuOpen": "Mantieni il menu aperto",
"collapseSideMenu": "Comprimi menu",
@@ -4890,21 +4886,6 @@
"viewHistory": "Visualizza cronologia"
},
"alerts": {
"tabFirings": "Avvisi",
"tabRules": "Regole",
"tabChannels": "Canali",
"noFirings": "Nessun avviso non confermato",
"noRules": "Nessuna regola di avviso configurata",
"noChannels": "Nessun canale di notifica configurato",
"rulesDesc": "Le regole di avviso attivano le notifiche",
"channelsDesc": "Dove vengono inviate le notifiche di avviso",
"acknowledge": "Conferma",
"ackAll": "Conferma tutto",
"allAcknowledged": "Tutti gli avvisi confermati",
"ackFailed": "Conferma dell'avviso non riuscita",
"ackAllFailed": "Conferma di tutti gli avvisi non riuscita",
"showAcknowledged": "Mostra tutti",
"hideAcknowledged": "Nascondi confermati",
"addChannel": "Aggiungi canale",
"editChannel": "Modifica canale",
"channelName": "Nome",
@@ -4917,22 +4898,7 @@
"ntfyTopic": "Argomento",
"ntfyTopicRequired": "Argomento obbligatorio",
"ntfyToken": "Token di accesso (facoltativo)",
"channelSaveFailed": "Salvataggio del canale non riuscito",
"test": "Test",
"testSent": "Notifica di test inviata",
"testFailed": "Notifica di test fallita",
"testFailedWithReason": "Notifica di prova non riuscita: {{reason}}",
"addRule": "Aggiungi regola di avviso",
"editRule": "Modifica regola di avviso",
"ruleName": "Nome regola",
"ruleNameRequired": "Nome obbligatorio",
"triggerType": "Trigger",
"thresholdValue": "Soglia (%)",
"durationSeconds": "Durata (secondi, 0 = attiva immediatamente)",
"cooldownMinutes": "Cooldown (minuti)",
"channels": "Canali di notifica",
"noChannelsHint": "Aggiungi prima i canali nella scheda Canali",
"ruleSaveFailed": "Salvataggio della regola non riuscito"
"channelSaveFailed": "Salvataggio del canale non riuscito"
},
"ai": {
"title": "Assistente",
+1 -35
View File
@@ -287,7 +287,6 @@
"loading": "読み込み中...",
"noData": "利用可能なデータはありません",
"allClear": "すべて正常",
"acknowledgeAlert": "確認",
"noPingUrls": "URLが設定されていません",
"pingLabel": "ラベル",
"addPingUrl": "URLを追加",
@@ -324,8 +323,6 @@
"widgetMetricsChartDesc": "ホストのCPU、メモリ、ディスク、またはネットワークの履歴チャート",
"widgetHostGridName": "ホストグリッド",
"widgetHostGridDesc": "SSHホストステータスのグリッドビュー",
"widgetAlertFeedName": "アラートフィード",
"widgetAlertFeedDesc": "確認機能付きのライブアラート通知",
"widgetPingStatusName": "Pingステータス",
"widgetPingStatusDesc": "1つ以上のURLのHTTP pingステータス",
"widgetRecentActivityName": "最近のアクティビティ",
@@ -747,7 +744,6 @@
"portForwarding": "転送",
"sftp": "SFTP",
"connections": "接続",
"alerts": "アラート",
"serial": "シリアル",
"keepSideMenuOpen": "メニューを開いたままにする",
"collapseSideMenu": "メニューを折りたたむ",
@@ -4890,21 +4886,6 @@
"viewHistory": "履歴を表示"
},
"alerts": {
"tabFirings": "アラート",
"tabRules": "ルール",
"tabChannels": "チャンネル",
"noFirings": "未確認のアラートはありません",
"noRules": "アラートルールが設定されていません",
"noChannels": "通知チャンネルが設定されていません",
"rulesDesc": "アラートルールは通知をトリガーします",
"channelsDesc": "アラート通知の送信先です",
"acknowledge": "確認",
"ackAll": "すべて確認",
"allAcknowledged": "すべてのアラートが確認済みです",
"ackFailed": "アラートの確認に失敗しました",
"ackAllFailed": "すべてのアラートの確認に失敗しました",
"showAcknowledged": "すべて表示",
"hideAcknowledged": "確認済みを非表示",
"addChannel": "チャンネルを追加",
"editChannel": "チャンネルを編集",
"channelName": "名前",
@@ -4917,22 +4898,7 @@
"ntfyTopic": "トピック",
"ntfyTopicRequired": "トピックは必須です",
"ntfyToken": "アクセストークン(任意)",
"channelSaveFailed": "チャンネルの保存に失敗しました",
"test": "テスト",
"testSent": "テスト通知を送信しました",
"testFailed": "テスト通知に失敗しました",
"testFailedWithReason": "テスト通知が失敗しました: {{reason}}",
"addRule": "アラートルールを追加",
"editRule": "アラートルールを編集",
"ruleName": "ルール名",
"ruleNameRequired": "ルール名は必須です",
"triggerType": "トリガー",
"thresholdValue": "しきい値 (%)",
"durationSeconds": "継続時間(秒、0は即時発火)",
"cooldownMinutes": "クールダウン(分)",
"channels": "通知チャンネル",
"noChannelsHint": "先にチャンネルタブでチャンネルを追加してください",
"ruleSaveFailed": "ルールの保存に失敗しました"
"channelSaveFailed": "チャンネルの保存に失敗しました"
},
"ai": {
"title": "アシスタント",
+1 -35
View File
@@ -287,7 +287,6 @@
"loading": "로딩 중...",
"noData": "데이터 없음",
"allClear": "모두 정상",
"acknowledgeAlert": "확인",
"noPingUrls": "구성된 URL 없음",
"pingLabel": "레이블",
"addPingUrl": "URL 추가",
@@ -324,8 +323,6 @@
"widgetMetricsChartDesc": "호스트의 CPU, 메모리, 디스크, 네트워크 기록 차트",
"widgetHostGridName": "호스트 그리드",
"widgetHostGridDesc": "SSH 호스트 상태 그리드 뷰",
"widgetAlertFeedName": "알림 피드",
"widgetAlertFeedDesc": "실시간 알림 및 확인 지원",
"widgetPingStatusName": "Ping 상태",
"widgetPingStatusDesc": "하나 이상의 URL에 대한 HTTP 핑 상태",
"widgetRecentActivityName": "최근 활동",
@@ -747,7 +744,6 @@
"portForwarding": "전송",
"sftp": "SFTP",
"connections": "연결",
"alerts": "알림",
"serial": "시리얼",
"keepSideMenuOpen": "메뉴를 열어 두세요",
"collapseSideMenu": "메뉴 접기",
@@ -4890,21 +4886,6 @@
"viewHistory": "기록 보기"
},
"alerts": {
"tabFirings": "알림",
"tabRules": "규칙",
"tabChannels": "채널",
"noFirings": "미확인 알림 없음",
"noRules": "구성된 알림 규칙 없음",
"noChannels": "구성된 알림 채널 없음",
"rulesDesc": "알림 규칙은 알림을 트리거합니다",
"channelsDesc": "알림이 전송되는 위치",
"acknowledge": "확인",
"ackAll": "모두 확인",
"allAcknowledged": "모든 알림 확인됨",
"ackFailed": "알림 확인 실패",
"ackAllFailed": "모든 알림 확인 실패",
"showAcknowledged": "모두 표시",
"hideAcknowledged": "확인된 항목 숨기기",
"addChannel": "채널 추가",
"editChannel": "채널 편집",
"channelName": "이름",
@@ -4917,22 +4898,7 @@
"ntfyTopic": "토픽",
"ntfyTopicRequired": "토픽은 필수입니다",
"ntfyToken": "액세스 토큰 (선택 사항)",
"channelSaveFailed": "채널 저장에 실패했습니다",
"test": "테스트",
"testSent": "테스트 알림이 전송되었습니다",
"testFailed": "테스트 알림 전송에 실패했습니다",
"testFailedWithReason": "테스트 알림 실패: {{reason}}",
"addRule": "알람 규칙 추가",
"editRule": "알람 규칙 편집",
"ruleName": "규칙 이름",
"ruleNameRequired": "이름은 필수입니다",
"triggerType": "트리거",
"thresholdValue": "임계값 (%)",
"durationSeconds": "지속 시간 (초, 0 = 즉시 발생)",
"cooldownMinutes": "쿨다운 (분)",
"channels": "알림 채널",
"noChannelsHint": "먼저 채널 탭에서 채널을 추가하세요",
"ruleSaveFailed": "규칙 저장에 실패했습니다"
"channelSaveFailed": "채널 저장에 실패했습니다"
},
"ai": {
"title": "어시스턴트",
+1 -35
View File
@@ -287,7 +287,6 @@
"loading": "Laden...",
"noData": "Geen gegevens beschikbaar",
"allClear": "Alles in orde",
"acknowledgeAlert": "Erkennen",
"noPingUrls": "Geen URL's geconfigureerd",
"pingLabel": "Label",
"addPingUrl": "URL toevoegen",
@@ -324,8 +323,6 @@
"widgetMetricsChartDesc": "Historische grafiek van CPU, geheugen, schijf of netwerk voor een host.",
"widgetHostGridName": "Host Grid",
"widgetHostGridDesc": "Rasterweergave van de status van SSH-hosts",
"widgetAlertFeedName": "Waarschuwingsfeed",
"widgetAlertFeedDesc": "Live waarschuwingen met bevestigingsondersteuning",
"widgetPingStatusName": "Pingstatus",
"widgetPingStatusDesc": "HTTP-pingstatus voor een of meer URL's",
"widgetRecentActivityName": "Recente activiteit",
@@ -747,7 +744,6 @@
"portForwarding": "Doorsturen",
"sftp": "SFTP",
"connections": "Verbindingen",
"alerts": "Waarschuwingen",
"serial": "Serieel",
"keepSideMenuOpen": "Menu open houden",
"collapseSideMenu": "Menu inklappen",
@@ -4890,21 +4886,6 @@
"viewHistory": "Geschiedenis bekijken"
},
"alerts": {
"tabFirings": "Waarschuwingen",
"tabRules": "Regels",
"tabChannels": "Kanalen",
"noFirings": "Geen niet-bevestigde meldingen",
"noRules": "Er zijn geen waarschuwingsregels geconfigureerd.",
"noChannels": "Er zijn geen notificatiekanalen geconfigureerd.",
"rulesDesc": "Waarschuwingsregels activeren meldingen.",
"channelsDesc": "Waar waarschuwingsmeldingen worden verzonden",
"acknowledge": "Erkennen",
"ackAll": "Bevestig alles",
"allAcknowledged": "Alle meldingen bevestigd",
"ackFailed": "Melding niet ontvangen",
"ackAllFailed": "Niet alle meldingen zijn bevestigd.",
"showAcknowledged": "Alles weergeven",
"hideAcknowledged": "Verberg Acked",
"addChannel": "Kanaal toevoegen",
"editChannel": "Bewerk kanaal",
"channelName": "Naam",
@@ -4917,22 +4898,7 @@
"ntfyTopic": "Onderwerp",
"ntfyTopicRequired": "Onderwerp is verplicht",
"ntfyToken": "Toegangstoken (optioneel)",
"channelSaveFailed": "Het opslaan van het kanaal is mislukt.",
"test": "Test",
"testSent": "Testmelding verzonden",
"testFailed": "Testmelding mislukt",
"testFailedWithReason": "Testmelding mislukt: {{reason}}",
"addRule": "Voeg een waarschuwingsregel toe",
"editRule": "Regel voor waarschuwing bewerken",
"ruleName": "Regelnaam",
"ruleNameRequired": "Naam is verplicht",
"triggerType": "Trekker",
"thresholdValue": "Drempelwaarde (%)",
"durationSeconds": "Duur (seconden, 0 = direct vuren)",
"cooldownMinutes": "Afkoeltijd (minuten)",
"channels": "Meldingskanalen",
"noChannelsHint": "Voeg eerst kanalen toe via het tabblad Kanalen.",
"ruleSaveFailed": "Het opslaan van de regel is mislukt."
"channelSaveFailed": "Het opslaan van het kanaal is mislukt."
},
"ai": {
"title": "Assistent",
+1 -35
View File
@@ -287,7 +287,6 @@
"loading": "Laster inn...",
"noData": "Ingen data tilgjengelig",
"allClear": "Alt klart",
"acknowledgeAlert": "Bekrefte",
"noPingUrls": "Ingen nettadresser konfigurert",
"pingLabel": "Merkelapp",
"addPingUrl": "Legg til nettadresse",
@@ -324,8 +323,6 @@
"widgetMetricsChartDesc": "Historisk CPU-, minne-, disk- eller nettverksdiagram for en vert",
"widgetHostGridName": "Vertsnett",
"widgetHostGridDesc": "Rutenettvisning av SSH-vertstatuser",
"widgetAlertFeedName": "Varselstrøm",
"widgetAlertFeedDesc": "Live-varslingsutløsninger med bekreftelsesstøtte",
"widgetPingStatusName": "Ping-status",
"widgetPingStatusDesc": "HTTP-pingstatus for én eller flere URL-er",
"widgetRecentActivityName": "Nylig aktivitet",
@@ -747,7 +744,6 @@
"portForwarding": "Videresending",
"sftp": "SFTP",
"connections": "Tilkoblinger",
"alerts": "Varsler",
"serial": "Seriell",
"keepSideMenuOpen": "Hold menyen åpen",
"collapseSideMenu": "Skjul menyen",
@@ -4890,21 +4886,6 @@
"viewHistory": "Vis historikk"
},
"alerts": {
"tabFirings": "Varsler",
"tabRules": "Regler",
"tabChannels": "Kanaler",
"noFirings": "Ingen ubekreftede varsler",
"noRules": "Ingen varslingsregler konfigurert",
"noChannels": "Ingen varslingskanaler konfigurert",
"rulesDesc": "Varslingsregler utløser varsler",
"channelsDesc": "Hvor varsler sendes",
"acknowledge": "Bekrefte",
"ackAll": "Bekreft alle",
"allAcknowledged": "Alle varsler bekreftet",
"ackFailed": "Kunne ikke bekrefte varselet",
"ackAllFailed": "Kunne ikke bekrefte alle varsler",
"showAcknowledged": "Vis alle",
"hideAcknowledged": "Skjul bekreftet",
"addChannel": "Legg til kanal",
"editChannel": "Rediger kanal",
"channelName": "Navn",
@@ -4917,22 +4898,7 @@
"ntfyTopic": "Emne",
"ntfyTopicRequired": "Emne er obligatorisk",
"ntfyToken": "Tilgangstoken (valgfritt)",
"channelSaveFailed": "Kunne ikke lagre kanalen",
"test": "Test",
"testSent": "Testvarsel sendt",
"testFailed": "Testvarsel mislyktes",
"testFailedWithReason": "Testvarsling mislyktes: {{reason}}",
"addRule": "Legg til varslingsregel",
"editRule": "Rediger varslingsregel",
"ruleName": "Regelnavn",
"ruleNameRequired": "Navn er obligatorisk",
"triggerType": "Avtrekker",
"thresholdValue": "Terskel (%)",
"durationSeconds": "Varighet (sekunder, 0 = avfyr umiddelbart)",
"cooldownMinutes": "Nedkjølingstid (minutter)",
"channels": "Varslingskanaler",
"noChannelsHint": "Legg først til kanaler i Kanaler-fanen",
"ruleSaveFailed": "Kunne ikke lagre regelen"
"channelSaveFailed": "Kunne ikke lagre kanalen"
},
"ai": {
"title": "Assistent",
+1 -35
View File
@@ -287,7 +287,6 @@
"loading": "Ładowanie...",
"noData": "Brak dostępnych danych",
"allClear": "Bez alertów",
"acknowledgeAlert": "Potwierdź",
"noPingUrls": "Brak skonfigurowanych adresów URL",
"pingLabel": "Etykieta",
"addPingUrl": "Dodaj adres URL",
@@ -324,8 +323,6 @@
"widgetMetricsChartDesc": "Historyczny wykres procesora, pamięci, dysku lub sieci dla hosta",
"widgetHostGridName": "Siatka hostów",
"widgetHostGridDesc": "Widok siatki statusów hostów SSH",
"widgetAlertFeedName": "Kanał alertów",
"widgetAlertFeedDesc": "Wyzwolone alerty na żywo z możliwością potwierdzenia",
"widgetPingStatusName": "Status pingu",
"widgetPingStatusDesc": "Status pingu HTTP dla jednego lub większej liczby adresów URL",
"widgetRecentActivityName": "Ostatnia aktywność",
@@ -747,7 +744,6 @@
"portForwarding": "Przekierowanie",
"sftp": "SFTP",
"connections": "Połączenia",
"alerts": "Alerty",
"serial": "Port szeregowy",
"keepSideMenuOpen": "Zachowaj otwarte menu",
"collapseSideMenu": "Zwiń menu",
@@ -4890,21 +4886,6 @@
"viewHistory": "Wyświetl historię"
},
"alerts": {
"tabFirings": "Alerty",
"tabRules": "Reguły",
"tabChannels": "Kanały",
"noFirings": "Brak niepotwierdzonych alertów",
"noRules": "Nie skonfigurowano żadnych reguł alertów",
"noChannels": "Brak skonfigurowanych kanałów powiadomień",
"rulesDesc": "Reguły alertów wyzwalają powiadomienia",
"channelsDesc": "Gdzie wysyłane są powiadomienia o alertach",
"acknowledge": "Potwierdź",
"ackAll": "Potwierdź wszystkie",
"allAcknowledged": "Wszystkie alerty potwierdzone",
"ackFailed": "Nie udało się potwierdzić alertu",
"ackAllFailed": "Nie udało się potwierdzić wszystkich alertów",
"showAcknowledged": "Pokaż wszystkie",
"hideAcknowledged": "Ukryj potwierdzone",
"addChannel": "Dodaj kanał",
"editChannel": "Edytuj kanał",
"channelName": "Nazwa",
@@ -4917,22 +4898,7 @@
"ntfyTopic": "Temat",
"ntfyTopicRequired": "Temat jest wymagany",
"ntfyToken": "Token dostępu (opcjonalnie)",
"channelSaveFailed": "Nie udało się zapisać kanału",
"test": "Test",
"testSent": "Wysłano powiadomienie testowe",
"testFailed": "Powiadomienie testowe nie powiodło się",
"testFailedWithReason": "Powiadomienie testowe nie powiodło się: {{reason}}",
"addRule": "Dodaj regułę alertu",
"editRule": "Edytuj regułę alertu",
"ruleName": "Nazwa reguły",
"ruleNameRequired": "Nazwa jest wymagana",
"triggerType": "Wyzwalacz",
"thresholdValue": "Próg (%)",
"durationSeconds": "Czas trwania (sekundy, 0 = wyzwól natychmiast)",
"cooldownMinutes": "Czas wyciszenia (minuty)",
"channels": "Kanały powiadomień",
"noChannelsHint": "Najpierw dodaj kanały na karcie Kanały",
"ruleSaveFailed": "Nie udało się zapisać reguły"
"channelSaveFailed": "Nie udało się zapisać kanału"
},
"ai": {
"title": "Asystent",
+1 -35
View File
@@ -287,7 +287,6 @@
"loading": "Carregando...",
"noData": "Não há dados disponíveis.",
"allClear": "Tudo certo",
"acknowledgeAlert": "Reconhecer",
"noPingUrls": "Nenhuma URL configurada",
"pingLabel": "Rótulo",
"addPingUrl": "Adicionar URL",
@@ -324,8 +323,6 @@
"widgetMetricsChartDesc": "Gráfico histórico de CPU, memória, disco ou rede para um host",
"widgetHostGridName": "Grade de anfitriões",
"widgetHostGridDesc": "Visualização em grade dos status dos hosts SSH",
"widgetAlertFeedName": "Feed de alertas",
"widgetAlertFeedDesc": "Disparos de alertas em tempo real com suporte de confirmação",
"widgetPingStatusName": "Status do ping",
"widgetPingStatusDesc": "Status do ping HTTP para um ou mais URLs",
"widgetRecentActivityName": "Atividade recente",
@@ -747,7 +744,6 @@
"portForwarding": "Encaminhamento",
"sftp": "SFTP",
"connections": "Conexões",
"alerts": "Alertas",
"serial": "Serial",
"keepSideMenuOpen": "Mantenha o menu aberto",
"collapseSideMenu": "Recolher menu",
@@ -4890,21 +4886,6 @@
"viewHistory": "Ver histórico"
},
"alerts": {
"tabFirings": "Alertas",
"tabRules": "Regras",
"tabChannels": "Canais",
"noFirings": "Nenhum alerta não reconhecido",
"noRules": "Nenhuma regra de alerta configurada",
"noChannels": "Nenhum canal de notificação configurado",
"rulesDesc": "As regras de alerta acionam notificações.",
"channelsDesc": "Para onde são enviadas as notificações de alerta",
"acknowledge": "Reconhecer",
"ackAll": "Concordo com tudo",
"allAcknowledged": "Todos os alertas foram reconhecidos.",
"ackFailed": "Falha em reconhecer o alerta",
"ackAllFailed": "Não reconheceu todos os alertas.",
"showAcknowledged": "Mostrar tudo",
"hideAcknowledged": "Esconder Acked",
"addChannel": "Adicionar canal",
"editChannel": "Editar canal",
"channelName": "Nome",
@@ -4917,22 +4898,7 @@
"ntfyTopic": "Tópico",
"ntfyTopicRequired": "É necessário definir o tema.",
"ntfyToken": "Token de acesso (opcional)",
"channelSaveFailed": "Falha ao salvar o canal",
"test": "Teste",
"testSent": "Notificação de teste enviada",
"testFailed": "Falha na notificação de teste",
"testFailedWithReason": "Falha na notificação de teste: {{reason}}",
"addRule": "Adicionar regra de alerta",
"editRule": "Editar regra de alerta",
"ruleName": "Nome da regra",
"ruleNameRequired": "O nome é obrigatório.",
"triggerType": "Acionar",
"thresholdValue": "Limite (%)",
"durationSeconds": "Duração (segundos, 0 = disparar imediatamente)",
"cooldownMinutes": "Tempo de resfriamento (minutos)",
"channels": "Canais de notificação",
"noChannelsHint": "Adicione os canais primeiro na aba Canais.",
"ruleSaveFailed": "Falha ao salvar a regra"
"channelSaveFailed": "Falha ao salvar o canal"
},
"ai": {
"title": "Assistente",
+1 -35
View File
@@ -287,7 +287,6 @@
"loading": "A carregar...",
"noData": "Sem dados disponíveis",
"allClear": "Tudo em ordem",
"acknowledgeAlert": "Reconhecer",
"noPingUrls": "Nenhum URL configurado",
"pingLabel": "Etiqueta",
"addPingUrl": "Adicionar URL",
@@ -324,8 +323,6 @@
"widgetMetricsChartDesc": "Gráfico histórico de CPU, memória, disco ou rede para um host",
"widgetHostGridName": "Grelha de hosts",
"widgetHostGridDesc": "Vista em grelha dos estados dos hosts SSH",
"widgetAlertFeedName": "Feed de alertas",
"widgetAlertFeedDesc": "Disparos de alertas em tempo real com suporte a reconhecimento",
"widgetPingStatusName": "Estado do ping",
"widgetPingStatusDesc": "Estado do ping HTTP para um ou mais URLs",
"widgetRecentActivityName": "Atividade recente",
@@ -747,7 +744,6 @@
"portForwarding": "Encaminhamento",
"sftp": "SFTP",
"connections": "Ligações",
"alerts": "Alertas",
"serial": "Serial",
"keepSideMenuOpen": "Mantenha o menu aberto",
"collapseSideMenu": "Recolher menu",
@@ -4890,21 +4886,6 @@
"viewHistory": "Ver Histórico"
},
"alerts": {
"tabFirings": "Alertas",
"tabRules": "Regras",
"tabChannels": "Canais",
"noFirings": "Nenhum alerta por reconhecer",
"noRules": "Nenhuma regra de alerta configurada",
"noChannels": "Nenhum canal de notificação configurado",
"rulesDesc": "As regras de alerta disparam notificações",
"channelsDesc": "Onde as notificações de alerta são enviadas",
"acknowledge": "Reconhecer",
"ackAll": "Reconhecer Todos",
"allAcknowledged": "Todos os alertas reconhecidos",
"ackFailed": "Falha ao reconhecer o alerta",
"ackAllFailed": "Falha ao reconhecer todos os alertas",
"showAcknowledged": "Mostrar Todos",
"hideAcknowledged": "Ocultar Reconhecidos",
"addChannel": "Adicionar Canal",
"editChannel": "Editar Canal",
"channelName": "Nome",
@@ -4917,22 +4898,7 @@
"ntfyTopic": "Tópico",
"ntfyTopicRequired": "O tópico é obrigatório",
"ntfyToken": "Token de acesso (opcional)",
"channelSaveFailed": "Falha ao guardar o canal",
"test": "Testar",
"testSent": "Notificação de teste enviada",
"testFailed": "Falha na notificação de teste",
"testFailedWithReason": "Falha na notificação de teste: {{reason}}",
"addRule": "Adicionar regra de alerta",
"editRule": "Editar regra de alerta",
"ruleName": "Nome da regra",
"ruleNameRequired": "O nome é obrigatório",
"triggerType": "Gatilho",
"thresholdValue": "Limiar (%)",
"durationSeconds": "Duração (segundos, 0 = disparar imediatamente)",
"cooldownMinutes": "Período de arrefecimento (minutos)",
"channels": "Canais de notificação",
"noChannelsHint": "Adicione canais primeiro no separador Canais",
"ruleSaveFailed": "Falha ao guardar a regra"
"channelSaveFailed": "Falha ao guardar o canal"
},
"ai": {
"title": "Assistente",
+1 -35
View File
@@ -287,7 +287,6 @@
"loading": "Încărcare...",
"noData": "Nu sunt disponibile date",
"allClear": "Totul este clar",
"acknowledgeAlert": "Recunoaște",
"noPingUrls": "Nicio adresă URL configurată",
"pingLabel": "Eticheta",
"addPingUrl": "Adăugați adresa URL",
@@ -324,8 +323,6 @@
"widgetMetricsChartDesc": "Diagramă istorică CPU, memorie, disc sau rețea pentru o gazdă",
"widgetHostGridName": "Grilă gazdă",
"widgetHostGridDesc": "Vizualizare grilă a stărilor gazdelor SSH",
"widgetAlertFeedName": "Flux de alerte",
"widgetAlertFeedDesc": "Declanșări în direct cu asistență pentru confirmare",
"widgetPingStatusName": "Starea ping-ului",
"widgetPingStatusDesc": "Starea ping-ului HTTP pentru una sau mai multe adrese URL",
"widgetRecentActivityName": "Activitate recentă",
@@ -747,7 +744,6 @@
"portForwarding": "Redirecționare",
"sftp": "SFTP",
"connections": "Conexiuni",
"alerts": "Alerte",
"serial": "Serial",
"keepSideMenuOpen": "Mențineți meniul deschis",
"collapseSideMenu": "Restrângeți meniul",
@@ -4890,21 +4886,6 @@
"viewHistory": "Vizualizați istoricul"
},
"alerts": {
"tabFirings": "Alerte",
"tabRules": "Reguli",
"tabChannels": "Canale",
"noFirings": "Nicio alertă neconfirmată",
"noRules": "Nicio regulă de alertă configurată",
"noChannels": "Nu sunt configurate canale de notificare",
"rulesDesc": "Regulile de alertă declanșează notificări",
"channelsDesc": "Unde sunt trimise notificările de alertă",
"acknowledge": "Recunoaște",
"ackAll": "Confirmare tuturor",
"allAcknowledged": "Toate alertele au fost confirmate",
"ackFailed": "Nu s-a putut confirma alerta",
"ackAllFailed": "Nu s-au putut confirma toate alertele",
"showAcknowledged": "Afișează tot",
"hideAcknowledged": "Ascundeți confirmarea",
"addChannel": "Adăugați canal",
"editChannel": "Editați canalul",
"channelName": "Nume",
@@ -4917,22 +4898,7 @@
"ntfyTopic": "Subiect",
"ntfyTopicRequired": "Subiectul este obligatoriu",
"ntfyToken": "Token de acces (opțional)",
"channelSaveFailed": "Canalul nu a putut fi salvat.",
"test": "Test",
"testSent": "Notificare de testare trimisă",
"testFailed": "Notificarea de testare a eșuat",
"testFailedWithReason": "Notificarea de testare a eșuat: {{reason}}",
"addRule": "Adăugați o regulă de alertă",
"editRule": "Editați regula de alertă",
"ruleName": "Nume regulă",
"ruleNameRequired": "Numele este obligatoriu",
"triggerType": "Declanșator",
"thresholdValue": "Prag (%)",
"durationSeconds": "Durată (secunde, 0 = declanșează imediat)",
"cooldownMinutes": "Timp de răcire (minute)",
"channels": "Canale de notificare",
"noChannelsHint": "Adăugați mai întâi canale în fila Canale",
"ruleSaveFailed": "Nu s-a putut salva regula"
"channelSaveFailed": "Canalul nu a putut fi salvat."
},
"ai": {
"title": "Asistent",
+1 -35
View File
@@ -287,7 +287,6 @@
"loading": "Загрузка...",
"noData": "Нет данных",
"allClear": "Всё в порядке",
"acknowledgeAlert": "Подтвердить",
"noPingUrls": "URL-адреса не настроены",
"pingLabel": "Метка",
"addPingUrl": "Добавить URL",
@@ -324,8 +323,6 @@
"widgetMetricsChartDesc": "График истории использования ЦП, памяти, диска или сети для хоста",
"widgetHostGridName": "Сетка хостов",
"widgetHostGridDesc": "Представление статусов хостов SSH в виде сетки",
"widgetAlertFeedName": "Лента оповещений",
"widgetAlertFeedDesc": "Живые срабатывания оповещений с возможностью подтверждения",
"widgetPingStatusName": "Статус ping",
"widgetPingStatusDesc": "Статус HTTP ping для одного или нескольких URL",
"widgetRecentActivityName": "Недавняя активность",
@@ -747,7 +744,6 @@
"portForwarding": "Пересылка",
"sftp": "SFTP",
"connections": "Подключения",
"alerts": "Оповещения",
"serial": "Последовательный порт",
"keepSideMenuOpen": "Оставить меню открытым",
"collapseSideMenu": "Свернуть меню",
@@ -4890,21 +4886,6 @@
"viewHistory": "Просмотреть историю"
},
"alerts": {
"tabFirings": "Оповещения",
"tabRules": "Правила",
"tabChannels": "Каналы",
"noFirings": "Нет неподтверждённых оповещений",
"noRules": "Не настроены правила оповещений",
"noChannels": "Не настроены каналы уведомлений",
"rulesDesc": "Правила оповещений инициируют уведомления",
"channelsDesc": "Куда отправляются уведомления об оповещениях",
"acknowledge": "Подтвердить",
"ackAll": "Подтвердить все",
"allAcknowledged": "Все оповещения подтверждены",
"ackFailed": "Не удалось подтвердить оповещение",
"ackAllFailed": "Не удалось подтвердить все оповещения",
"showAcknowledged": "Показать все",
"hideAcknowledged": "Скрыть подтверждённые",
"addChannel": "Добавить канал",
"editChannel": "Изменить канал",
"channelName": "Название",
@@ -4917,22 +4898,7 @@
"ntfyTopic": "Тема",
"ntfyTopicRequired": "Тема обязательна",
"ntfyToken": "Токен доступа (необязательно)",
"channelSaveFailed": "Не удалось сохранить канал",
"test": "Проверить",
"testSent": "Тестовое уведомление отправлено",
"testFailed": "Ошибка отправки тестового уведомления",
"testFailedWithReason": "Тестовое уведомление не пройдено: {{reason}}",
"addRule": "Добавить правило оповещения",
"editRule": "Редактировать правило оповещения",
"ruleName": "Имя правила",
"ruleNameRequired": "Имя обязательно",
"triggerType": "Триггер",
"thresholdValue": "Порог (%)",
"durationSeconds": "Длительность (секунд, 0 = сработать немедленно)",
"cooldownMinutes": "Задержка повторения (минут)",
"channels": "Каналы уведомлений",
"noChannelsHint": "Сначала добавьте каналы на вкладке «Каналы»",
"ruleSaveFailed": "Не удалось сохранить правило"
"channelSaveFailed": "Не удалось сохранить канал"
},
"ai": {
"title": "Помощник",
+1 -35
View File
@@ -287,7 +287,6 @@
"loading": "Учитавање...",
"noData": "Нема доступних података",
"allClear": "Све је чисто",
"acknowledgeAlert": "Потврди",
"noPingUrls": "Није конфигурисан ниједан URL",
"pingLabel": "Ознака",
"addPingUrl": "Додај URL",
@@ -324,8 +323,6 @@
"widgetMetricsChartDesc": "Историјски графикон процесора, меморије, диска или мреже за хост",
"widgetHostGridName": "Хост мрежа",
"widgetHostGridDesc": "Приказ мреже статуса SSH хоста",
"widgetAlertFeedName": "Фид обавештења",
"widgetAlertFeedDesc": "Укључивање упозорења уживо са подршком за потврду",
"widgetPingStatusName": "Статус пинга",
"widgetPingStatusDesc": "HTTP пинг статус за један или више URL-ова",
"widgetRecentActivityName": "Недавне активности",
@@ -747,7 +744,6 @@
"portForwarding": "Преусмеравање",
"sftp": "СФТП",
"connections": "Везе",
"alerts": "Упозорења",
"serial": "Серијски",
"keepSideMenuOpen": "Остави мени отвореним",
"collapseSideMenu": "Сакриј мени",
@@ -4890,21 +4886,6 @@
"viewHistory": "Прикажи историју"
},
"alerts": {
"tabFirings": "Упозорења",
"tabRules": "Правила",
"tabChannels": "Канали",
"noFirings": "Нема непотврђених упозорења",
"noRules": "Није конфигурисано ниједно правило упозорења",
"noChannels": "Није конфигурисан ниједан канал за обавештења",
"rulesDesc": "Правила упозорења покрећу обавештења",
"channelsDesc": "Где се шаљу обавештења",
"acknowledge": "Потврди",
"ackAll": "Потврди све",
"allAcknowledged": "Сва упозорења су потврђена",
"ackFailed": "Потврђивање упозорења није успело",
"ackAllFailed": "Није успело потврђивање свих упозорења",
"showAcknowledged": "Прикажи све",
"hideAcknowledged": "Сакриј потврђено",
"addChannel": "Додај канал",
"editChannel": "Измени канал",
"channelName": "Име",
@@ -4917,22 +4898,7 @@
"ntfyTopic": "Тема",
"ntfyTopicRequired": "Тема је обавезна",
"ntfyToken": "Приступни токен (опционо)",
"channelSaveFailed": "Чување канала није успело",
"test": "Тест",
"testSent": "Послато је пробно обавештење",
"testFailed": "Обавештење о тестирању није успело",
"testFailedWithReason": "Обавештење о тестирању није успело: {{reason}}",
"addRule": "Додај правило упозорења",
"editRule": "Измени правило упозорења",
"ruleName": "Назив правила",
"ruleNameRequired": "Име је обавезно",
"triggerType": "Окидач",
"thresholdValue": "Праг (%)",
"durationSeconds": "Трајање (секунде, 0 = одмах се активира)",
"cooldownMinutes": "Хлађење (минути)",
"channels": "Канали за обавештења",
"noChannelsHint": "Прво додајте канале на картици Канали",
"ruleSaveFailed": "Чување правила није успело"
"channelSaveFailed": "Чување канала није успело"
},
"ai": {
"title": "Асистент",
+1 -35
View File
@@ -287,7 +287,6 @@
"loading": "Belastning...",
"noData": "Inga data tillgängliga",
"allClear": "Faran över",
"acknowledgeAlert": "Erkänna",
"noPingUrls": "Inga webbadresser konfigurerade",
"pingLabel": "Märka",
"addPingUrl": "Lägg till URL",
@@ -324,8 +323,6 @@
"widgetMetricsChartDesc": "Historiskt diagram över CPU, minne, disk eller nätverk för en värd",
"widgetHostGridName": "Värdnät",
"widgetHostGridDesc": "Rutnätsvy över SSH-värdstatusar",
"widgetAlertFeedName": "Aviseringsflöde",
"widgetAlertFeedDesc": "Live-avfyrningar med bekräftelsestöd",
"widgetPingStatusName": "Pingstatus",
"widgetPingStatusDesc": "HTTP-pingstatus för en eller flera URL:er",
"widgetRecentActivityName": "Senaste aktivitet",
@@ -747,7 +744,6 @@
"portForwarding": "Spedition",
"sftp": "SFTP",
"connections": "Anslutningar",
"alerts": "Aviseringar",
"serial": "Serie",
"keepSideMenuOpen": "Håll menyn öppen",
"collapseSideMenu": "Minimera menyn",
@@ -4890,21 +4886,6 @@
"viewHistory": "Visa historik"
},
"alerts": {
"tabFirings": "Aviseringar",
"tabRules": "Regler",
"tabChannels": "Kanaler",
"noFirings": "Inga okvitterade varningar",
"noRules": "Inga varningsregler konfigurerade",
"noChannels": "Inga aviseringskanaler konfigurerade",
"rulesDesc": "Aviseringsregler utlöser aviseringar",
"channelsDesc": "Vart varningsmeddelanden skickas",
"acknowledge": "Erkänna",
"ackAll": "Bekräfta alla",
"allAcknowledged": "Alla varningar bekräftade",
"ackFailed": "Kunde inte bekräfta varningen",
"ackAllFailed": "Kunde inte bekräfta alla varningar",
"showAcknowledged": "Visa alla",
"hideAcknowledged": "Dölj godkända",
"addChannel": "Lägg till kanal",
"editChannel": "Redigera kanal",
"channelName": "Namn",
@@ -4917,22 +4898,7 @@
"ntfyTopic": "Ämne",
"ntfyTopicRequired": "Ämne är obligatoriskt",
"ntfyToken": "Åtkomsttoken (valfritt)",
"channelSaveFailed": "Misslyckades med att spara kanalen",
"test": "Testa",
"testSent": "Testmeddelande skickat",
"testFailed": "Testmeddelandet misslyckades",
"testFailedWithReason": "Testmeddelande misslyckades: {{reason}}",
"addRule": "Lägg till varningsregel",
"editRule": "Redigera varningsregel",
"ruleName": "Regelnamn",
"ruleNameRequired": "Namn krävs",
"triggerType": "Utlösare",
"thresholdValue": "Tröskelvärde (%)",
"durationSeconds": "Varaktighet (sekunder, 0 = avfyras omedelbart)",
"cooldownMinutes": "Nedkylningstid (minuter)",
"channels": "Aviseringskanaler",
"noChannelsHint": "Lägg först till kanaler på fliken Kanaler",
"ruleSaveFailed": "Det gick inte att spara regeln"
"channelSaveFailed": "Misslyckades med att spara kanalen"
},
"ai": {
"title": "Assistent",
+1 -35
View File
@@ -287,7 +287,6 @@
"loading": "กำลังโหลด...",
"noData": "ไม่มีข้อมูล",
"allClear": "ทุกอย่างเรียบร้อย",
"acknowledgeAlert": "รับทราบ",
"noPingUrls": "ไม่มีการกำหนดค่า URL",
"pingLabel": "ฉลาก",
"addPingUrl": "เพิ่ม URL",
@@ -324,8 +323,6 @@
"widgetMetricsChartDesc": "แผนภูมิแสดงประวัติการใช้งาน CPU, หน่วยความจำ, ดิสก์ หรือเครือข่ายสำหรับโฮสต์",
"widgetHostGridName": "โฮสต์กริด",
"widgetHostGridDesc": "การแสดงผลแบบตารางของสถานะโฮสต์ SSH",
"widgetAlertFeedName": "ฟีดแจ้งเตือน",
"widgetAlertFeedDesc": "การแจ้งเตือนแบบเรียลไทม์พร้อมการยืนยันการรับการแจ้งเตือน",
"widgetPingStatusName": "สถานะการปิง",
"widgetPingStatusDesc": "สถานะการ ping HTTP สำหรับ URL หนึ่งรายการขึ้นไป",
"widgetRecentActivityName": "กิจกรรมล่าสุด",
@@ -747,7 +744,6 @@
"portForwarding": "การส่งต่อ",
"sftp": "เอสเอฟทีพี",
"connections": "การเชื่อมต่อ",
"alerts": "การแจ้งเตือน",
"serial": "ซีเรียล",
"keepSideMenuOpen": "เปิดเมนูค้างไว้",
"collapseSideMenu": "ยุบเมนู",
@@ -4890,21 +4886,6 @@
"viewHistory": "ประวัติการดู"
},
"alerts": {
"tabFirings": "การแจ้งเตือน",
"tabRules": "กฎ",
"tabChannels": "ช่องต่างๆ",
"noFirings": "ไม่มีการแจ้งเตือนที่ยังไม่ได้รับการยืนยัน",
"noRules": "ไม่มีการกำหนดค่ากฎการแจ้งเตือน",
"noChannels": "ไม่ได้กำหนดค่าช่องทางการแจ้งเตือนใดๆ",
"rulesDesc": "กฎการแจ้งเตือนจะเรียกใช้การแจ้งเตือน",
"channelsDesc": "ส่งการแจ้งเตือนไปยังที่ต่างๆ",
"acknowledge": "รับทราบ",
"ackAll": "ยอมรับทั้งหมด",
"allAcknowledged": "รับทราบการแจ้งเตือนทั้งหมดแล้ว",
"ackFailed": "ไม่ตอบรับการแจ้งเตือน",
"ackAllFailed": "ไม่สามารถตอบรับการแจ้งเตือนทั้งหมดได้",
"showAcknowledged": "แสดงทั้งหมด",
"hideAcknowledged": "ซ่อน ตอบรับ",
"addChannel": "เพิ่มช่องทาง",
"editChannel": "แก้ไขช่อง",
"channelName": "ชื่อ",
@@ -4917,22 +4898,7 @@
"ntfyTopic": "หัวข้อ",
"ntfyTopicRequired": "จำเป็นต้องระบุหัวข้อ",
"ntfyToken": "โทเค็นการเข้าถึง (ไม่บังคับ)",
"channelSaveFailed": "ไม่สามารถบันทึกช่องได้",
"test": "ทดสอบ",
"testSent": "ส่งการแจ้งเตือนการทดสอบแล้ว",
"testFailed": "การแจ้งเตือนการทดสอบล้มเหลว",
"testFailedWithReason": "การแจ้งเตือนการทดสอบล้มเหลว: {{reason}}",
"addRule": "เพิ่มกฎการแจ้งเตือน",
"editRule": "แก้ไขกฎการแจ้งเตือน",
"ruleName": "ชื่อกฎ",
"ruleNameRequired": "ต้องระบุชื่อ",
"triggerType": "สิ่งกระตุ้น",
"thresholdValue": "เกณฑ์ (%)",
"durationSeconds": "ระยะเวลา (วินาที, 0 = ยิงทันที)",
"cooldownMinutes": "ระยะเวลาคูลดาวน์ (นาที)",
"channels": "ช่องทางการแจ้งเตือน",
"noChannelsHint": "เพิ่มช่องในแท็บช่องก่อน",
"ruleSaveFailed": "ไม่สามารถบันทึกกฎได้"
"channelSaveFailed": "ไม่สามารถบันทึกช่องได้"
},
"ai": {
"title": "ผู้ช่วย",
+1 -35
View File
@@ -287,7 +287,6 @@
"loading": "Yükleniyor...",
"noData": "Veri yok",
"allClear": "Her şey normal",
"acknowledgeAlert": "Onayla",
"noPingUrls": "Hiçbir URL yapılandırılmamış",
"pingLabel": "Etiket",
"addPingUrl": "URL Ekle",
@@ -324,8 +323,6 @@
"widgetMetricsChartDesc": "Bir sunucu için geçmiş CPU, bellek, disk veya ağ grafiği",
"widgetHostGridName": "Sunucu Izgarası",
"widgetHostGridDesc": "SSH sunucu durumlarının ızgara görünümü",
"widgetAlertFeedName": "Uyarı Akışı",
"widgetAlertFeedDesc": "Onay desteği ile canlı uyarı tetiklemeleri",
"widgetPingStatusName": "Ping Durumu",
"widgetPingStatusDesc": "Bir veya daha fazla URL için HTTP ping durumu",
"widgetRecentActivityName": "Son Etkinlikler",
@@ -747,7 +744,6 @@
"portForwarding": "İletme",
"sftp": "SFTP",
"connections": "Bağlantılar",
"alerts": "Uyarılar",
"serial": "Seri",
"keepSideMenuOpen": "Menüyü Açık Tutun",
"collapseSideMenu": "Menüyü Daralt",
@@ -4890,21 +4886,6 @@
"viewHistory": "Geçmişi Görüntüle"
},
"alerts": {
"tabFirings": "Uyarılar",
"tabRules": "Kurallar",
"tabChannels": "Kanallar",
"noFirings": "Onaylanmamış uyarı yok",
"noRules": "Uyarı kuralı yapılandırılmamış",
"noChannels": "Bildirim kanalı yapılandırılmamış",
"rulesDesc": "Uyarı kuralları bildirimleri tetikler",
"channelsDesc": "Uyarı bildirimlerinin gönderildiği yer",
"acknowledge": "Onayla",
"ackAll": "Tümünü Onayla",
"allAcknowledged": "Tüm uyarılar onaylandı",
"ackFailed": "Uyarı onaylanamadı",
"ackAllFailed": "Tüm uyarılar onaylanamadı",
"showAcknowledged": "Tümünü Göster",
"hideAcknowledged": "Onaylananları Gizle",
"addChannel": "Kanal Ekle",
"editChannel": "Kanalı Düzenle",
"channelName": "Ad",
@@ -4917,22 +4898,7 @@
"ntfyTopic": "Konu",
"ntfyTopicRequired": "Konu zorunludur",
"ntfyToken": "Erişim Anahtarı (isteğe bağlı)",
"channelSaveFailed": "Kanal kaydedilemedi",
"test": "Test Et",
"testSent": "Test bildirimi gönderildi",
"testFailed": "Test bildirimi başarısız oldu",
"testFailedWithReason": "Test bildirimi başarısız oldu: {{reason}}",
"addRule": "Uyarı Kuralı Ekle",
"editRule": "Uyarı Kuralını Düzenle",
"ruleName": "Kural Adı",
"ruleNameRequired": "Ad zorunludur",
"triggerType": "Tetikleyici",
"thresholdValue": "Eşik (%)",
"durationSeconds": "Süre (saniye, 0 = hemen tetikle)",
"cooldownMinutes": "Bekleme süresi (dakika)",
"channels": "Bildirim Kanalları",
"noChannelsHint": "Önce Kanallar sekmesinde kanal ekleyin",
"ruleSaveFailed": "Kural kaydedilemedi"
"channelSaveFailed": "Kanal kaydedilemedi"
},
"ai": {
"title": "Asistan",
+1 -35
View File
@@ -287,7 +287,6 @@
"loading": "Завантаження...",
"noData": "Дані недоступні",
"allClear": "Все чисто",
"acknowledgeAlert": "Підтвердити",
"noPingUrls": "URL-адреси не налаштовано",
"pingLabel": "Мітка",
"addPingUrl": "Додати URL-адресу",
@@ -324,8 +323,6 @@
"widgetMetricsChartDesc": "Історична діаграма процесора, пам'яті, диска або мережі для хоста",
"widgetHostGridName": "Сітка хоста",
"widgetHostGridDesc": "Таблиця статусів хостів SSH",
"widgetAlertFeedName": "Стрічка сповіщень",
"widgetAlertFeedDesc": "Активація тривог у реальному часі з підтримкою підтвердження",
"widgetPingStatusName": "Стан пінгу",
"widgetPingStatusDesc": "Статус HTTP-пінгу для однієї або кількох URL-адрес",
"widgetRecentActivityName": "Нещодавня активність",
@@ -747,7 +744,6 @@
"portForwarding": "Переадресація",
"sftp": "SFTP",
"connections": "З'єднання",
"alerts": "Сповіщення",
"serial": "Серійний",
"keepSideMenuOpen": "Залишити меню відкритим",
"collapseSideMenu": "Згорнути меню",
@@ -4890,21 +4886,6 @@
"viewHistory": "Переглянути історію"
},
"alerts": {
"tabFirings": "Сповіщення",
"tabRules": "Правила",
"tabChannels": "Канали",
"noFirings": "Немає непідтверджених сповіщень",
"noRules": "Правила сповіщень не налаштовано",
"noChannels": "Канали сповіщень не налаштовано",
"rulesDesc": "Правила сповіщень запускають сповіщення",
"channelsDesc": "Куди надсилаються сповіщення",
"acknowledge": "Підтвердити",
"ackAll": "Підтвердження всім",
"allAcknowledged": "Усі сповіщення підтверджено",
"ackFailed": "Не вдалося підтвердити сповіщення",
"ackAllFailed": "Не вдалося підтвердити всі сповіщення",
"showAcknowledged": "Показати все",
"hideAcknowledged": "Приховати підтверджено",
"addChannel": "Додати канал",
"editChannel": "Редагувати канал",
"channelName": "Ім'я",
@@ -4917,22 +4898,7 @@
"ntfyTopic": "Тема",
"ntfyTopicRequired": "Тема обов'язкова",
"ntfyToken": "Маркер доступу (необов'язково)",
"channelSaveFailed": "Не вдалося зберегти канал",
"test": "Тест",
"testSent": "Тестове сповіщення надіслано",
"testFailed": "Не вдалося виконати тестове сповіщення",
"testFailedWithReason": "Не вдалося виконати тестове сповіщення: {{reason}}",
"addRule": "Додати правило сповіщення",
"editRule": "Редагувати правило сповіщення",
"ruleName": "Назва правила",
"ruleNameRequired": "Ім'я обов'язкове",
"triggerType": "Тригер",
"thresholdValue": "Поріг (%)",
"durationSeconds": "Тривалість (секунди, 0 = негайно спрацьовувати)",
"cooldownMinutes": "Охолодження (хвилини)",
"channels": "Канали сповіщень",
"noChannelsHint": "Спочатку додайте канали на вкладці «Канали»",
"ruleSaveFailed": "Не вдалося зберегти правило"
"channelSaveFailed": "Не вдалося зберегти канал"
},
"ai": {
"title": "Помічник",
+1 -35
View File
@@ -287,7 +287,6 @@
"loading": "Đang tải...",
"noData": "Không có dữ liệu",
"allClear": "Tất cả ổn",
"acknowledgeAlert": "Xác nhận",
"noPingUrls": "Chưa cấu hình URL",
"pingLabel": "Nhãn",
"addPingUrl": "Thêm URL",
@@ -324,8 +323,6 @@
"widgetMetricsChartDesc": "Biểu đồ lịch sử CPU, bộ nhớ, ổ đĩa hoặc mạng cho một máy chủ",
"widgetHostGridName": "Lưới máy chủ",
"widgetHostGridDesc": "Hiển thị trạng thái máy chủ SSH dạng lưới",
"widgetAlertFeedName": "Luồng cảnh báo",
"widgetAlertFeedDesc": "Cảnh báo trực tiếp kèm hỗ trợ xác nhận",
"widgetPingStatusName": "Trạng thái ping",
"widgetPingStatusDesc": "Trạng thái ping HTTP cho một hoặc nhiều URL",
"widgetRecentActivityName": "Hoạt động gần đây",
@@ -747,7 +744,6 @@
"portForwarding": "Chuyển tiếp",
"sftp": "SFTP",
"connections": "Kết nối",
"alerts": "Cảnh báo",
"serial": "Nối tiếp",
"keepSideMenuOpen": "Giữ menu luôn mở",
"collapseSideMenu": "Menu thu gọn",
@@ -4890,21 +4886,6 @@
"viewHistory": "Xem lịch sử"
},
"alerts": {
"tabFirings": "Cảnh báo",
"tabRules": "Quy tắc",
"tabChannels": "Kênh",
"noFirings": "Không có cảnh báo chưa xác nhận",
"noRules": "Chưa cấu hình quy tắc cảnh báo",
"noChannels": "Chưa cấu hình kênh thông báo",
"rulesDesc": "Quy tắc cảnh báo kích hoạt thông báo",
"channelsDesc": "Nơi gửi thông báo cảnh báo",
"acknowledge": "Xác nhận",
"ackAll": "Xác nhận tất cả",
"allAcknowledged": "Tất cả cảnh báo đã được xác nhận",
"ackFailed": "Không thể xác nhận cảnh báo",
"ackAllFailed": "Không thể xác nhận tất cả cảnh báo",
"showAcknowledged": "Hiện tất cả",
"hideAcknowledged": "Ẩn đã xác nhận",
"addChannel": "Thêm kênh",
"editChannel": "Sửa kênh",
"channelName": "Tên",
@@ -4917,22 +4898,7 @@
"ntfyTopic": "Chủ đề",
"ntfyTopicRequired": "Chủ đề là bắt buộc",
"ntfyToken": "Mã truy cập (tùy chọn)",
"channelSaveFailed": "Lỗi khi lưu kênh",
"test": "Kiểm tra",
"testSent": "Đã gửi thông báo kiểm tra",
"testFailed": "Gửi thông báo kiểm tra thất bại",
"testFailedWithReason": "Thông báo kiểm tra thất bại: {{reason}}",
"addRule": "Thêm quy tắc cảnh báo",
"editRule": "Sửa quy tắc cảnh báo",
"ruleName": "Tên quy tắc",
"ruleNameRequired": "Tên là bắt buộc",
"triggerType": "Kích hoạt",
"thresholdValue": "Ngưỡng (%)",
"durationSeconds": "Thời gian (giây, 0 = kích hoạt ngay)",
"cooldownMinutes": "Thời gian chờ (phút)",
"channels": "Kênh thông báo",
"noChannelsHint": "Vui lòng thêm kênh trong tab Kênh trước",
"ruleSaveFailed": "Lỗi khi lưu quy tắc"
"channelSaveFailed": "Lỗi khi lưu kênh"
},
"ai": {
"title": "Trợ lý",
+1 -35
View File
@@ -287,7 +287,6 @@
"loading": "加载中...",
"noData": "暂无数据",
"allClear": "一切正常",
"acknowledgeAlert": "确认告警",
"noPingUrls": "未配置 URL",
"pingLabel": "标签",
"addPingUrl": "添加 URL",
@@ -324,8 +323,6 @@
"widgetMetricsChartDesc": "主机的历史 CPU、内存、磁盘或网络图表",
"widgetHostGridName": "主机网格",
"widgetHostGridDesc": "SSH 主机状态的网格视图",
"widgetAlertFeedName": "告警信息流",
"widgetAlertFeedDesc": "实时告警触发信息,支持确认操作",
"widgetPingStatusName": "Ping 状态",
"widgetPingStatusDesc": "一个或多个 URL 的 HTTP ping 状态",
"widgetRecentActivityName": "近期活动",
@@ -747,7 +744,6 @@
"portForwarding": "转发",
"sftp": "SFTP",
"connections": "连接",
"alerts": "告警",
"serial": "串口",
"keepSideMenuOpen": "保持菜单打开",
"collapseSideMenu": "折叠菜单",
@@ -4890,21 +4886,6 @@
"viewHistory": "查看历史"
},
"alerts": {
"tabFirings": "告警",
"tabRules": "规则",
"tabChannels": "通道",
"noFirings": "没有未确认告警",
"noRules": "未配置告警规则",
"noChannels": "未配置通知渠道",
"rulesDesc": "告警规则触发通知",
"channelsDesc": "告警通知的发送渠道",
"acknowledge": "确认告警",
"ackAll": "全部确认",
"allAcknowledged": "所有告警已确认",
"ackFailed": "确认告警失败",
"ackAllFailed": "确认所有告警失败",
"showAcknowledged": "显示全部",
"hideAcknowledged": "隐藏已确认",
"addChannel": "添加渠道",
"editChannel": "编辑渠道",
"channelName": "名称",
@@ -4917,22 +4898,7 @@
"ntfyTopic": "主题",
"ntfyTopicRequired": "主题为必填项",
"ntfyToken": "访问令牌(选填)",
"channelSaveFailed": "保存渠道失败",
"test": "测试",
"testSent": "测试通知已发送",
"testFailed": "测试通知失败",
"testFailedWithReason": "测试通知失败:{{reason}}",
"addRule": "添加告警规则",
"editRule": "编辑告警规则",
"ruleName": "规则名称",
"ruleNameRequired": "名称为必填项",
"triggerType": "触发条件",
"thresholdValue": "阈值(%)",
"durationSeconds": "持续时间(秒,0 表示立即触发)",
"cooldownMinutes": "冷却时间(分钟)",
"channels": "通知渠道",
"noChannelsHint": "请先在渠道标签页中添加渠道",
"ruleSaveFailed": "保存规则失败"
"channelSaveFailed": "保存渠道失败"
},
"ai": {
"title": "助手",
+1 -35
View File
@@ -287,7 +287,6 @@
"loading": "載入中...",
"noData": "無可用資料",
"allClear": "一切正常",
"acknowledgeAlert": "確認",
"noPingUrls": "未設定 URL",
"pingLabel": "標籤",
"addPingUrl": "新增 URL",
@@ -324,8 +323,6 @@
"widgetMetricsChartDesc": "主機的歷史 CPU、記憶體、磁碟或網路圖表",
"widgetHostGridName": "主機方格",
"widgetHostGridDesc": "SSH 主機狀態的方格檢視",
"widgetAlertFeedName": "警報動態",
"widgetAlertFeedDesc": "即時警報觸發並支援確認",
"widgetPingStatusName": "Ping 狀態",
"widgetPingStatusDesc": "一個或多個 URL 的 HTTP ping 狀態",
"widgetRecentActivityName": "近期活動",
@@ -747,7 +744,6 @@
"portForwarding": "轉發",
"sftp": "SFTP",
"connections": "連線",
"alerts": "警示",
"serial": "序列",
"keepSideMenuOpen": "保持菜單打開",
"collapseSideMenu": "折疊菜單",
@@ -4890,21 +4886,6 @@
"viewHistory": "檢視歷史記錄"
},
"alerts": {
"tabFirings": "警示",
"tabRules": "規則",
"tabChannels": "通道",
"noFirings": "沒有未確認的警示",
"noRules": "未設定警示規則",
"noChannels": "未設定通知通道",
"rulesDesc": "警示規則會觸發通知",
"channelsDesc": "警示通知傳送的位置",
"acknowledge": "確認",
"ackAll": "全部確認",
"allAcknowledged": "所有警示已確認",
"ackFailed": "確認警示失敗",
"ackAllFailed": "確認所有警示失敗",
"showAcknowledged": "顯示全部",
"hideAcknowledged": "隱藏已確認",
"addChannel": "新增通道",
"editChannel": "編輯通道",
"channelName": "名稱",
@@ -4917,22 +4898,7 @@
"ntfyTopic": "主題",
"ntfyTopicRequired": "主題為必填",
"ntfyToken": "存取權杖(選填)",
"channelSaveFailed": "無法儲存頻道",
"test": "測試",
"testSent": "已傳送測試通知",
"testFailed": "測試通知失敗",
"testFailedWithReason": "測試通知失敗: {{reason}}",
"addRule": "新增警示規則",
"editRule": "編輯警示規則",
"ruleName": "規則名稱",
"ruleNameRequired": "名稱為必填",
"triggerType": "觸發條件",
"thresholdValue": "閾值(%)",
"durationSeconds": "持續時間(秒,0 = 立即觸發)",
"cooldownMinutes": "冷卻時間(分鐘)",
"channels": "通知頻道",
"noChannelsHint": "請先於「頻道」分頁新增頻道",
"ruleSaveFailed": "無法儲存規則"
"channelSaveFailed": "無法儲存頻道"
},
"ai": {
"title": "助理",
-13
View File
@@ -25,7 +25,6 @@ import {
Fingerprint,
Hammer,
Play,
Plug,
ScrollText,
Sparkles,
Presentation,
@@ -154,9 +153,6 @@ const FleetInventoryTab = lazy(() =>
const TermixIdPanel = lazy(() =>
import("@/sidebar/TermixIdPanel").then((m) => ({ default: m.TermixIdPanel })),
);
const AlertsPanel = lazy(() =>
import("@/sidebar/AlertsPanel").then((m) => ({ default: m.AlertsPanel })),
);
const SessionLogsPanel = lazy(() =>
import("@/sidebar/SessionLogsPanel").then((m) => ({
default: m.SessionLogsPanel,
@@ -391,8 +387,6 @@ export function tabIcon(type: TabType) {
return <Presentation className="size-3.5" />;
case "termix-id":
return <Fingerprint className="size-3.5" />;
case "alerts":
return <Plug className="size-3.5" />;
case "session-logs":
return <ScrollText className="size-3.5" />;
case "snippets":
@@ -721,13 +715,6 @@ export function renderTabContent(
</PanelTabFrame>,
);
case "alerts":
return withTabSuspense(
<PanelTabFrame>
<AlertsPanel />
</PanelTabFrame>,
);
case "session-logs":
return withTabSuspense(
<PanelTabFrame>
-2
View File
@@ -26,7 +26,6 @@ export const WORKSPACE_CAPTURABLE_TYPES: TabType[] = [
"homepage",
"fleet-inventory",
"termix-id",
"alerts",
"session-logs",
"snippets",
"macros",
@@ -44,7 +43,6 @@ export const HOSTLESS_WORKSPACE_TAB_TYPES: TabType[] = [
"fleet-inventory",
"docker",
"termix-id",
"alerts",
"session-logs",
"snippets",
"macros",
-274
View File
@@ -1,274 +0,0 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/dialog";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { toast } from "sonner";
import {
createAlertRule,
updateAlertRule,
type AlertRule,
type NotificationChannel,
} from "@/api/alerts-api";
const TRIGGER_TYPES = [
{ value: "host_offline", label: "Host Offline", hasThreshold: false },
{ value: "host_online", label: "Host Online", hasThreshold: false },
{ value: "cpu_threshold", label: "CPU Usage Threshold", hasThreshold: true },
{
value: "memory_threshold",
label: "Memory Usage Threshold",
hasThreshold: true,
},
{
value: "disk_threshold",
label: "Disk Usage Threshold",
hasThreshold: true,
},
{
value: "health_check_failure",
label: "Health Check Failure",
hasThreshold: false,
},
{ value: "user_login", label: "User SSH Login", hasThreshold: false },
];
interface AlertRuleDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
rule: AlertRule | null;
channels: NotificationChannel[];
onSaved: () => void;
}
export function AlertRuleDialog({
open,
onOpenChange,
rule,
channels,
onSaved,
}: AlertRuleDialogProps) {
const { t } = useTranslation();
const [name, setName] = useState("");
const [triggerType, setTriggerType] = useState("host_offline");
const [threshold, setThreshold] = useState("80");
const [durationSeconds, setDurationSeconds] = useState("0");
const [cooldownMinutes, setCooldownMinutes] = useState("15");
const [selectedChannels, setSelectedChannels] = useState<number[]>([]);
const [saving, setSaving] = useState(false);
const triggerDef = TRIGGER_TYPES.find((tt) => tt.value === triggerType);
const hasThreshold = triggerDef?.hasThreshold ?? false;
useEffect(() => {
if (!open) return;
if (rule) {
setName(rule.name);
setTriggerType(rule.triggerType);
setThreshold(
rule.thresholdValue != null ? String(rule.thresholdValue) : "80",
);
setDurationSeconds(
rule.thresholdDurationSeconds != null
? String(rule.thresholdDurationSeconds)
: "0",
);
setCooldownMinutes(String(rule.cooldownMinutes));
setSelectedChannels(rule.channelIds ?? []);
} else {
setName("");
setTriggerType("host_offline");
setThreshold("80");
setDurationSeconds("0");
setCooldownMinutes("15");
setSelectedChannels([]);
}
}, [open, rule]);
function toggleChannel(id: number) {
setSelectedChannels((prev) =>
prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id],
);
}
async function handleSave() {
if (!name.trim()) {
toast.error(t("alerts.ruleNameRequired", "Name is required"));
return;
}
setSaving(true);
try {
const payload = {
name: name.trim(),
triggerType,
thresholdValue: hasThreshold ? Number(threshold) : null,
thresholdDurationSeconds:
hasThreshold && Number(durationSeconds) > 0
? Number(durationSeconds)
: null,
cooldownMinutes: Number(cooldownMinutes) || 15,
channels: selectedChannels,
};
if (rule) {
await updateAlertRule(rule.id, payload);
} else {
await createAlertRule(payload);
}
onSaved();
} catch {
toast.error(t("alerts.ruleSaveFailed", "Failed to save rule"));
} finally {
setSaving(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{rule
? t("alerts.editRule", "Edit Alert Rule")
: t("alerts.addRule", "Add Alert Rule")}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3 py-2">
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("alerts.ruleName", "Rule Name")}
</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
className="text-sm"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("alerts.triggerType", "Trigger")}
</label>
<div className="flex flex-col gap-1">
{TRIGGER_TYPES.map((tt) => (
<button
key={tt.value}
onClick={() => setTriggerType(tt.value)}
className={`flex items-center gap-2 px-2 py-1.5 text-xs border transition-colors text-left ${triggerType === tt.value ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
>
<span
className={`size-2 shrink-0 rounded-full border-2 ${triggerType === tt.value ? "border-accent-brand bg-accent-brand" : "border-muted-foreground"}`}
/>
{tt.label}
</button>
))}
</div>
</div>
{hasThreshold && (
<>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("alerts.thresholdValue", "Threshold (%)")}
</label>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={100}
value={threshold}
onChange={(e) => setThreshold(e.target.value)}
className="w-20 text-sm"
/>
<span className="text-xs text-muted-foreground">%</span>
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t(
"alerts.durationSeconds",
"Duration (seconds, 0 = fire immediately)",
)}
</label>
<Input
type="number"
min={0}
value={durationSeconds}
onChange={(e) => setDurationSeconds(e.target.value)}
className="w-24 text-sm"
/>
</div>
</>
)}
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("alerts.cooldownMinutes", "Cooldown (minutes)")}
</label>
<Input
type="number"
min={0}
value={cooldownMinutes}
onChange={(e) => setCooldownMinutes(e.target.value)}
className="w-24 text-sm"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("alerts.channels", "Notification Channels")}
</label>
{channels.length === 0 ? (
<span className="text-[10px] text-muted-foreground">
{t(
"alerts.noChannelsHint",
"Add channels in the Channels tab first",
)}
</span>
) : (
<div className="flex flex-col gap-1">
{channels.map((ch) => (
<button
key={ch.id}
onClick={() => toggleChannel(ch.id)}
className={`flex items-center gap-2 px-2 py-1.5 text-xs border transition-colors text-left ${selectedChannels.includes(ch.id) ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
>
<span
className={`size-2 shrink-0 rounded-sm border-2 ${selectedChannels.includes(ch.id) ? "border-accent-brand bg-accent-brand" : "border-muted-foreground"}`}
/>
{ch.name}
<span className="ml-auto text-[9px] font-mono uppercase opacity-60">
{ch.type}
</span>
</button>
))}
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
{t("common.cancel", "Cancel")}
</Button>
<Button
variant="outline"
onClick={handleSave}
disabled={saving}
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
>
{saving
? t("common.saving", "Saving...")
: t("common.save", "Save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
-471
View File
@@ -1,471 +0,0 @@
import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import {
Bell,
BellOff,
Check,
CheckCheck,
Trash2,
Plus,
Settings2,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/button";
import {
getAlertFirings,
acknowledgeAlertFiring,
acknowledgeAllAlertFirings,
getAlertRules,
getNotificationChannels,
deleteAlertRule,
deleteNotificationChannel,
testNotificationChannel,
type AlertFiring,
type AlertRule,
type NotificationChannel,
} from "@/api/alerts-api";
import { AlertRuleDialog } from "./AlertRuleDialog";
import { NotificationChannelDialog } from "./NotificationChannelDialog";
type PanelTab = "firings" | "rules" | "channels";
const SEVERITY_CLASS: Record<string, string> = {
info: "bg-blue-500/20 text-blue-400 border-blue-500/30",
warning: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30",
critical: "bg-red-500/20 text-red-400 border-red-500/30",
};
function timeAgo(isoStr: string): string {
const ms = Date.now() - new Date(isoStr).getTime();
if (!Number.isFinite(ms)) return "";
const sec = Math.floor(ms / 1000);
if (sec < 60) return `${sec}s ago`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
return `${Math.floor(hr / 24)}d ago`;
}
export function AlertsPanel() {
const { t } = useTranslation();
const [tab, setTab] = useState<PanelTab>("firings");
// Firings state
const [firings, setFirings] = useState<AlertFiring[]>([]);
const [firingsLoading, setFiringsLoading] = useState(true);
const [showAcknowledged, setShowAcknowledged] = useState(false);
// Rules state
const [rules, setRules] = useState<AlertRule[]>([]);
const [rulesLoading, setRulesLoading] = useState(false);
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
const [editRule, setEditRule] = useState<AlertRule | null>(null);
// Channels state
const [channels, setChannels] = useState<NotificationChannel[]>([]);
const [channelsLoading, setChannelsLoading] = useState(false);
const [channelDialogOpen, setChannelDialogOpen] = useState(false);
const [editChannel, setEditChannel] = useState<NotificationChannel | null>(
null,
);
const loadFirings = useCallback(() => {
setFiringsLoading(true);
getAlertFirings({ limit: 100 })
.then(setFirings)
.catch(() => {})
.finally(() => setFiringsLoading(false));
}, []);
const loadRules = useCallback(() => {
setRulesLoading(true);
getAlertRules()
.then(setRules)
.catch(() => {})
.finally(() => setRulesLoading(false));
}, []);
const loadChannels = useCallback(() => {
setChannelsLoading(true);
getNotificationChannels()
.then(setChannels)
.catch(() => {})
.finally(() => setChannelsLoading(false));
}, []);
useEffect(() => {
loadFirings();
loadChannels();
}, [loadFirings, loadChannels]);
useEffect(() => {
if (tab === "rules") loadRules();
if (tab === "channels") loadChannels();
}, [tab, loadRules, loadChannels]);
const visibleFirings = firings.filter((f) =>
showAcknowledged ? true : !f.acknowledged,
);
async function handleAck(id: number) {
try {
await acknowledgeAlertFiring(id);
setFirings((prev) =>
prev.map((f) => (f.id === id ? { ...f, acknowledged: true } : f)),
);
} catch {
toast.error(t("alerts.ackFailed", "Failed to acknowledge alert"));
}
}
async function handleAckAll() {
try {
await acknowledgeAllAlertFirings();
setFirings((prev) => prev.map((f) => ({ ...f, acknowledged: true })));
toast.success(t("alerts.allAcknowledged", "All alerts acknowledged"));
} catch {
toast.error(t("alerts.ackAllFailed", "Failed to acknowledge all alerts"));
}
}
async function handleDeleteRule(id: number) {
if (!confirm(t("common.confirmDelete", "Delete this rule?"))) return;
try {
await deleteAlertRule(id);
setRules((prev) => prev.filter((r) => r.id !== id));
toast.success(t("common.deleted", "Deleted"));
} catch {
toast.error(t("common.deleteFailed", "Delete failed"));
}
}
async function handleDeleteChannel(id: number) {
if (!confirm(t("common.confirmDelete", "Delete this channel?"))) return;
try {
await deleteNotificationChannel(id);
setChannels((prev) => prev.filter((c) => c.id !== id));
toast.success(t("common.deleted", "Deleted"));
} catch {
toast.error(t("common.deleteFailed", "Delete failed"));
}
}
async function handleTestChannel(id: number) {
try {
await testNotificationChannel(id);
toast.success(t("alerts.testSent", "Test notification sent"));
} catch (err) {
const detail = err instanceof Error ? err.message : undefined;
toast.error(
detail
? t(
"alerts.testFailedWithReason",
"Test notification failed: {{reason}}",
{ reason: detail },
)
: t("alerts.testFailed", "Test notification failed"),
);
}
}
const unreadCount = firings.filter((f) => !f.acknowledged).length;
return (
<div className="flex flex-col h-full">
{/* Tab strip */}
<div className="flex shrink-0 border-b border-border">
{(["firings", "rules", "channels"] as PanelTab[]).map((t_) => (
<button
key={t_}
onClick={() => setTab(t_)}
className={`flex-1 py-2 text-xs font-semibold uppercase tracking-widest transition-colors ${
tab === t_
? "border-b-2 border-accent-brand text-accent-brand"
: "text-muted-foreground hover:text-foreground"
}`}
>
{t_ === "firings"
? t("alerts.tabFirings", "Alerts")
: t_ === "rules"
? t("alerts.tabRules", "Rules")
: t("alerts.tabChannels", "Channels")}
{t_ === "firings" && unreadCount > 0 && (
<span className="ml-1.5 rounded-full bg-destructive px-1 text-[9px] font-bold text-white">
{unreadCount}
</span>
)}
</button>
))}
</div>
{/* Firings tab */}
{tab === "firings" && (
<div className="flex flex-col flex-1 min-h-0">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
<button
onClick={() => setShowAcknowledged((v) => !v)}
className={`text-[10px] font-semibold px-2 py-1 border transition-colors ${showAcknowledged ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
>
{showAcknowledged
? t("alerts.hideAcknowledged", "Hide Acked")
: t("alerts.showAcknowledged", "Show All")}
</button>
<div className="flex-1" />
{unreadCount > 0 && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-muted-foreground"
onClick={handleAckAll}
>
<CheckCheck className="size-3 mr-1" />
{t("alerts.ackAll", "Ack All")}
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground"
onClick={loadFirings}
title={t("common.refresh", "Refresh")}
>
<Settings2 className="size-3" />
</Button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
{firingsLoading && (
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
{t("common.loading", "Loading...")}
</div>
)}
{!firingsLoading && visibleFirings.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 gap-2 text-muted-foreground">
<BellOff className="size-8 opacity-40" />
<span className="text-xs">
{t("alerts.noFirings", "No unacknowledged alerts")}
</span>
</div>
)}
{!firingsLoading &&
visibleFirings.map((firing) => (
<div
key={firing.id}
className={`flex flex-col gap-1 px-3 py-2.5 border-b border-border transition-colors ${firing.acknowledged ? "opacity-50" : ""}`}
>
<div className="flex items-center gap-2">
<span
className={`shrink-0 text-[9px] font-bold uppercase px-1.5 py-0.5 border ${SEVERITY_CLASS[firing.severity] ?? "bg-muted text-muted-foreground border-border"}`}
>
{firing.severity}
</span>
<span className="flex-1 truncate text-xs font-semibold">
{firing.hostName}
</span>
{!firing.acknowledged && (
<button
onClick={() => handleAck(firing.id)}
className="shrink-0 text-muted-foreground hover:text-foreground transition-colors"
title={t("alerts.acknowledge", "Acknowledge")}
>
<Check className="size-3.5" />
</button>
)}
</div>
<span className="text-[11px] text-foreground/80">
{firing.message}
</span>
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
{firing.ruleName && <span>{firing.ruleName}</span>}
<span>{timeAgo(firing.firedAt)}</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Rules tab */}
{tab === "rules" && (
<div className="flex flex-col flex-1 min-h-0">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
<span className="flex-1 text-xs text-muted-foreground">
{t("alerts.rulesDesc", "Alert rules trigger notifications")}
</span>
<Button
variant="outline"
size="sm"
className="h-6 px-2 text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={() => {
setEditRule(null);
setRuleDialogOpen(true);
}}
>
<Plus className="size-3 mr-1" />
{t("common.add", "Add")}
</Button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
{rulesLoading && (
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
{t("common.loading", "Loading...")}
</div>
)}
{!rulesLoading && rules.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 gap-2 text-muted-foreground">
<Bell className="size-8 opacity-40" />
<span className="text-xs">
{t("alerts.noRules", "No alert rules configured")}
</span>
</div>
)}
{!rulesLoading &&
rules.map((rule) => (
<div
key={rule.id}
className="flex items-center gap-2 px-3 py-2.5 border-b border-border"
>
<div className="flex-1 min-w-0">
<div className="text-xs font-semibold truncate">
{rule.name}
</div>
<div className="text-[10px] text-muted-foreground">
{(rule.triggerType || "").replace(/_/g, " ")}
</div>
</div>
<div
className={`size-2 shrink-0 rounded-full ${rule.enabled ? "bg-green-500" : "bg-muted"}`}
/>
<button
onClick={() => {
setEditRule(rule);
setRuleDialogOpen(true);
}}
className="shrink-0 text-muted-foreground hover:text-foreground"
title={t("common.edit", "Edit")}
>
<Settings2 className="size-3.5" />
</button>
<button
onClick={() => handleDeleteRule(rule.id)}
className="shrink-0 text-muted-foreground hover:text-destructive"
title={t("common.delete", "Delete")}
>
<Trash2 className="size-3.5" />
</button>
</div>
))}
</div>
</div>
)}
{/* Channels tab */}
{tab === "channels" && (
<div className="flex flex-col flex-1 min-h-0">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
<span className="flex-1 text-xs text-muted-foreground">
{t("alerts.channelsDesc", "Where alert notifications are sent")}
</span>
<Button
variant="outline"
size="sm"
className="h-6 px-2 text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={() => {
setEditChannel(null);
setChannelDialogOpen(true);
}}
>
<Plus className="size-3 mr-1" />
{t("common.add", "Add")}
</Button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
{channelsLoading && (
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
{t("common.loading", "Loading...")}
</div>
)}
{!channelsLoading && channels.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 gap-2 text-muted-foreground">
<Bell className="size-8 opacity-40" />
<span className="text-xs">
{t(
"alerts.noChannels",
"No notification channels configured",
)}
</span>
</div>
)}
{!channelsLoading &&
channels.map((channel) => (
<div
key={channel.id}
className="flex items-center gap-2 px-3 py-2.5 border-b border-border"
>
<div className="flex-1 min-w-0">
<div className="text-xs font-semibold truncate">
{channel.name}
</div>
<div className="text-[10px] text-muted-foreground font-mono uppercase">
{channel.type}
</div>
</div>
<div
className={`size-2 shrink-0 rounded-full ${channel.enabled ? "bg-green-500" : "bg-muted"}`}
/>
<button
onClick={() => handleTestChannel(channel.id)}
className="shrink-0 text-[10px] font-semibold text-muted-foreground hover:text-foreground border border-border px-1.5 py-0.5"
title={t("alerts.test", "Test")}
>
{t("alerts.test", "Test")}
</button>
<button
onClick={() => {
setEditChannel(channel);
setChannelDialogOpen(true);
}}
className="shrink-0 text-muted-foreground hover:text-foreground"
title={t("common.edit", "Edit")}
>
<Settings2 className="size-3.5" />
</button>
<button
onClick={() => handleDeleteChannel(channel.id)}
className="shrink-0 text-muted-foreground hover:text-destructive"
title={t("common.delete", "Delete")}
>
<Trash2 className="size-3.5" />
</button>
</div>
))}
</div>
</div>
)}
<AlertRuleDialog
open={ruleDialogOpen}
onOpenChange={setRuleDialogOpen}
rule={editRule}
channels={channels}
onSaved={() => {
setRuleDialogOpen(false);
loadRules();
}}
/>
<NotificationChannelDialog
open={channelDialogOpen}
onOpenChange={setChannelDialogOpen}
channel={editChannel}
onSaved={() => {
setChannelDialogOpen(false);
loadChannels();
}}
/>
</div>
);
}
+24 -74
View File
@@ -1,7 +1,6 @@
import { useState, useEffect } from "react";
import { useState, useEffect, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import {
Bell,
Check,
LogOut,
PanelRight,
@@ -12,7 +11,6 @@ import {
User,
} from "lucide-react";
import type { SplitMode, TabType, ToolsTab } from "@/types/ui-types";
import { getAlertFirings } from "@/api/alerts-api";
import { isElectron } from "@/lib/electron";
import { readRailPreference, setRailPreference } from "./rail-preferences";
import { visibleRailItems } from "./rail-items";
@@ -31,7 +29,6 @@ export type RailView =
| "session-logs"
| "user-profile"
| "admin-settings"
| "alerts"
| "automations"
| "ai"
| "fleets"
@@ -148,49 +145,6 @@ export function AppRail({
promotable?: boolean;
rightDockable?: boolean;
} | null>(null);
const [unreadAlerts, setUnreadAlerts] = useState(0);
useEffect(() => {
let cancelled = false;
let intervalId: ReturnType<typeof setInterval> | null = null;
const poll = () => {
if (document.visibilityState === "hidden") return;
getAlertFirings({ acknowledged: false, limit: 50 })
.then((firings) => {
if (!cancelled) setUnreadAlerts(firings.length);
})
.catch(() => {});
};
const start = () => {
if (intervalId !== null) return;
intervalId = setInterval(poll, 30000);
};
const stop = () => {
if (intervalId === null) return;
clearInterval(intervalId);
intervalId = null;
};
const onVisibility = () => {
if (document.visibilityState === "hidden") {
stop();
return;
}
poll();
start();
};
poll();
if (document.visibilityState !== "hidden") start();
document.addEventListener("visibilitychange", onVisibility);
return () => {
cancelled = true;
stop();
document.removeEventListener("visibilitychange", onVisibility);
};
}, []);
const [hiddenTabs, setHiddenTabs] = useState<Set<string>>(() => {
try {
const stored = localStorage.getItem("hiddenRailTabs");
@@ -457,28 +411,29 @@ export function AppRail({
style={{ width: railExpanded ? "calc(100% - 16px)" : 20 }}
/>
)}
{[
{
view: "alerts" as RailView,
icon: <Bell size={16} />,
title: t("nav.alerts"),
promotable: true,
},
{
view: "user-profile" as RailView,
icon: <User size={16} />,
title: t("nav.userProfile"),
},
...(isAdmin
? [
{
view: "admin-settings" as RailView,
icon: <Settings size={16} />,
title: t("nav.admin"),
},
]
: []),
].map((item) => (
{(
[
{
view: "user-profile" as RailView,
icon: <User size={16} />,
title: t("nav.userProfile"),
},
...(isAdmin
? [
{
view: "admin-settings" as RailView,
icon: <Settings size={16} />,
title: t("nav.admin"),
},
]
: []),
] as {
view: RailView;
icon: ReactElement;
title: string;
promotable?: boolean;
}[]
).map((item) => (
<button
key={item.view}
onClick={(e) => {
@@ -520,11 +475,6 @@ export function AppRail({
style={{ width: 16, height: 16 }}
>
{item.icon}
{item.view === "alerts" && unreadAlerts > 0 && (
<span className="absolute -top-1 -right-1 flex size-3 items-center justify-center rounded-full bg-destructive text-[8px] font-bold text-white leading-none">
{unreadAlerts > 9 ? "9+" : unreadAlerts}
</span>
)}
</span>
<span
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"}`}
+1 -1
View File
@@ -14,7 +14,7 @@ import {
createNotificationChannel,
updateNotificationChannel,
type NotificationChannel,
} from "@/api/alerts-api";
} from "@/api/notification-channels-api";
interface NotificationChannelDialogProps {
open: boolean;
-7
View File
@@ -235,13 +235,6 @@ export function visibleRailItems(): RailItemDef[] {
* title and a mobile entry.
*/
export const RAIL_UTILITY_ITEMS: RailItemDef[] = [
{
id: "alerts",
icon: Plug,
labelKey: "nav.alerts",
promotable: true,
rightDockable: true,
},
{ id: "user-profile", icon: User, labelKey: "nav.userProfile" },
{ id: "admin-settings", icon: Settings, labelKey: "nav.admin" },
];
-45
View File
@@ -1,45 +0,0 @@
import { describe, expect, it } from "vitest";
import { mapAlertFiring } from "../../api/alerts-api";
describe("mapAlertFiring", () => {
it("normalizes sqlite snake_case rows for the alerts UI", () => {
expect(
mapAlertFiring({
id: 9,
user_id: "u1",
rule_id: 4,
host_id: 12,
host_name: "db-01",
fired_at: "2026-06-30 12:00:00",
resolved_at: null,
value: 91,
message: "CPU threshold exceeded",
severity: "critical",
acknowledged: 0,
rule_name: "CPU",
}),
).toEqual({
id: 9,
userId: "u1",
ruleId: 4,
hostId: 12,
hostName: "db-01",
firedAt: "2026-06-30 12:00:00",
resolvedAt: null,
value: 91,
message: "CPU threshold exceeded",
severity: "critical",
acknowledged: false,
ruleName: "CPU",
});
});
it("uses safe defaults for malformed rows", () => {
const firing = mapAlertFiring({ acknowledged: 1, severity: "bad" });
expect(firing.id).toBe(0);
expect(firing.hostName).toBe("");
expect(firing.severity).toBe("warning");
expect(firing.acknowledged).toBe(true);
});
});
+3 -3
View File
@@ -16,8 +16,8 @@ describe("humanize", () => {
});
it("splits snake_case into words", () => {
expect(humanize("propose_create_alert_rule")).toBe(
"Propose create alert rule",
expect(humanize("propose_create_automation")).toBe(
"Propose create automation",
);
});
@@ -51,7 +51,7 @@ describe("fieldLabel", () => {
describe("toolLabel", () => {
it("phrases tools as actions rather than function names", () => {
expect(toolLabel("list_hosts")).toBe("Reading hosts");
expect(toolLabel("propose_create_alert_rule")).toBe("Create alert rule");
expect(toolLabel("propose_create_fleet")).toBe("Create fleet");
});
it("covers every tool in the catalog", () => {
-131
View File
@@ -1,131 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
render,
screen,
waitFor,
cleanup,
fireEvent,
} from "@testing-library/react";
import type { NotificationChannel } from "@/api/alerts-api";
const alertsApi = vi.hoisted(() => ({
getAlertFirings: vi.fn(async () => []),
acknowledgeAlertFiring: vi.fn(async () => {}),
acknowledgeAllAlertFirings: vi.fn(async () => {}),
getAlertRules: vi.fn(async () => []),
getNotificationChannels: vi.fn(async () => [] as NotificationChannel[]),
deleteAlertRule: vi.fn(async () => {}),
deleteNotificationChannel: vi.fn(async () => {}),
testNotificationChannel: vi.fn(async () => {}),
}));
vi.mock("@/api/alerts-api", () => alertsApi);
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (_key: string, fallback?: string) => fallback ?? _key,
}),
}));
// A real (non-null) fake so tests can see what channels actually reach the
// rule dialog, instead of only asserting the API call count.
vi.mock("../../sidebar/AlertRuleDialog", () => ({
AlertRuleDialog: ({
open,
channels,
}: {
open: boolean;
channels: NotificationChannel[];
}) =>
open ? (
<div data-testid="rule-dialog-channels">
{channels.length === 0
? "no channels"
: channels.map((c) => c.name).join(",")}
</div>
) : null,
}));
vi.mock("../../sidebar/NotificationChannelDialog", () => ({
NotificationChannelDialog: () => null,
}));
import { AlertsPanel } from "../../sidebar/AlertsPanel";
function channel(
overrides: Partial<NotificationChannel> = {},
): NotificationChannel {
return {
id: 1,
userId: "user-1",
name: "ntfy",
type: "ntfy",
config: "{}",
enabled: true,
createdAt: new Date().toISOString(),
...overrides,
};
}
beforeEach(() => {
alertsApi.getAlertFirings.mockReset().mockResolvedValue([]);
alertsApi.getAlertRules.mockReset().mockResolvedValue([]);
alertsApi.getNotificationChannels.mockReset().mockResolvedValue([]);
});
afterEach(() => {
cleanup();
});
describe("AlertsPanel - notification channels", () => {
it("loads notification channels on mount, before the Channels tab is ever opened", async () => {
alertsApi.getNotificationChannels.mockResolvedValue([channel()]);
render(<AlertsPanel />);
// Regression: channels used to only load once the user switched to the
// "channels" tab, so editing a rule before ever visiting that tab showed
// an empty channel picker even though channels existed.
await waitFor(() => {
expect(alertsApi.getNotificationChannels).toHaveBeenCalledTimes(1);
});
});
it("populates the rule dialog's channel picker without ever visiting the Channels tab", async () => {
alertsApi.getNotificationChannels.mockResolvedValue([
channel({ id: 1, name: "ntfy-home" }),
channel({ id: 2, name: "webhook-slack" }),
]);
render(<AlertsPanel />);
await waitFor(() => {
expect(alertsApi.getNotificationChannels).toHaveBeenCalledTimes(1);
});
// Go straight to Rules and open the "new rule" dialog, skipping Channels
// entirely, this is exactly the sequence the original bug broke.
fireEvent.click(screen.getByText("Rules"));
fireEvent.click(screen.getByText("Add"));
expect(screen.getByTestId("rule-dialog-channels").textContent).toBe(
"ntfy-home,webhook-slack",
);
});
it("does not refetch channels again when switching to the Rules tab", async () => {
render(<AlertsPanel />);
await waitFor(() => {
expect(alertsApi.getNotificationChannels).toHaveBeenCalledTimes(1);
});
// Switching tabs only reloads rules here, not channels, since channels
// were already fetched on mount by the fix under test.
fireEvent.click(screen.getByText("Rules"));
await waitFor(() => {
expect(alertsApi.getAlertRules).toHaveBeenCalledTimes(1);
});
expect(alertsApi.getNotificationChannels).toHaveBeenCalledTimes(1);
});
});
@@ -29,10 +29,6 @@ vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("@/api/alerts-api", () => ({
getAlertFirings: vi.fn().mockResolvedValue([]),
}));
vi.mock("@/hooks/use-ai-availability", () => ({
useAiAvailability: () => ({ userEnabled: false }),
}));
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff. Show more