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

-10
View File
@@ -72,11 +72,6 @@ const SplitScreenPanel = lazy(() =>
default: m.SplitScreenPanel,
})),
);
const AlertManager = lazy(() =>
import("@/dashboard/panels/alerts/AlertManager").then((m) => ({
default: m.AlertManager,
})),
);
// Secondary rail panels — load on first open, not with the shell critical path.
const SshToolsPanel = lazy(() =>
@@ -259,7 +254,6 @@ export function AppShell({
// The standalone desktop backend still owns system settings such as the
// Tailscale API key, even though it has only one implicit user.
const showAdminUI = isAdmin;
const [userId, setUserId] = useState<string | null>(null);
const [showDonationModal, setShowDonationModal] = useState(false);
const [showOnboarding, setShowOnboarding] = useState(false);
const [backgroundTabRecords, setBackgroundTabRecords] = useState<
@@ -396,7 +390,6 @@ export function AppShell({
getUserInfo()
.then((info) => {
setIsAdmin(info.is_admin);
setUserId(info.userId);
setShowDonationModal(!!info.show_donation_modal);
})
.catch(() => setIsAdmin(false));
@@ -2913,9 +2906,6 @@ export function AppShell({
/>
</Suspense>
)}
<Suspense fallback={null}>
<AlertManager userId={userId} loggedIn={!!username} />
</Suspense>
<OnboardingDialog
open={showOnboarding}
context={{}}
-56
View File
@@ -1,56 +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 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");
}
}
-28
View File
@@ -1,33 +1,5 @@
import type { TermixAlert } from "@/types";
import { authApi, handleApiError } from "@/main-axios";
// ALERTS
// ============================================================================
export async function getUserAlerts(): Promise<{
alerts: TermixAlert[];
}> {
try {
const response = await authApi.get(`/alerts`);
return response.data;
} catch (error) {
handleApiError(error, "fetch user alerts");
throw error;
}
}
export async function dismissAlert(
alertId: string,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.post("/alerts/dismiss", { alertId });
return response.data;
} catch (error) {
handleApiError(error, "dismiss alert");
throw error;
}
}
// ============================================================================
// UPDATES & RELEASES
// ============================================================================
@@ -1,151 +0,0 @@
import React from "react";
import { Button } from "@/components/button.tsx";
import { Badge } from "@/components/badge.tsx";
import {
X,
ExternalLink,
AlertTriangle,
Info,
CheckCircle,
AlertCircle,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { TermixAlert } from "@/types";
interface AlertCardProps {
alert: TermixAlert;
onDismiss: (alertId: string) => void;
onClose: () => void;
}
const getAlertIcon = (type?: string) => {
switch (type) {
case "warning":
return <AlertTriangle className="h-5 w-5 text-accent-brand" />;
case "error":
return <AlertCircle className="h-5 w-5 text-destructive" />;
case "success":
return <CheckCircle className="h-5 w-5 text-green-400" />;
case "info":
default:
return <Info className="h-5 w-5 text-muted-foreground" />;
}
};
const getAccentBorderClass = (priority?: string, type?: string): string => {
if (priority === "critical" || type === "error")
return "border-t-2 border-t-destructive";
if (priority === "high" || type === "warning")
return "border-t-2 border-t-accent-brand";
if (priority === "medium" || type === "success")
return "border-t-2 border-t-green-400";
return "";
};
const getPriorityBadgeVariant = (
priority?: string,
): "destructive" | "secondary" | "default" => {
switch (priority) {
case "critical":
case "high":
return "destructive";
case "medium":
return "secondary";
case "low":
default:
return "default";
}
};
const getTypeBadgeVariant = (
type?: string,
): "destructive" | "secondary" | "default" => {
switch (type) {
case "error":
return "destructive";
case "warning":
return "secondary";
case "success":
case "info":
default:
return "default";
}
};
export function AlertCard({
alert,
onDismiss,
onClose,
}: AlertCardProps): React.ReactElement | null {
const { t } = useTranslation();
if (!alert) {
return null;
}
const handleDismiss = () => {
onDismiss(alert.id);
onClose();
};
return (
<div
className={`w-full border border-foreground/10 rounded-none bg-card overflow-hidden ${getAccentBorderClass(alert.priority, alert.type)}`}
>
<div className="flex items-start justify-between px-4 pt-4 pb-2">
<div className="flex items-center gap-3">
{getAlertIcon(alert.type)}
<span className="text-sm font-semibold">{alert.title}</span>
</div>
<Button variant="ghost" size="icon-sm" onClick={onClose}>
<X />
</Button>
</div>
{(alert.priority || alert.type) && (
<div className="flex items-center gap-2 px-4 pb-3">
{alert.priority && (
<Badge variant={getPriorityBadgeVariant(alert.priority)}>
{alert.priority.toUpperCase()}
</Badge>
)}
{alert.type && (
<Badge
variant={getTypeBadgeVariant(alert.type)}
className={
alert.type === "success" ? "text-green-400" : undefined
}
>
{alert.type}
</Badge>
)}
</div>
)}
<div className="mx-3 mb-3 border border-foreground/10 px-3 py-3 bg-background">
<p className="text-sm text-muted-foreground leading-relaxed whitespace-pre-wrap">
{alert.message}
</p>
</div>
<div className="flex items-center gap-2 px-4 pb-4">
<Button variant="outline" size="sm" onClick={handleDismiss}>
{t("common.dismiss")}
</Button>
{alert.actionUrl && alert.actionText && (
<Button
variant="outline"
size="sm"
onClick={() =>
window.open(alert.actionUrl, "_blank", "noopener,noreferrer")
}
className="gap-1.5 text-accent-brand border-accent-brand/30 hover:border-accent-brand/60"
>
{alert.actionText}
<ExternalLink className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
);
}
@@ -1,168 +0,0 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React, { useEffect, useState } from "react";
import { AlertCard } from "./AlertCard.tsx";
import { Button } from "@/components/button.tsx";
import { getUserAlerts, dismissAlert } from "@/main-axios.ts";
import { useTranslation } from "react-i18next";
import type { TermixAlert } from "@/types";
import { toast } from "sonner";
interface AlertManagerProps {
userId: string | null;
loggedIn: boolean;
}
export function AlertManager({
userId,
loggedIn,
}: AlertManagerProps): React.ReactElement {
const { t } = useTranslation();
const [alerts, setAlerts] = useState<TermixAlert[]>([]);
const [currentAlertIndex, setCurrentAlertIndex] = useState(0);
const [, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (loggedIn && userId) {
fetchUserAlerts();
}
}, [loggedIn, userId]);
const fetchUserAlerts = async () => {
setLoading(true);
setError(null);
try {
const response = await getUserAlerts();
const userAlerts = response.alerts || [];
const sortedAlerts = userAlerts.sort((a: TermixAlert, b: TermixAlert) => {
const priorityOrder = { critical: 4, high: 3, medium: 2, low: 1 };
const aPriority =
priorityOrder[a.priority as keyof typeof priorityOrder] || 0;
const bPriority =
priorityOrder[b.priority as keyof typeof priorityOrder] || 0;
if (aPriority !== bPriority) {
return bPriority - aPriority;
}
return (
new Date(a.expiresAt).getTime() - new Date(b.expiresAt).getTime()
);
});
setAlerts(sortedAlerts);
setCurrentAlertIndex(0);
} catch {
toast.error(t("alerts.failedToLoad"));
setError(t("alerts.failedToLoad"));
} finally {
setLoading(false);
}
};
const handleDismissAlert = async (alertId: string) => {
try {
await dismissAlert(alertId);
setAlerts((prev) => {
const newAlerts = prev.filter((alert) => alert.id !== alertId);
return newAlerts;
});
setCurrentAlertIndex((prevIndex) => {
const newAlertsLength = alerts.length - 1;
if (newAlertsLength === 0) return 0;
if (prevIndex >= newAlertsLength)
return Math.max(0, newAlertsLength - 1);
return prevIndex;
});
} catch {
setError(t("alerts.failedToDismiss"));
}
};
const handleCloseCurrentAlert = () => {
if (alerts.length === 0) return;
if (currentAlertIndex < alerts.length - 1) {
setCurrentAlertIndex(currentAlertIndex + 1);
} else {
setAlerts([]);
setCurrentAlertIndex(0);
}
};
const handlePreviousAlert = () => {
if (currentAlertIndex > 0) {
setCurrentAlertIndex(currentAlertIndex - 1);
}
};
const handleNextAlert = () => {
if (currentAlertIndex < alerts.length - 1) {
setCurrentAlertIndex(currentAlertIndex + 1);
}
};
if (!loggedIn || !userId) {
return null;
}
if (alerts.length === 0) {
return null;
}
const currentAlert = alerts[currentAlertIndex];
if (!currentAlert) {
return null;
}
const hasMultipleAlerts = alerts.length > 1;
return (
<div className="fixed inset-0 flex items-center justify-center bg-background/80 z-[99999]">
<div className="w-full max-w-2xl mx-4 flex flex-col gap-2">
{hasMultipleAlerts && (
<div className="flex items-center justify-between px-1">
<span className="text-xs text-muted-foreground">
{currentAlertIndex + 1} {t("common.of")} {alerts.length}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handlePreviousAlert}
disabled={currentAlertIndex === 0}
>
{t("common.previous")}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleNextAlert}
disabled={currentAlertIndex === alerts.length - 1}
>
{t("common.next")}
</Button>
</div>
</div>
)}
<AlertCard
alert={currentAlert}
onDismiss={handleDismissAlert}
onClose={handleCloseCurrentAlert}
/>
{error && (
<div className="border border-destructive/50 rounded-md px-3 py-2 text-destructive text-xs">
{error}
</div>
)}
</div>
</div>
);
}
+6 -23
View File
@@ -2271,27 +2271,6 @@
}
},
"terminalToolbar": {},
"alerts": {
"addChannel": "Add Channel",
"editChannel": "Edit Channel",
"channelName": "Name",
"channelType": "Type",
"channelNameRequired": "Name is required",
"webhookUrl": "URL",
"webhookUrlRequired": "Webhook URL is required",
"webhookDesc": "POST JSON payload to this URL on each alert firing",
"ntfyServer": "Server URL",
"ntfyTopic": "Topic",
"ntfyTopicRequired": "Topic is required",
"ntfyToken": "Access Token (optional)",
"channelSaveFailed": "Failed to save channel",
"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)",
"failedToLoad": "Failed to load alerts",
"failedToDismiss": "Failed to dismiss alert"
},
"macros": {
"title": "Macros",
"description": "Run a saved series of commands in a terminal, and react to what the server sends back.",
@@ -2450,8 +2429,12 @@
"consequence": "Including activity from other users, not just yours."
},
"notify:send": {
"title": "Send notifications",
"consequence": "Through the channels you have already set up."
"title": "Send alerts",
"consequence": "To you or other users, and through the channels they set up."
},
"notify:hub": {
"title": "Receive every alert",
"consequence": "Stores and delivers the alerts all plugins send, including their contents."
},
"audit:read": {
"title": "Read the audit log",
-2
View File
@@ -1297,8 +1297,6 @@ export {
} from "@/api/admin-user-data-api";
export {
getUserAlerts,
dismissAlert,
getReleasesRSS,
getVersionInfo,
releaseUrlFrom,
+2
View File
@@ -130,6 +130,8 @@ export function createPluginApp(
hidden: item.hidden,
after: item.after,
order: item.order,
placement: item.placement,
useBadge: item.useBadge,
pluginId,
permission: item.permission
? resolvePluginPermission(pluginId, item.permission)
-10
View File
@@ -150,16 +150,6 @@ export {
type VersionInfo,
} from "@/api/system-status-api";
// Notification channels are core (ctx.notify on the backend). A plugin that
// lets users pick or manage them shows core's own dialog.
export { NotificationChannelDialog } from "@/sidebar/NotificationChannelDialog";
export {
getNotificationChannels,
deleteNotificationChannel,
testNotificationChannel,
type NotificationChannel,
} from "@/api/notification-channels-api";
// Keyboard hints, the connection log panel and a named console logger.
export { Kbd, KbdKey, KbdSeparator } from "@/components/kbd";
export { ConnectionLogPanel } from "@/components/connection/ConnectionLogPanel";
+28 -2
View File
@@ -12,6 +12,7 @@ import type { RailView } from "@/sidebar/AppRail";
import { useRailItems } from "@/sidebar/rail-items";
import type { SplitMode } from "@/types/ui-types";
import { readHiddenRailTabs } from "@/sidebar/hidden-rail-tabs";
import { RailBadge } from "@/sidebar/RailBadge";
export function MobileBottomBar({
railView,
@@ -75,7 +76,15 @@ export function MobileBottomBar({
className={`relative flex flex-col items-center justify-center flex-1 gap-0.5 py-2 min-h-[56px] transition-colors text-[10px] font-medium
${active ? "text-accent-brand" : "text-muted-foreground"}`}
>
<Icon className="size-5" />
<span className="relative">
<Icon className="size-5" />
{item.useBadge && (
<RailBadge
useBadge={item.useBadge}
className="-top-1.5 -right-2.5"
/>
)}
</span>
<span className="max-w-full truncate px-0.5">
{t(item.labelKey)}
</span>
@@ -92,7 +101,19 @@ export function MobileBottomBar({
className={`relative flex flex-col items-center justify-center flex-1 gap-0.5 py-2 min-h-[56px] transition-colors text-[10px] font-medium
${moreActive ? "text-accent-brand" : "text-muted-foreground"}`}
>
<MoreHorizontal className="size-5" />
<span className="relative">
<MoreHorizontal className="size-5" />
{moreItems.map((item) =>
item.useBadge ? (
<RailBadge
key={item.id}
useBadge={item.useBadge}
variant="dot"
className="-top-0.5 -right-1"
/>
) : null,
)}
</span>
<span>{t("common.more")}</span>
</button>
</DropdownMenuTrigger>
@@ -115,6 +136,11 @@ export function MobileBottomBar({
>
<Icon className="size-4" />
{t(item.labelKey)}
{item.useBadge && (
<span className="relative ml-auto w-5 h-3.5">
<RailBadge useBadge={item.useBadge} className="right-0" />
</span>
)}
</DropdownMenuItem>
);
})}
-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[] = [
+98
View File
@@ -0,0 +1,98 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Bell, Boxes } from "lucide-react";
import { AppRail } from "../../sidebar/AppRail";
import { RailBadge } from "../../sidebar/RailBadge";
import {
registerRailItem,
resetRegisteredRailItems,
} from "../../sidebar/rail-items";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("@/hooks/use-permissions", () => ({
usePermissions: () => ({ has: () => true, loaded: true }),
}));
afterEach(() => {
resetRegisteredRailItems();
localStorage.clear();
});
function renderRail(onRailClick = vi.fn()) {
render(
<AppRail
railView="hosts"
sidebarOpen={false}
splitMode="none"
username="alice"
isAdmin={false}
onRailClick={onRailClick}
onLogout={vi.fn()}
/>,
);
return onRailClick;
}
describe("AppRail", () => {
it("puts a footer item above the profile with its badge", async () => {
registerRailItem({
id: "inbox",
icon: Bell,
labelKey: "test.inbox",
placement: "footer",
useBadge: () => 3,
pluginId: "demo",
});
registerRailItem({
id: "boxes",
icon: Boxes,
labelKey: "test.boxes",
pluginId: "demo",
});
const onRailClick = renderRail();
const inbox = screen.getByTitle("test.inbox");
const profile = screen.getAllByTitle("nav.userProfile")[0];
const boxes = screen.getByTitle("test.boxes");
// Footer items render after the main list and before the profile.
expect(
boxes.compareDocumentPosition(inbox) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(
inbox.compareDocumentPosition(profile) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(inbox.textContent).toContain("3");
await userEvent.click(inbox);
expect(onRailClick).toHaveBeenCalledWith("inbox");
});
it("hides a footer item the user hid from Navigation", () => {
localStorage.setItem("hiddenRailTabs", JSON.stringify(["inbox"]));
registerRailItem({
id: "inbox",
icon: Bell,
labelKey: "test.inbox",
placement: "footer",
pluginId: "demo",
});
renderRail();
expect(screen.queryByTitle("test.inbox")).toBeNull();
});
});
describe("RailBadge", () => {
it("shows nothing for zero", () => {
const { container } = render(<RailBadge useBadge={() => 0} />);
expect(container.textContent).toBe("");
});
it("caps a large count", () => {
render(<RailBadge useBadge={() => 150} />);
expect(screen.getByText("99+")).toBeTruthy();
});
});