mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
feat: unify alerts into one plugin, channels, rules and inbox
This commit is contained in:
1 parent
fbe736bc20
commit
7c02daccf4
129 files changed
+20957
-2687
No files matched your search
@@ -0,0 +1,166 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
ExternalLink,
|
||||
Info,
|
||||
Mail,
|
||||
MailOpen,
|
||||
OctagonAlert,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Button, cn } from "@termix/plugin-sdk/ui";
|
||||
import type { AlertItem, Severity } from "../types";
|
||||
import { sourceLabel, timeAgo } from "./format";
|
||||
|
||||
const SEVERITY_ICON: Record<Severity, typeof Info> = {
|
||||
info: Info,
|
||||
success: CheckCircle2,
|
||||
warning: AlertTriangle,
|
||||
critical: OctagonAlert,
|
||||
};
|
||||
|
||||
const SEVERITY_CLASS: Record<Severity, string> = {
|
||||
info: "text-muted-foreground",
|
||||
success: "text-green-500",
|
||||
warning: "text-amber-500",
|
||||
critical: "text-destructive",
|
||||
};
|
||||
|
||||
export function SeverityIcon({
|
||||
severity,
|
||||
className,
|
||||
}: {
|
||||
severity: Severity;
|
||||
className?: string;
|
||||
}) {
|
||||
const Icon = SEVERITY_ICON[severity];
|
||||
return <Icon className={cn(SEVERITY_CLASS[severity], className)} />;
|
||||
}
|
||||
|
||||
export function AlertList({
|
||||
items,
|
||||
compact = false,
|
||||
onOpen,
|
||||
onToggleRead,
|
||||
onDelete,
|
||||
}: {
|
||||
items: AlertItem[];
|
||||
compact?: boolean;
|
||||
onOpen: (item: AlertItem) => void;
|
||||
onToggleRead: (item: AlertItem) => void;
|
||||
onDelete: (item: AlertItem) => void;
|
||||
}) {
|
||||
const { t, language } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{items.map((item) => {
|
||||
const unread = !item.readAt;
|
||||
const failed = (item.deliveries ?? []).filter((entry) => !entry.ok);
|
||||
const actionText =
|
||||
typeof item.context?.actionText === "string"
|
||||
? item.context.actionText
|
||||
: t("inbox.openLink");
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"group relative flex gap-2.5 border-b border-border px-3 py-2.5 hover:bg-muted/40",
|
||||
unread && "bg-accent-brand/5",
|
||||
)}
|
||||
>
|
||||
{unread && (
|
||||
<span
|
||||
className="absolute left-0 top-0 bottom-0 w-0.5 bg-accent-brand"
|
||||
aria-label={t("inbox.unread")}
|
||||
/>
|
||||
)}
|
||||
<SeverityIcon
|
||||
severity={item.severity}
|
||||
className="size-4 shrink-0 mt-0.5"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 min-w-0 text-left"
|
||||
onClick={() => onOpen(item)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"text-sm leading-snug break-words",
|
||||
unread ? "font-semibold" : "font-medium",
|
||||
)}
|
||||
>
|
||||
{item.title}
|
||||
</div>
|
||||
{item.body && (
|
||||
<div
|
||||
className={cn(
|
||||
"text-xs text-muted-foreground mt-0.5 whitespace-pre-wrap break-words",
|
||||
compact && "line-clamp-2",
|
||||
)}
|
||||
>
|
||||
{item.body}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 mt-1 text-[11px] text-muted-foreground">
|
||||
<span title={item.category}>{sourceLabel(item.source, t)}</span>
|
||||
<span>{timeAgo(item.createdAt, language)}</span>
|
||||
{failed.length > 0 && (
|
||||
<span
|
||||
className="text-destructive"
|
||||
title={failed
|
||||
.map((entry) => `${entry.name}: ${entry.error ?? ""}`)
|
||||
.join("\n")}
|
||||
>
|
||||
{t("inbox.deliveryFailed", { count: failed.length })}
|
||||
</span>
|
||||
)}
|
||||
{item.link?.url && (
|
||||
<a
|
||||
href={item.link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-accent-brand hover:underline"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{actionText}
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex items-start gap-0.5 shrink-0 opacity-60 group-hover:opacity-100">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 rounded-none"
|
||||
title={unread ? t("inbox.markRead") : t("inbox.markUnread")}
|
||||
aria-label={
|
||||
unread ? t("inbox.markRead") : t("inbox.markUnread")
|
||||
}
|
||||
onClick={() => onToggleRead(item)}
|
||||
>
|
||||
{unread ? (
|
||||
<MailOpen className="size-3.5" />
|
||||
) : (
|
||||
<Mail className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 rounded-none"
|
||||
title={t("inbox.delete")}
|
||||
aria-label={t("inbox.delete")}
|
||||
onClick={() => onDelete(item)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
useSettings,
|
||||
useTabs,
|
||||
useTranslation,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { shouldPopUp, type PopupLevel } from "./popups";
|
||||
import type { AlertsStore } from "./store";
|
||||
|
||||
/** Pops up alerts as they arrive, as far as the user's setting allows. */
|
||||
export function AlertToaster({
|
||||
store,
|
||||
viewId,
|
||||
}: {
|
||||
store: AlertsStore;
|
||||
viewId: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tabs = useTabs();
|
||||
const { values } = useSettings("user");
|
||||
const level = useRef<PopupLevel>("warning");
|
||||
level.current = (values.popups as PopupLevel | undefined) ?? "warning";
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
store.onItem((item, isNew) => {
|
||||
if (!isNew || !shouldPopUp(item, level.current)) return;
|
||||
const show =
|
||||
item.severity === "critical"
|
||||
? toast.error
|
||||
: item.severity === "warning"
|
||||
? toast.warning
|
||||
: item.severity === "success"
|
||||
? toast.success
|
||||
: toast.info;
|
||||
show(item.title, {
|
||||
description: item.body ? item.body.slice(0, 200) : undefined,
|
||||
action: {
|
||||
label: t("inbox.view"),
|
||||
onClick: () => tabs.openRailView(viewId),
|
||||
},
|
||||
});
|
||||
}),
|
||||
[store, t, tabs, viewId],
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Bell, Radio, Route } from "lucide-react";
|
||||
import { usePluginApi, useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { cn } from "@termix/plugin-sdk/ui";
|
||||
import { createAlertsApi } from "./api";
|
||||
import { ChannelsView } from "./ChannelsView";
|
||||
import { InboxView } from "./InboxView";
|
||||
import { RulesView } from "./RulesView";
|
||||
import type { AlertsStore } from "./store";
|
||||
|
||||
import type { Section, SectionRequests } from "./sections";
|
||||
|
||||
const SECTIONS: Array<{ id: Section; icon: typeof Bell; labelKey: string }> = [
|
||||
{ id: "inbox", icon: Bell, labelKey: "sections.inbox" },
|
||||
{ id: "channels", icon: Radio, labelKey: "sections.channels" },
|
||||
{ id: "rules", icon: Route, labelKey: "sections.rules" },
|
||||
];
|
||||
|
||||
export function AlertsView({
|
||||
store,
|
||||
sections,
|
||||
}: {
|
||||
store: AlertsStore;
|
||||
sections: SectionRequests;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const client = usePluginApi();
|
||||
const api = useMemo(() => createAlertsApi(client), [client]);
|
||||
const [section, setSection] = useState<Section>(
|
||||
() => sections.take() ?? "inbox",
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
sections.subscribe((requested) => {
|
||||
sections.take();
|
||||
setSection(requested);
|
||||
}),
|
||||
[sections],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 h-full bg-background">
|
||||
<div className="flex items-center gap-1 border-b border-border px-2 shrink-0">
|
||||
{SECTIONS.map(({ id, icon: Icon, labelKey }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setSection(id)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-2 text-xs font-medium border-b-2 -mb-px transition-colors",
|
||||
section === id
|
||||
? "border-accent-brand text-accent-brand"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
{t(labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
|
||||
{section === "inbox" && <InboxView store={store} />}
|
||||
{section === "channels" && <ChannelsView api={api} />}
|
||||
{section === "rules" && <RulesView api={api} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Input,
|
||||
PasswordInput,
|
||||
Switch,
|
||||
Textarea,
|
||||
cn,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { CHANNEL_TYPES, type ChannelType } from "../types";
|
||||
import { EMPTY, configFrom, draftFrom, type Draft } from "./channel-config";
|
||||
import type { AlertsApi } from "./api";
|
||||
|
||||
function Field({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
{hint && (
|
||||
<span className="text-[10px] text-muted-foreground">{hint}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChannelDialog({
|
||||
api,
|
||||
open,
|
||||
channelId,
|
||||
emailAvailable,
|
||||
onOpenChange,
|
||||
onSaved,
|
||||
}: {
|
||||
api: AlertsApi;
|
||||
open: boolean;
|
||||
/** Null to create a new channel. */
|
||||
channelId: number | null;
|
||||
emailAvailable: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [draft, setDraft] = useState<Draft>(EMPTY);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (channelId === null) {
|
||||
setDraft(EMPTY);
|
||||
return;
|
||||
}
|
||||
api
|
||||
.channel(channelId)
|
||||
.then((channel) => setDraft(draftFrom(channel)))
|
||||
.catch((error) =>
|
||||
toast.error(error instanceof Error ? error.message : String(error)),
|
||||
);
|
||||
}, [api, channelId, open]);
|
||||
|
||||
const set = <K extends keyof Draft>(key: K, value: Draft[K]) =>
|
||||
setDraft((current) => ({ ...current, [key]: value }));
|
||||
|
||||
const pickType = (type: ChannelType) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
type,
|
||||
url:
|
||||
type === "ntfy" && !current.url
|
||||
? "https://ntfy.sh"
|
||||
: type !== "ntfy" && current.url === "https://ntfy.sh"
|
||||
? ""
|
||||
: current.url,
|
||||
}));
|
||||
|
||||
const save = async () => {
|
||||
if (!draft.name.trim()) {
|
||||
toast.error(t("channels.nameRequired"));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const input = {
|
||||
name: draft.name.trim(),
|
||||
type: draft.type,
|
||||
config: configFrom(draft),
|
||||
};
|
||||
if (channelId === null) await api.createChannel(input);
|
||||
else await api.updateChannel(channelId, input);
|
||||
onSaved();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const canReachPrivate = draft.type === "webhook" || draft.type === "ntfy";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md rounded-none">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{channelId === null ? t("channels.add") : t("channels.edit")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-3 py-2">
|
||||
<Field label={t("channels.name")}>
|
||||
<Input
|
||||
value={draft.name}
|
||||
onChange={(event) => set("name", event.target.value)}
|
||||
className="text-sm rounded-none"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={t("channels.type")}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CHANNEL_TYPES.map((type) => {
|
||||
const disabled = type === "email" && !emailAvailable;
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
title={
|
||||
disabled ? t("channels.emailUnavailable") : undefined
|
||||
}
|
||||
onClick={() => pickType(type)}
|
||||
className={cn(
|
||||
"px-3 py-1 text-xs font-semibold border transition-colors disabled:opacity-40 disabled:cursor-not-allowed",
|
||||
draft.type === type
|
||||
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
|
||||
: "border-border text-muted-foreground hover:border-accent-brand/30",
|
||||
)}
|
||||
>
|
||||
{t(`channels.types.${type}`)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{draft.type === "email" && !emailAvailable && (
|
||||
<span className="text-[10px] text-destructive">
|
||||
{t("channels.emailUnavailable")}
|
||||
</span>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
{draft.type === "webhook" && (
|
||||
<>
|
||||
<Field
|
||||
label={t("channels.webhookUrl")}
|
||||
hint={t("channels.webhookHint")}
|
||||
>
|
||||
<Input
|
||||
value={draft.url}
|
||||
onChange={(event) => set("url", event.target.value)}
|
||||
placeholder="https://example.com/webhook"
|
||||
className="text-sm font-mono rounded-none"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("channels.method")}>
|
||||
<div className="flex gap-2">
|
||||
{(["POST", "PUT"] as const).map((method) => (
|
||||
<button
|
||||
key={method}
|
||||
type="button"
|
||||
onClick={() => set("method", method)}
|
||||
className={cn(
|
||||
"px-3 py-1 text-xs font-mono border transition-colors",
|
||||
draft.method === method
|
||||
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
|
||||
: "border-border text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{method}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("channels.headers")}
|
||||
hint={t("channels.headersHint")}
|
||||
>
|
||||
<Textarea
|
||||
value={draft.headers}
|
||||
onChange={(event) => set("headers", event.target.value)}
|
||||
placeholder="Authorization: Bearer ..."
|
||||
rows={3}
|
||||
className="text-xs font-mono rounded-none"
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{draft.type === "ntfy" && (
|
||||
<>
|
||||
<Field label={t("channels.ntfyServer")}>
|
||||
<Input
|
||||
value={draft.url}
|
||||
onChange={(event) => set("url", event.target.value)}
|
||||
placeholder="https://ntfy.sh"
|
||||
className="text-sm font-mono rounded-none"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("channels.ntfyTopic")}>
|
||||
<Input
|
||||
value={draft.topic}
|
||||
onChange={(event) => set("topic", event.target.value)}
|
||||
placeholder="my-alerts"
|
||||
className="text-sm font-mono rounded-none"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("channels.ntfyToken")}>
|
||||
<PasswordInput
|
||||
value={draft.token}
|
||||
onChange={(event) => set("token", event.target.value)}
|
||||
placeholder="tk_..."
|
||||
className="text-sm font-mono rounded-none"
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{draft.type === "discord" && (
|
||||
<>
|
||||
<Field
|
||||
label={t("channels.discordUrl")}
|
||||
hint={t("channels.discordHint")}
|
||||
>
|
||||
<Input
|
||||
value={draft.url}
|
||||
onChange={(event) => set("url", event.target.value)}
|
||||
placeholder="https://discord.com/api/webhooks/..."
|
||||
className="text-sm font-mono rounded-none"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("channels.discordUsername")}>
|
||||
<Input
|
||||
value={draft.username}
|
||||
onChange={(event) => set("username", event.target.value)}
|
||||
placeholder="Termix"
|
||||
className="text-sm rounded-none"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("channels.discordAvatar")}>
|
||||
<Input
|
||||
value={draft.avatarUrl}
|
||||
onChange={(event) => set("avatarUrl", event.target.value)}
|
||||
placeholder="https://example.com/avatar.png"
|
||||
className="text-sm font-mono rounded-none"
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{draft.type === "email" && (
|
||||
<Field label={t("channels.emailTo")} hint={t("channels.emailHint")}>
|
||||
<Input
|
||||
value={draft.to}
|
||||
onChange={(event) => set("to", event.target.value)}
|
||||
placeholder="ops@example.com"
|
||||
className="text-sm rounded-none"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{canReachPrivate && (
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-medium">
|
||||
{t("channels.privateNetwork")}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("channels.privateNetworkHint")}
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
checked={draft.allowPrivateNetwork}
|
||||
onCheckedChange={(checked) =>
|
||||
set("allowPrivateNetwork", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="rounded-none"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void save()}
|
||||
disabled={saving}
|
||||
className="rounded-none border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
>
|
||||
{saving ? t("actions.saving") : t("actions.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Plus,
|
||||
Send,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Badge, Button, Switch, useConfirmation } from "@termix/plugin-sdk/ui";
|
||||
import type { ChannelSummary } from "../types";
|
||||
import type { AlertsApi } from "./api";
|
||||
import { ChannelDialog } from "./ChannelDialog";
|
||||
|
||||
export function ChannelsView({
|
||||
api,
|
||||
onChanged,
|
||||
}: {
|
||||
api: AlertsApi;
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
const [channels, setChannels] = useState<ChannelSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [emailAvailable, setEmailAvailable] = useState(false);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [testing, setTesting] = useState<number | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [list, meta] = await Promise.all([api.channels(), api.meta()]);
|
||||
setChannels(list);
|
||||
setEmailAvailable(meta.emailAvailable);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const test = async (channel: ChannelSummary) => {
|
||||
setTesting(channel.id);
|
||||
try {
|
||||
await api.testChannel(channel.id);
|
||||
toast.success(t("channels.testSent"));
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t("channels.testFailed", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setTesting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = async (channel: ChannelSummary, enabled: boolean) => {
|
||||
try {
|
||||
await api.updateChannel(channel.id, { enabled });
|
||||
setChannels((current) =>
|
||||
current.map((entry) =>
|
||||
entry.id === channel.id ? { ...entry, enabled } : entry,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
};
|
||||
|
||||
const remove = (channel: ChannelSummary) => {
|
||||
void confirmWithToast(
|
||||
t("channels.deleteConfirm", { name: channel.name }),
|
||||
async () => {
|
||||
try {
|
||||
await api.deleteChannel(channel.id);
|
||||
toast.success(t("channels.deleted"));
|
||||
await load();
|
||||
onChanged?.();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
},
|
||||
t("actions.delete"),
|
||||
t("actions.cancel"),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
<p className="text-xs text-muted-foreground">{t("channels.intro")}</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="rounded-none self-start border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={() => {
|
||||
setEditingId(null);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3.5 mr-1" />
|
||||
{t("channels.add")}
|
||||
</Button>
|
||||
|
||||
{loading ? (
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground m-4" />
|
||||
) : channels.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground py-4">
|
||||
{t("channels.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col border border-border">
|
||||
{channels.map((channel) => (
|
||||
<div
|
||||
key={channel.id}
|
||||
className="flex items-center gap-2 px-3 py-2 border-b border-border last:border-b-0"
|
||||
>
|
||||
<Switch
|
||||
checked={channel.enabled}
|
||||
onCheckedChange={(checked) => void toggle(channel, checked)}
|
||||
aria-label={t("channels.enabled")}
|
||||
/>
|
||||
<span className="flex-1 min-w-0 text-sm truncate">
|
||||
{channel.name}
|
||||
</span>
|
||||
{!channel.usable && (
|
||||
<span
|
||||
className="text-destructive"
|
||||
title={t("channels.needsSave")}
|
||||
>
|
||||
<AlertTriangle className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="rounded-none text-[10px] uppercase"
|
||||
>
|
||||
{t(`channels.types.${channel.type}`)}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-none"
|
||||
title={t("channels.test")}
|
||||
aria-label={t("channels.test")}
|
||||
disabled={testing === channel.id}
|
||||
onClick={() => void test(channel)}
|
||||
>
|
||||
{testing === channel.id ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Send className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-none"
|
||||
title={t("channels.edit")}
|
||||
aria-label={t("channels.edit")}
|
||||
onClick={() => {
|
||||
setEditingId(channel.id);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-none"
|
||||
title={t("actions.delete")}
|
||||
aria-label={t("actions.delete")}
|
||||
onClick={() => remove(channel)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ChannelDialog
|
||||
api={api}
|
||||
open={dialogOpen}
|
||||
channelId={editingId}
|
||||
emailAvailable={emailAvailable}
|
||||
onOpenChange={setDialogOpen}
|
||||
onSaved={() => {
|
||||
setDialogOpen(false);
|
||||
void load();
|
||||
onChanged?.();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { BellOff, CheckCheck, Loader2, Maximize2, Trash2 } from "lucide-react";
|
||||
import {
|
||||
usePluginApi,
|
||||
useTabs,
|
||||
useTranslation,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
Button,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
cn,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { SEVERITIES, type AlertItem, type Severity } from "../types";
|
||||
import { createAlertsApi } from "./api";
|
||||
import { AlertList } from "./AlertList";
|
||||
import { sourceLabel } from "./format";
|
||||
import { useAlertsVersion, type AlertsStore } from "./store";
|
||||
|
||||
const ALL = "__all";
|
||||
|
||||
export function InboxView({
|
||||
store,
|
||||
compact = false,
|
||||
onOpenAll,
|
||||
}: {
|
||||
store: AlertsStore;
|
||||
compact?: boolean;
|
||||
onOpenAll?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const client = usePluginApi();
|
||||
const api = useMemo(() => createAlertsApi(client), [client]);
|
||||
const tabs = useTabs();
|
||||
const version = useAlertsVersion(store);
|
||||
|
||||
const [items, setItems] = useState<AlertItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [unreadOnly, setUnreadOnly] = useState(false);
|
||||
const [severity, setSeverity] = useState<Severity | "">("");
|
||||
const [source, setSource] = useState("");
|
||||
const [sources, setSources] = useState<string[]>([]);
|
||||
const pageSize = compact ? 25 : 50;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const result = await api.items({
|
||||
unread: unreadOnly,
|
||||
severity: severity || undefined,
|
||||
source: source || undefined,
|
||||
limit: pageSize,
|
||||
});
|
||||
setItems(result.items);
|
||||
setHasMore(result.items.length === pageSize);
|
||||
store.setUnread(result.unread);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [api, pageSize, severity, source, store, unreadOnly]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load, version]);
|
||||
|
||||
useEffect(() => {
|
||||
if (compact) return;
|
||||
api
|
||||
.categories()
|
||||
.then((rows) =>
|
||||
setSources([...new Set(rows.map((row) => row.source))].sort()),
|
||||
)
|
||||
.catch(() => {});
|
||||
}, [api, compact, version]);
|
||||
|
||||
const loadMore = async () => {
|
||||
const last = items[items.length - 1];
|
||||
if (!last) return;
|
||||
try {
|
||||
const result = await api.items({
|
||||
unread: unreadOnly,
|
||||
severity: severity || undefined,
|
||||
source: source || undefined,
|
||||
limit: pageSize,
|
||||
before: last.id,
|
||||
});
|
||||
setItems((current) => [...current, ...result.items]);
|
||||
setHasMore(result.items.length === pageSize);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
};
|
||||
|
||||
const setRead = async (ids: number[] | "all", read: boolean) => {
|
||||
const { count } = await api.setRead(ids, read);
|
||||
store.setUnread(count);
|
||||
const now = new Date().toISOString();
|
||||
setItems((current) =>
|
||||
current
|
||||
.map((item) =>
|
||||
ids === "all" || ids.includes(item.id)
|
||||
? { ...item, readAt: read ? (item.readAt ?? now) : null }
|
||||
: item,
|
||||
)
|
||||
.filter((item) => !unreadOnly || !item.readAt),
|
||||
);
|
||||
};
|
||||
|
||||
const open = (item: AlertItem) => {
|
||||
if (!item.readAt) void setRead([item.id], true).catch(() => {});
|
||||
if (item.link?.tab) tabs.openSingletonTab(item.link.tab);
|
||||
};
|
||||
|
||||
const remove = async (item: AlertItem) => {
|
||||
try {
|
||||
await api.remove(item.id);
|
||||
setItems((current) => current.filter((entry) => entry.id !== item.id));
|
||||
store.setUnread((await api.unread()).count);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
};
|
||||
|
||||
const clearRead = async () => {
|
||||
try {
|
||||
const { removed } = await api.clear(true);
|
||||
toast.success(t("inbox.cleared", { count: removed }));
|
||||
await load();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
};
|
||||
|
||||
const unread = items.some((item) => !item.readAt);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-1.5 border-b border-border shrink-0",
|
||||
compact ? "px-2 py-1.5" : "px-3 py-2",
|
||||
)}
|
||||
>
|
||||
<div className="flex border border-border">
|
||||
{[false, true].map((value) => (
|
||||
<button
|
||||
key={String(value)}
|
||||
type="button"
|
||||
onClick={() => setUnreadOnly(value)}
|
||||
className={cn(
|
||||
"px-2 py-1 text-xs font-medium transition-colors",
|
||||
unreadOnly === value
|
||||
? "bg-accent-brand/10 text-accent-brand"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{value ? t("inbox.filterUnread") : t("inbox.filterAll")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{!compact && (
|
||||
<>
|
||||
<Select
|
||||
value={severity || ALL}
|
||||
onValueChange={(value) =>
|
||||
setSeverity(value === ALL ? "" : (value as Severity))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-[140px] rounded-none text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>{t("inbox.anySeverity")}</SelectItem>
|
||||
{SEVERITIES.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{t(`severity.${value}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={source || ALL}
|
||||
onValueChange={(value) => setSource(value === ALL ? "" : value)}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-[160px] rounded-none text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>{t("inbox.anySource")}</SelectItem>
|
||||
{sources.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{sourceLabel(value, t)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</>
|
||||
)}
|
||||
<span className="flex-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-none"
|
||||
disabled={!unread}
|
||||
title={t("inbox.markAllRead")}
|
||||
aria-label={t("inbox.markAllRead")}
|
||||
onClick={() => void setRead("all", true).catch(() => {})}
|
||||
>
|
||||
<CheckCheck className="size-4" />
|
||||
</Button>
|
||||
{!compact && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-none"
|
||||
title={t("inbox.clearRead")}
|
||||
aria-label={t("inbox.clearRead")}
|
||||
onClick={() => void clearRead()}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
{onOpenAll && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-none"
|
||||
title={t("inbox.openAll")}
|
||||
aria-label={t("inbox.openAll")}
|
||||
onClick={onOpenAll}
|
||||
>
|
||||
<Maximize2 className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="flex justify-center p-6">
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-2 p-8 text-center text-muted-foreground">
|
||||
<BellOff className="size-6" />
|
||||
<p className="text-sm">
|
||||
{unreadOnly ? t("inbox.emptyUnread") : t("inbox.empty")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<AlertList
|
||||
items={items}
|
||||
compact={compact}
|
||||
onOpen={open}
|
||||
onToggleRead={(item) =>
|
||||
void setRead([item.id], !item.readAt).catch(() => {})
|
||||
}
|
||||
onDelete={(item) => void remove(item)}
|
||||
/>
|
||||
{hasMore && (
|
||||
<div className="p-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full rounded-none text-xs"
|
||||
onClick={() => void loadMore()}
|
||||
>
|
||||
{t("inbox.loadMore")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Input,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Switch,
|
||||
useConfirmation,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
SEVERITIES,
|
||||
type AlertRule,
|
||||
type ChannelSummary,
|
||||
type Severity,
|
||||
} from "../types";
|
||||
import type { AlertsApi, RuleInput } from "./api";
|
||||
|
||||
const EMPTY: RuleInput = {
|
||||
name: "",
|
||||
match: "*",
|
||||
minSeverity: "warning",
|
||||
channelIds: [],
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
export function RulesView({ api }: { api: AlertsApi }) {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
const [rules, setRules] = useState<AlertRule[]>([]);
|
||||
const [channels, setChannels] = useState<ChannelSummary[]>([]);
|
||||
const [categories, setCategories] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState<AlertRule | null>(null);
|
||||
const [draft, setDraft] = useState<RuleInput>(EMPTY);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [ruleList, channelList, categoryList] = await Promise.all([
|
||||
api.rules(),
|
||||
api.channels(),
|
||||
api.categories(),
|
||||
]);
|
||||
setRules(ruleList);
|
||||
setChannels(channelList);
|
||||
setCategories([...new Set(categoryList.map((entry) => entry.category))]);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const channelName = (id: number) =>
|
||||
channels.find((channel) => channel.id === id)?.name ?? `#${id}`;
|
||||
|
||||
const openEditor = (rule: AlertRule | null) => {
|
||||
setEditing(rule);
|
||||
setDraft(
|
||||
rule
|
||||
? {
|
||||
name: rule.name,
|
||||
match: rule.match,
|
||||
minSeverity: rule.minSeverity,
|
||||
channelIds: rule.channelIds,
|
||||
enabled: rule.enabled,
|
||||
}
|
||||
: EMPTY,
|
||||
);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!draft.name.trim()) {
|
||||
toast.error(t("rules.nameRequired"));
|
||||
return;
|
||||
}
|
||||
if (draft.channelIds.length === 0) {
|
||||
toast.error(t("rules.channelRequired"));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing) await api.updateRule(editing.id, draft);
|
||||
else await api.createRule(draft);
|
||||
setDialogOpen(false);
|
||||
await load();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = async (rule: AlertRule, enabled: boolean) => {
|
||||
try {
|
||||
await api.updateRule(rule.id, {
|
||||
name: rule.name,
|
||||
match: rule.match,
|
||||
minSeverity: rule.minSeverity,
|
||||
channelIds: rule.channelIds,
|
||||
enabled,
|
||||
});
|
||||
setRules((current) =>
|
||||
current.map((entry) =>
|
||||
entry.id === rule.id ? { ...entry, enabled } : entry,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
};
|
||||
|
||||
const remove = (rule: AlertRule) => {
|
||||
void confirmWithToast(
|
||||
t("rules.deleteConfirm", { name: rule.name }),
|
||||
async () => {
|
||||
try {
|
||||
await api.deleteRule(rule.id);
|
||||
await load();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
},
|
||||
t("actions.delete"),
|
||||
t("actions.cancel"),
|
||||
);
|
||||
};
|
||||
|
||||
const toggleChannel = (id: number, checked: boolean) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
channelIds: checked
|
||||
? [...new Set([...current.channelIds, id])]
|
||||
: current.channelIds.filter((entry) => entry !== id),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
<p className="text-xs text-muted-foreground">{t("rules.intro")}</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="rounded-none self-start border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
disabled={channels.length === 0}
|
||||
title={channels.length === 0 ? t("rules.needChannel") : undefined}
|
||||
onClick={() => openEditor(null)}
|
||||
>
|
||||
<Plus className="size-3.5 mr-1" />
|
||||
{t("rules.add")}
|
||||
</Button>
|
||||
|
||||
{loading ? (
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground m-4" />
|
||||
) : rules.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground py-4">
|
||||
{channels.length === 0 ? t("rules.needChannel") : t("rules.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col border border-border">
|
||||
{rules.map((rule) => (
|
||||
<div
|
||||
key={rule.id}
|
||||
className="flex items-center gap-2 px-3 py-2 border-b border-border last:border-b-0"
|
||||
>
|
||||
<Switch
|
||||
checked={rule.enabled}
|
||||
onCheckedChange={(checked) => void toggle(rule, checked)}
|
||||
aria-label={t("rules.enabled")}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm truncate">{rule.name}</div>
|
||||
<div className="text-[11px] text-muted-foreground truncate">
|
||||
{t("rules.summary", {
|
||||
match:
|
||||
rule.match === "*" ? t("rules.everything") : rule.match,
|
||||
severity: t(`severity.${rule.minSeverity}`),
|
||||
channels: rule.channelIds.map(channelName).join(", "),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-none"
|
||||
title={t("rules.edit")}
|
||||
aria-label={t("rules.edit")}
|
||||
onClick={() => openEditor(rule)}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-none"
|
||||
title={t("actions.delete")}
|
||||
aria-label={t("actions.delete")}
|
||||
onClick={() => remove(rule)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-md rounded-none">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editing ? t("rules.edit") : t("rules.add")}
|
||||
</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("rules.name")}
|
||||
</label>
|
||||
<Input
|
||||
value={draft.name}
|
||||
onChange={(event) =>
|
||||
setDraft({ ...draft, name: event.target.value })
|
||||
}
|
||||
className="text-sm rounded-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("rules.match")}
|
||||
</label>
|
||||
<Input
|
||||
value={draft.match}
|
||||
list="alerts-rule-categories"
|
||||
onChange={(event) =>
|
||||
setDraft({ ...draft, match: event.target.value })
|
||||
}
|
||||
placeholder="*"
|
||||
className="text-sm font-mono rounded-none"
|
||||
/>
|
||||
<datalist id="alerts-rule-categories">
|
||||
<option value="*" />
|
||||
{categories.map((category) => (
|
||||
<option key={category} value={category} />
|
||||
))}
|
||||
</datalist>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("rules.matchHint")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("rules.minSeverity")}
|
||||
</label>
|
||||
<Select
|
||||
value={draft.minSeverity}
|
||||
onValueChange={(value) =>
|
||||
setDraft({ ...draft, minSeverity: value as Severity })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 rounded-none text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SEVERITIES.filter((value) => value !== "success").map(
|
||||
(value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{t(`severity.${value}`)}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("rules.channels")}
|
||||
</label>
|
||||
<div className="flex flex-col gap-1.5 border border-border p-2 max-h-40 overflow-y-auto">
|
||||
{channels.map((channel) => (
|
||||
<label
|
||||
key={channel.id}
|
||||
className="flex items-center gap-2 text-sm cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={draft.channelIds.includes(channel.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleChannel(channel.id, checked === true)
|
||||
}
|
||||
/>
|
||||
<span className="truncate">{channel.name}</span>
|
||||
<span className="text-[10px] uppercase text-muted-foreground">
|
||||
{t(`channels.types.${channel.type}`)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="rounded-none"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
>
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={saving}
|
||||
onClick={() => void save()}
|
||||
className="rounded-none border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
>
|
||||
{saving ? t("actions.saving") : t("actions.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
||||
import type {
|
||||
AlertItem,
|
||||
AlertRule,
|
||||
AlertsMeta,
|
||||
ChannelDetail,
|
||||
ChannelSummary,
|
||||
ChannelType,
|
||||
Severity,
|
||||
} from "../types";
|
||||
|
||||
export interface ItemQuery {
|
||||
unread?: boolean;
|
||||
source?: string;
|
||||
severity?: Severity;
|
||||
limit?: number;
|
||||
before?: number;
|
||||
}
|
||||
|
||||
export interface ChannelInput {
|
||||
name: string;
|
||||
type: ChannelType;
|
||||
config: Record<string, unknown>;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export type RuleInput = Omit<AlertRule, "id">;
|
||||
|
||||
function apiError(error: unknown, action: string): Error {
|
||||
const data = (
|
||||
error as { response?: { data?: { error?: string; message?: string } } }
|
||||
)?.response?.data;
|
||||
return new Error(
|
||||
data?.error ||
|
||||
data?.message ||
|
||||
(error instanceof Error ? error.message : `Failed to ${action}`),
|
||||
);
|
||||
}
|
||||
|
||||
async function call<T>(
|
||||
action: string,
|
||||
request: () => Promise<{ data: T }>,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return (await request()).data;
|
||||
} catch (error) {
|
||||
throw apiError(error, action);
|
||||
}
|
||||
}
|
||||
|
||||
function query(params: ItemQuery): string {
|
||||
const search = new URLSearchParams();
|
||||
if (params.unread) search.set("unread", "true");
|
||||
if (params.source) search.set("source", params.source);
|
||||
if (params.severity) search.set("severity", params.severity);
|
||||
if (params.limit) search.set("limit", String(params.limit));
|
||||
if (params.before) search.set("before", String(params.before));
|
||||
const text = search.toString();
|
||||
return text ? `?${text}` : "";
|
||||
}
|
||||
|
||||
export function createAlertsApi(api: PluginApiClient) {
|
||||
return {
|
||||
items: (params: ItemQuery = {}) =>
|
||||
call<{ items: AlertItem[]; unread: number }>("load alerts", () =>
|
||||
api.get(`/items${query(params)}`),
|
||||
),
|
||||
unread: () =>
|
||||
call<{ count: number }>("count alerts", () => api.get("/unread")),
|
||||
setRead: (ids: number[] | "all", read = true) =>
|
||||
call<{ count: number }>("update alerts", () =>
|
||||
api.post(
|
||||
"/items/read",
|
||||
ids === "all" ? { all: true, read } : { ids, read },
|
||||
),
|
||||
),
|
||||
remove: (id: number) =>
|
||||
call<{ success: boolean }>("delete alert", () =>
|
||||
api.delete(`/items/${id}`),
|
||||
),
|
||||
clear: (onlyRead: boolean) =>
|
||||
call<{ removed: number }>("clear alerts", () =>
|
||||
api.delete(`/items${onlyRead ? "?read=true" : ""}`),
|
||||
),
|
||||
categories: () =>
|
||||
call<Array<{ source: string; category: string }>>("load categories", () =>
|
||||
api.get("/categories"),
|
||||
),
|
||||
meta: () => call<AlertsMeta>("load channel types", () => api.get("/meta")),
|
||||
channels: () =>
|
||||
call<ChannelSummary[]>("load channels", () => api.get("/channels")),
|
||||
channel: (id: number) =>
|
||||
call<ChannelDetail>("load channel", () => api.get(`/channels/${id}`)),
|
||||
createChannel: (input: ChannelInput) =>
|
||||
call<ChannelDetail>("create channel", () => api.post("/channels", input)),
|
||||
updateChannel: (id: number, input: Partial<ChannelInput>) =>
|
||||
call<ChannelDetail>("update channel", () =>
|
||||
api.put(`/channels/${id}`, input),
|
||||
),
|
||||
deleteChannel: (id: number) =>
|
||||
call<{ success: boolean }>("delete channel", () =>
|
||||
api.delete(`/channels/${id}`),
|
||||
),
|
||||
testChannel: async (id: number) => {
|
||||
const result = await call<{ success: boolean; error?: string }>(
|
||||
"test channel",
|
||||
() => api.post(`/channels/${id}/test`),
|
||||
);
|
||||
if (!result.success) throw new Error(result.error || "Test failed");
|
||||
},
|
||||
rules: () => call<AlertRule[]>("load rules", () => api.get("/rules")),
|
||||
createRule: (input: RuleInput) =>
|
||||
call<AlertRule>("create rule", () => api.post("/rules", input)),
|
||||
updateRule: (id: number, input: RuleInput) =>
|
||||
call<AlertRule>("update rule", () => api.put(`/rules/${id}`, input)),
|
||||
deleteRule: (id: number) =>
|
||||
call<{ success: boolean }>("delete rule", () =>
|
||||
api.delete(`/rules/${id}`),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export type AlertsApi = ReturnType<typeof createAlertsApi>;
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { ChannelDetail, ChannelType } from "../types";
|
||||
|
||||
export interface Draft {
|
||||
name: string;
|
||||
type: ChannelType;
|
||||
url: string;
|
||||
method: "POST" | "PUT";
|
||||
headers: string;
|
||||
topic: string;
|
||||
token: string;
|
||||
username: string;
|
||||
avatarUrl: string;
|
||||
to: string;
|
||||
allowPrivateNetwork: boolean;
|
||||
}
|
||||
|
||||
export const EMPTY: Draft = {
|
||||
name: "",
|
||||
type: "webhook",
|
||||
url: "",
|
||||
method: "POST",
|
||||
headers: "",
|
||||
topic: "",
|
||||
token: "",
|
||||
username: "",
|
||||
avatarUrl: "",
|
||||
to: "",
|
||||
allowPrivateNetwork: false,
|
||||
};
|
||||
|
||||
const str = (value: unknown) => (typeof value === "string" ? value : "");
|
||||
|
||||
export function draftFrom(channel: ChannelDetail): Draft {
|
||||
const config = channel.config;
|
||||
const headers =
|
||||
config.headers && typeof config.headers === "object"
|
||||
? Object.entries(config.headers as Record<string, unknown>)
|
||||
.map(([key, value]) => `${key}: ${String(value)}`)
|
||||
.join("\n")
|
||||
: "";
|
||||
return {
|
||||
...EMPTY,
|
||||
name: channel.name,
|
||||
type: channel.type,
|
||||
url: str(config.url) || (channel.type === "ntfy" ? "https://ntfy.sh" : ""),
|
||||
method: config.method === "PUT" ? "PUT" : "POST",
|
||||
headers,
|
||||
topic: str(config.topic),
|
||||
token: str(config.token),
|
||||
username: str(config.username),
|
||||
avatarUrl: str(config.avatar_url),
|
||||
to: str(config.to),
|
||||
allowPrivateNetwork: config.allowPrivateNetwork === true,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseHeaders(text: string): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const at = line.indexOf(":");
|
||||
if (at <= 0) continue;
|
||||
const key = line.slice(0, at).trim();
|
||||
const value = line.slice(at + 1).trim();
|
||||
if (key) headers[key] = value;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
export function configFrom(draft: Draft): Record<string, unknown> {
|
||||
const privateNetwork = draft.allowPrivateNetwork
|
||||
? { allowPrivateNetwork: true }
|
||||
: {};
|
||||
switch (draft.type) {
|
||||
case "webhook": {
|
||||
const headers = parseHeaders(draft.headers);
|
||||
return {
|
||||
url: draft.url.trim(),
|
||||
method: draft.method,
|
||||
...(Object.keys(headers).length ? { headers } : {}),
|
||||
...privateNetwork,
|
||||
};
|
||||
}
|
||||
case "ntfy":
|
||||
return {
|
||||
url: draft.url.trim(),
|
||||
topic: draft.topic.trim(),
|
||||
...(draft.token.trim() ? { token: draft.token.trim() } : {}),
|
||||
...privateNetwork,
|
||||
};
|
||||
case "discord":
|
||||
return {
|
||||
url: draft.url.trim(),
|
||||
...(draft.username.trim() ? { username: draft.username.trim() } : {}),
|
||||
...(draft.avatarUrl.trim()
|
||||
? { avatar_url: draft.avatarUrl.trim() }
|
||||
: {}),
|
||||
};
|
||||
case "email":
|
||||
return { to: draft.to.trim() };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ANNOUNCEMENT_SOURCE } from "../types";
|
||||
|
||||
export function timeAgo(iso: string, language: string): string {
|
||||
// SQLite's CURRENT_TIMESTAMP has no zone; it is UTC.
|
||||
const normalized = /[zZ]|[+-]\d\d:?\d\d$/.test(iso)
|
||||
? iso
|
||||
: `${iso.replace(" ", "T")}Z`;
|
||||
const ms = Date.parse(normalized);
|
||||
if (!Number.isFinite(ms)) return "";
|
||||
const seconds = Math.round((ms - Date.now()) / 1000);
|
||||
const format = new Intl.RelativeTimeFormat(language || "en", {
|
||||
numeric: "auto",
|
||||
});
|
||||
const abs = Math.abs(seconds);
|
||||
if (abs < 60) return format.format(seconds, "second");
|
||||
if (abs < 3600) return format.format(Math.round(seconds / 60), "minute");
|
||||
if (abs < 86400) return format.format(Math.round(seconds / 3600), "hour");
|
||||
return format.format(Math.round(seconds / 86400), "day");
|
||||
}
|
||||
|
||||
export function sourceLabel(
|
||||
source: string,
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
): string {
|
||||
if (source === ANNOUNCEMENT_SOURCE) return t("inbox.sourceTermix");
|
||||
return source
|
||||
.split("-")
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { Bell } from "lucide-react";
|
||||
import type {
|
||||
PanelProps,
|
||||
TabProps,
|
||||
TermixApp,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { AlertToaster } from "./AlertToaster";
|
||||
import { AlertsView } from "./AlertsView";
|
||||
import { createSectionRequests } from "./sections";
|
||||
import { InboxView } from "./InboxView";
|
||||
import { createAlertsStore, useUnreadCount } from "./store";
|
||||
|
||||
const VIEW_ID = "alerts";
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
const store = createAlertsStore({
|
||||
connect: (init) => app.fetch("/stream", init),
|
||||
loadUnread: async () =>
|
||||
(await app.api.get<{ count: number }>("/unread")).data.count,
|
||||
});
|
||||
const sections = createSectionRequests();
|
||||
|
||||
if (!app.guest) {
|
||||
void app.hasPermission("use").then((allowed) => {
|
||||
if (allowed) store.start();
|
||||
});
|
||||
}
|
||||
app.onDispose(() => store.stop());
|
||||
|
||||
const openTab = () => app.tabs.openSingletonTab(VIEW_ID);
|
||||
|
||||
function Panel({ active }: PanelProps) {
|
||||
if (!active) return null;
|
||||
return <InboxView store={store} compact onOpenAll={openTab} />;
|
||||
}
|
||||
|
||||
function AlertsTab() {
|
||||
return <AlertsView store={store} sections={sections} />;
|
||||
}
|
||||
|
||||
function Toaster() {
|
||||
return <AlertToaster store={store} viewId={VIEW_ID} />;
|
||||
}
|
||||
|
||||
app.registerRailItem({
|
||||
id: VIEW_ID,
|
||||
icon: Bell,
|
||||
titleKey: "nav.alerts",
|
||||
placement: "footer",
|
||||
simplePreset: true,
|
||||
promotable: true,
|
||||
rightDockable: true,
|
||||
separatorAfter: false,
|
||||
permission: "use",
|
||||
useBadge: () => useUnreadCount(store),
|
||||
});
|
||||
app.registerPanel(VIEW_ID, Panel);
|
||||
app.registerTab(VIEW_ID, AlertsTab as ComponentType<TabProps>, {
|
||||
icon: Bell,
|
||||
titleKey: "nav.alerts",
|
||||
singleton: true,
|
||||
hostless: true,
|
||||
panelFrame: true,
|
||||
});
|
||||
|
||||
app.registerSlotContribution("shell.overlay", {
|
||||
actionId: "alerts.toaster",
|
||||
titleKey: "nav.alerts",
|
||||
kind: "component",
|
||||
component: Toaster as ComponentType<Record<string, unknown>>,
|
||||
});
|
||||
|
||||
app.registerAction("alerts.open", () => openTab());
|
||||
app.registerAction("alerts.openChannels", () => {
|
||||
sections.request("channels");
|
||||
openTab();
|
||||
});
|
||||
|
||||
app.registerSlotContribution("onboarding.features", {
|
||||
actionId: "alerts.feature",
|
||||
titleKey: "onboarding.feature",
|
||||
descriptionKey: "onboarding.featureDesc",
|
||||
icon: Bell as ComponentType<{ className?: string }>,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { severityRank, type AlertItem } from "../types";
|
||||
|
||||
export type PopupLevel = "all" | "warning" | "critical" | "off";
|
||||
|
||||
export function shouldPopUp(item: AlertItem, level: PopupLevel): boolean {
|
||||
if (level === "off") return false;
|
||||
if (level === "all") return true;
|
||||
return severityRank(item.severity) >= severityRank(level);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export type Section = "inbox" | "channels" | "rules";
|
||||
|
||||
/** Lets an action open the tab on a given section. */
|
||||
export function createSectionRequests() {
|
||||
let pending: Section | null = null;
|
||||
const listeners = new Set<(section: Section) => void>();
|
||||
return {
|
||||
request(section: Section) {
|
||||
pending = section;
|
||||
for (const listener of listeners) listener(section);
|
||||
},
|
||||
take(): Section | null {
|
||||
const section = pending;
|
||||
pending = null;
|
||||
return section;
|
||||
},
|
||||
subscribe(listener: (section: Section) => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type SectionRequests = ReturnType<typeof createSectionRequests>;
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import type { AlertItem } from "../types";
|
||||
|
||||
interface ServerSentEvent {
|
||||
event: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
export function parseServerSentEvents(
|
||||
buffer: string,
|
||||
onEvent: (event: ServerSentEvent) => void,
|
||||
): string {
|
||||
const blocks = buffer.split(/\r?\n\r?\n/);
|
||||
const rest = blocks.pop() ?? "";
|
||||
for (const block of blocks) {
|
||||
let event = "message";
|
||||
const data: string[] = [];
|
||||
for (const line of block.split(/\r?\n/)) {
|
||||
if (line.startsWith(":")) continue;
|
||||
if (line.startsWith("event:")) event = line.slice(6).trim();
|
||||
else if (line.startsWith("data:")) data.push(line.slice(5).trimStart());
|
||||
}
|
||||
if (data.length > 0) onEvent({ event, data: data.join("\n") });
|
||||
}
|
||||
return rest;
|
||||
}
|
||||
|
||||
export interface AlertsStoreDeps {
|
||||
/** Opens the event stream; app.fetch carries core's auth. */
|
||||
connect: (init: RequestInit) => Promise<Response>;
|
||||
/** The unread count, for when the stream is down. */
|
||||
loadUnread: () => Promise<number>;
|
||||
retryMs?: number;
|
||||
pollMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The live side of the inbox: the unread count every badge shows, and every
|
||||
* new alert as it arrives, from one stream shared by the whole app.
|
||||
*/
|
||||
export function createAlertsStore(deps: AlertsStoreDeps) {
|
||||
const retryMs = deps.retryMs ?? 5_000;
|
||||
const pollMs = deps.pollMs ?? 60_000;
|
||||
let unread = 0;
|
||||
let version = 0;
|
||||
const seen = new Set<number>();
|
||||
const listeners = new Set<() => void>();
|
||||
const itemListeners = new Set<(item: AlertItem, isNew: boolean) => void>();
|
||||
let controller: AbortController | null = null;
|
||||
|
||||
const emit = () => {
|
||||
for (const listener of listeners) listener();
|
||||
};
|
||||
|
||||
const setUnread = (count: number) => {
|
||||
if (count === unread) return;
|
||||
unread = Math.max(0, count);
|
||||
emit();
|
||||
};
|
||||
|
||||
const onItem = (item: AlertItem) => {
|
||||
const isNew = !seen.has(item.id);
|
||||
seen.add(item.id);
|
||||
version++;
|
||||
emit();
|
||||
for (const listener of itemListeners) listener(item, isNew);
|
||||
};
|
||||
|
||||
const wait = (ms: number, signal: AbortSignal) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(resolve, ms);
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
|
||||
async function run(signal: AbortSignal) {
|
||||
while (!signal.aborted) {
|
||||
try {
|
||||
const response = await deps.connect({
|
||||
headers: { Accept: "text/event-stream" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok || !response.body) throw new Error("stream failed");
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
while (!signal.aborted) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buffer = parseServerSentEvents(
|
||||
buffer + decoder.decode(value, { stream: true }),
|
||||
(event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (event.event === "unread") setUnread(Number(data.count));
|
||||
else if (event.event === "item") onItem(data as AlertItem);
|
||||
} catch {
|
||||
// A malformed event is skipped; the next one carries the count.
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Falls through to a poll and a retry.
|
||||
}
|
||||
if (signal.aborted) return;
|
||||
try {
|
||||
setUnread(await deps.loadUnread());
|
||||
} catch {
|
||||
// Still offline.
|
||||
}
|
||||
await wait(retryMs, signal);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
start(): void {
|
||||
if (controller) return;
|
||||
controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
void run(signal);
|
||||
// A proxy that buffers the stream would hold the count back forever.
|
||||
void (async () => {
|
||||
while (!signal.aborted) {
|
||||
await wait(pollMs, signal);
|
||||
if (signal.aborted) return;
|
||||
try {
|
||||
setUnread(await deps.loadUnread());
|
||||
} catch {
|
||||
// Offline; the stream loop retries.
|
||||
}
|
||||
}
|
||||
})();
|
||||
},
|
||||
|
||||
stop(): void {
|
||||
controller?.abort();
|
||||
controller = null;
|
||||
},
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
|
||||
/** Every alert that arrives while the app is open, and whether it is new. */
|
||||
onItem(listener: (item: AlertItem, isNew: boolean) => void): () => void {
|
||||
itemListeners.add(listener);
|
||||
return () => {
|
||||
itemListeners.delete(listener);
|
||||
};
|
||||
},
|
||||
|
||||
unread: () => unread,
|
||||
/** Bumps whenever an alert arrives, so open lists know to reload. */
|
||||
version: () => version,
|
||||
setUnread,
|
||||
};
|
||||
}
|
||||
|
||||
export type AlertsStore = ReturnType<typeof createAlertsStore>;
|
||||
|
||||
export function useUnreadCount(store: AlertsStore): number {
|
||||
return useSyncExternalStore(store.subscribe, store.unread, store.unread);
|
||||
}
|
||||
|
||||
export function useAlertsVersion(store: AlertsStore): number {
|
||||
return useSyncExternalStore(store.subscribe, store.version, store.version);
|
||||
}
|
||||
Reference in new issue
Block a user