feat: unify alerts into one plugin, channels, rules and inbox

This commit is contained in:
LukeGus committed 2026-09-25 16:26:37 -05:00
1 parent fbe736bc20
commit 7c02daccf4
129 files changed
+20957 -2687

No files matched your search

-1
View File
@@ -775,7 +775,6 @@ export function AdminSettingsPanel({
(s.sshHostsImported || 0) +
(s.sshCredentialsImported || 0) +
(s.pluginItemsImported || 0) +
(s.dismissedAlertsImported || 0) +
(s.settingsImported || 0);
toast.success(
t("admin.importCompleted", { total, skipped: s.skippedItems || 0 }),
+98 -4
View File
@@ -14,6 +14,7 @@ import type { SplitMode, TabType, ToolsTab } from "@/types/ui-types";
import { Skeleton } from "@/components/skeleton";
import { readRailPreference, setRailPreference } from "./rail-preferences";
import { useRailItems, type RailItemDef } from "./rail-items";
import { RailBadge } from "./RailBadge";
/** Core rail views; plugins add their own ids at runtime. */
export type CoreRailView =
@@ -36,8 +37,15 @@ type RailItem =
dot?: boolean;
promotable?: boolean;
rightDockable?: boolean;
useBadge?: () => number | null | undefined;
}
| {
kind: "tab";
tabType: TabType;
icon: React.ReactNode;
title: string;
useBadge?: () => number | null | undefined;
}
| { kind: "tab"; tabType: TabType; icon: React.ReactNode; title: string }
| { kind: "separator" };
function buildRailButtons(
@@ -55,6 +63,7 @@ function buildRailButtons(
tabType: item.id as TabType,
icon: <Icon size={16} />,
title: t(item.labelKey),
useBadge: item.useBadge,
});
} else {
all.push({
@@ -64,6 +73,7 @@ function buildRailButtons(
dot: item.id === "split-screen" ? splitMode !== "none" : undefined,
promotable: item.promotable,
rightDockable: item.rightDockable,
useBadge: item.useBadge,
});
}
if (item.separatorAfter) all.push({ kind: "separator" });
@@ -202,7 +212,15 @@ export function AppRail({
}, []);
const railExpanded = pinned || (expandOnHover && hovered);
const railButtons = buildRailButtons(railItems, splitMode, t, hiddenTabs);
const railButtons = buildRailButtons(
railItems.filter((item) => item.placement !== "footer"),
splitMode,
t,
hiddenTabs,
);
const footerItems = railItems.filter(
(item) => item.placement === "footer" && !hiddenTabs.has(item.id),
);
const setRailPinned = (nextPinned: boolean) => {
setPinned(nextPinned);
localStorage.setItem("pinAppRail", String(nextPinned));
@@ -265,10 +283,16 @@ export function AppRail({
className={`${btnBase} text-muted-foreground hover:text-foreground hover:bg-muted/60`}
>
<span
className="shrink-0 flex items-center justify-center"
className="relative shrink-0 flex items-center justify-center"
style={{ width: 16, height: 16 }}
>
{item.icon}
{item.useBadge && (
<RailBadge
useBadge={item.useBadge}
className="-top-1.5 -right-2"
/>
)}
</span>
<span
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${
@@ -318,10 +342,16 @@ export function AppRail({
}`}
>
<span
className="shrink-0 flex items-center justify-center"
className="relative shrink-0 flex items-center justify-center"
style={{ width: 16, height: 16 }}
>
{item.icon}
{item.useBadge && (
<RailBadge
useBadge={item.useBadge}
className="-top-1.5 -right-2"
/>
)}
</span>
<span
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${
@@ -373,6 +403,70 @@ export function AppRail({
style={{ width: railExpanded ? "calc(100% - 16px)" : 20 }}
/>
)}
{footerItems.map((item) => {
const Icon = item.icon;
const title = t(item.labelKey);
return (
<button
key={item.id}
onClick={(e) => {
if (item.kind === "tab") {
onOpenTab?.(item.id as TabType);
return;
}
if (item.promotable && (e.ctrlKey || e.metaKey)) {
onOpenTab?.(item.id as TabType);
return;
}
onRailClick(item.id as RailView);
}}
onAuxClick={(e) => {
if (e.button !== 1 || !item.promotable) return;
e.preventDefault();
onOpenTab?.(item.id as TabType);
}}
onContextMenu={() => {
if (item.promotable || item.rightDockable)
setMenuTarget({
view: item.id as RailView,
title,
promotable: item.promotable,
rightDockable: item.rightDockable,
});
}}
data-rail-promotable={
item.promotable || item.rightDockable ? "" : undefined
}
title={
item.promotable ? `${title}\n${t("nav.openAsTabHint")}` : title
}
style={btnStyle}
className={`${btnBase} ${
sidebarOpen && railView === item.id
? "text-accent-brand bg-accent-brand/10"
: "text-muted-foreground hover:text-foreground hover:bg-muted/60"
}`}
>
<span
className="relative shrink-0 flex items-center justify-center"
style={{ width: 16, height: 16 }}
>
<Icon size={16} />
{item.useBadge && (
<RailBadge
useBadge={item.useBadge}
className="-top-1.5 -right-2"
/>
)}
</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"}`}
>
{title}
</span>
</button>
);
})}
{(
[
{
@@ -1,327 +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 {
createNotificationChannel,
updateNotificationChannel,
type NotificationChannel,
} from "@/api/notification-channels-api";
interface NotificationChannelDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
channel: NotificationChannel | null;
onSaved: () => void;
}
type ChannelType = "webhook" | "ntfy" | "discord";
interface WebhookConfig {
url: string;
headers?: Record<string, string>;
}
interface NtfyConfig {
url: string;
topic: string;
token?: string;
}
interface DiscordConfig {
url: string;
username?: string;
avatar_url?: string;
}
export function NotificationChannelDialog({
open,
onOpenChange,
channel,
onSaved,
}: NotificationChannelDialogProps) {
const { t } = useTranslation();
const [name, setName] = useState("");
const [type, setType] = useState<ChannelType>("webhook");
const [webhookUrl, setWebhookUrl] = useState("");
const [ntfyUrl, setNtfyUrl] = useState("https://ntfy.sh");
const [ntfyTopic, setNtfyTopic] = useState("");
const [ntfyToken, setNtfyToken] = useState("");
const [discordUsername, setDiscordUsername] = useState("");
const [discordAvatarUrl, setDiscordAvatarUrl] = useState("");
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) return;
if (channel) {
setName(channel.name);
setType(channel.type);
try {
const cfg = JSON.parse(channel.config);
if (channel.type === "webhook") {
setWebhookUrl((cfg as WebhookConfig).url ?? "");
} else if (channel.type === "discord") {
const dc = cfg as DiscordConfig;
setWebhookUrl(dc.url ?? "");
setDiscordUsername(dc.username ?? "");
setDiscordAvatarUrl(dc.avatar_url ?? "");
} else {
const nc = cfg as NtfyConfig;
setNtfyUrl(nc.url ?? "https://ntfy.sh");
setNtfyTopic(nc.topic ?? "");
setNtfyToken(nc.token ?? "");
}
} catch {
// ignore malformed config
}
} else {
setName("");
setType("webhook");
setWebhookUrl("");
setNtfyUrl("https://ntfy.sh");
setNtfyTopic("");
setNtfyToken("");
}
}, [open, channel]);
async function handleSave() {
if (!name.trim()) {
toast.error(t("alerts.channelNameRequired", "Name is required"));
return;
}
let config: WebhookConfig | NtfyConfig | DiscordConfig;
if (type === "webhook") {
if (!webhookUrl.trim()) {
toast.error(t("alerts.webhookUrlRequired", "Webhook URL is required"));
return;
}
config = {
url: webhookUrl.trim(),
} satisfies WebhookConfig;
} else if (type === "discord") {
const trimmedUrl = webhookUrl.trim();
if (!trimmedUrl) {
toast.error(t("alerts.webhookUrlRequired", "Webhook URL is required"));
return;
}
if (
!/^https:\/\/(?:canary\.|ptb\.)?(?:discord\.com|discordapp\.com)\/api\/webhooks\/.+/i.test(
trimmedUrl,
)
) {
toast.error(
t(
"alerts.discordUrlInvalid",
"Discord URL must be a valid Discord webhook URL",
),
);
return;
}
const dc: DiscordConfig = { url: trimmedUrl };
if (discordUsername.trim()) dc.username = discordUsername.trim();
if (discordAvatarUrl.trim()) dc.avatar_url = discordAvatarUrl.trim();
config = dc;
} else {
if (!ntfyTopic.trim()) {
toast.error(t("alerts.ntfyTopicRequired", "Topic is required"));
return;
}
const cfg: NtfyConfig = { url: ntfyUrl.trim(), topic: ntfyTopic.trim() };
if (ntfyToken.trim()) cfg.token = ntfyToken.trim();
config = cfg;
}
setSaving(true);
try {
if (channel) {
await updateNotificationChannel(channel.id, {
name: name.trim(),
type,
config,
});
} else {
await createNotificationChannel({ name: name.trim(), type, config });
}
onSaved();
} catch {
toast.error(t("alerts.channelSaveFailed", "Failed to save channel"));
} finally {
setSaving(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{channel
? t("alerts.editChannel", "Edit Channel")
: t("alerts.addChannel", "Add Channel")}
</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.channelName", "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.channelType", "Type")}
</label>
<div className="flex gap-2">
{(["webhook", "ntfy", "discord"] as ChannelType[]).map((ct) => (
<button
key={ct}
onClick={() => setType(ct)}
className={`px-3 py-1 text-xs font-semibold border transition-colors ${type === ct ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:border-accent-brand/30"}`}
>
{ct === "webhook"
? "Webhook"
: ct === "ntfy"
? "ntfy"
: "Discord"}
</button>
))}
</div>
</div>
{type === "webhook" && (
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("alerts.webhookUrl", "URL")}
</label>
<Input
value={webhookUrl}
onChange={(e) => setWebhookUrl(e.target.value)}
placeholder="https://example.com/webhook"
className="text-sm font-mono"
/>
<span className="text-[10px] text-muted-foreground">
{t(
"alerts.webhookDesc",
"POST JSON payload to this URL on each alert firing",
)}
</span>
</div>
)}
{type === "discord" && (
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("alerts.webhookUrl", "Discord webhook URL")}
</label>
<Input
value={webhookUrl}
onChange={(e) => setWebhookUrl(e.target.value)}
placeholder="https://discord.com/api/webhooks/..."
className="text-sm font-mono"
/>
<span className="text-[10px] text-muted-foreground">
{t(
"alerts.discordWebhookDesc",
"POST a Discord embed to this webhook URL on each alert firing",
)}
</span>
<div className="flex flex-col gap-1 pt-2">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("alerts.discordUsername", "Display name (optional)")}
</label>
<Input
value={discordUsername}
onChange={(e) => setDiscordUsername(e.target.value)}
placeholder="Termix"
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.discordAvatar", "Avatar URL (optional)")}
</label>
<Input
value={discordAvatarUrl}
onChange={(e) => setDiscordAvatarUrl(e.target.value)}
placeholder="https://example.com/avatar.png"
className="text-sm font-mono"
/>
</div>
</div>
)}
{type === "ntfy" && (
<>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("alerts.ntfyServer", "Server URL")}
</label>
<Input
value={ntfyUrl}
onChange={(e) => setNtfyUrl(e.target.value)}
placeholder="https://ntfy.sh"
className="text-sm font-mono"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("alerts.ntfyTopic", "Topic")}
</label>
<Input
value={ntfyTopic}
onChange={(e) => setNtfyTopic(e.target.value)}
placeholder="my-alerts"
className="text-sm font-mono"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("alerts.ntfyToken", "Access Token (optional)")}
</label>
<Input
type="password"
value={ntfyToken}
onChange={(e) => setNtfyToken(e.target.value)}
placeholder="tk_..."
className="text-sm font-mono"
/>
</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>
);
}
+30
View File
@@ -0,0 +1,30 @@
/**
* The count a rail item shows on its icon. Each badge is its own component so
* the item's useBadge hook runs once per render of a stable element.
*/
export function RailBadge({
useBadge,
variant = "count",
className = "",
}: {
useBadge: () => number | null | undefined;
variant?: "count" | "dot";
className?: string;
}) {
const count = useBadge();
if (!count || count <= 0) return null;
if (variant === "dot") {
return (
<span
className={`absolute size-1.5 rounded-full bg-accent-brand ${className}`}
/>
);
}
return (
<span
className={`absolute min-w-[14px] h-[14px] px-[3px] rounded-full bg-accent-brand text-[9px] font-bold leading-[14px] text-center text-white tabular-nums ${className}`}
>
{count > 99 ? "99+" : count}
</span>
);
}
-1
View File
@@ -1270,7 +1270,6 @@ export function UserProfilePanel({
(s.sshHostsImported || 0) +
(s.sshCredentialsImported || 0) +
(s.pluginItemsImported || 0) +
(s.dismissedAlertsImported || 0) +
(s.settingsImported || 0);
toast.success(
t("newUi.sidebar.userProfile.importCompleted", {
+4
View File
@@ -65,6 +65,10 @@ export interface RailItemDef {
pluginId?: string;
/** Role permission the user needs to see it at all, as a full id. */
permission?: string;
/** "footer" renders it at the bottom of the rail, above the profile. */
placement?: "main" | "footer";
/** Called as a hook by the rail; a positive number shows as a badge. */
useBadge?: () => number | null | undefined;
}
export const RAIL_ITEMS: RailItemDef[] = [