mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35: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
@@ -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" },
|
||||
];
|
||||
|
||||
Reference in new issue
Block a user