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

+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 }),
}));
-2
View File
@@ -92,7 +92,6 @@ describe("RAIL_ITEMS", () => {
"ai",
"history",
"session-logs",
"alerts",
]);
});
@@ -138,7 +137,6 @@ describe("RAIL_ITEMS", () => {
"ai",
"history",
"session-logs",
"alerts",
]);
});
});