mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
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:
1 parent
e0f14f2ecf
commit
e372b0eda7
101 files changed
+29118
-5539
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
+9
-9
@@ -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),
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -157,7 +157,6 @@ const ALL_HIDEABLE_RAIL_VIEWS = [
|
||||
"split-screen",
|
||||
"connections",
|
||||
"session-logs",
|
||||
"alerts",
|
||||
"fleets",
|
||||
"workspaces",
|
||||
"network_graph",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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,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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "مساعد",
|
||||
|
||||
@@ -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": "Асистент",
|
||||
|
||||
@@ -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": "সহকারী",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Βοηθός",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "עוֹזֵר",
|
||||
|
||||
@@ -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": "सहायक",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "アシスタント",
|
||||
|
||||
@@ -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": "어시스턴트",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Помощник",
|
||||
|
||||
@@ -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": "Асистент",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ผู้ช่วย",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Помічник",
|
||||
|
||||
@@ -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ý",
|
||||
|
||||
@@ -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": "助手",
|
||||
|
||||
@@ -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": "助理",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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"}`}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
createNotificationChannel,
|
||||
updateNotificationChannel,
|
||||
type NotificationChannel,
|
||||
} from "@/api/alerts-api";
|
||||
} from "@/api/notification-channels-api";
|
||||
|
||||
interface NotificationChannelDialogProps {
|
||||
open: boolean;
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 }),
|
||||
}));
|
||||
|
||||
@@ -92,7 +92,6 @@ describe("RAIL_ITEMS", () => {
|
||||
"ai",
|
||||
"history",
|
||||
"session-logs",
|
||||
"alerts",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -138,7 +137,6 @@ describe("RAIL_ITEMS", () => {
|
||||
"ai",
|
||||
"history",
|
||||
"session-logs",
|
||||
"alerts",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user