Skip to content

Commit 7928c00

Browse files
committed
banner notifications
1 parent d027638 commit 7928c00

2 files changed

Lines changed: 170 additions & 0 deletions

File tree

packages/admin-ui/src/pages-new/ai/AiLayout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { NexusPage } from "./nexus/NexusPage";
1010
import { storeRelativePath } from "./store/data";
1111
import { packagesRelativePath } from "./packages/data";
1212
import { nexusRelativePath } from "./nexus/data";
13+
import { BannerNotifications } from "../home/BannerNotifications";
1314

1415
/* ── Navigation items ───────────────────────────────────────────────── */
1516

@@ -24,6 +25,7 @@ const navItems: NavItem[] = [
2425
export function AiLayout() {
2526
return (
2627
<SectionLayout sectionLabel="AI" basePath="/ai" navItems={navItems}>
28+
<BannerNotifications />
2729
<Routes>
2830
<Route index element={<Navigate to={packagesRelativePath} replace />} />
2931
<Route path="packages" element={<PackagesPage />} />
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import React, { useEffect, useMemo, useState } from "react";
2+
import { NavLink } from "react-router-dom";
3+
import ReactMarkdown from "react-markdown";
4+
import { api, useApi } from "api";
5+
import { Notification, Priority } from "@dappnode/types";
6+
import { dappmanagerAliases, externalUrlProps } from "params";
7+
import { resolveDappnodeUrl } from "utils/resolveDappnodeUrl";
8+
import { X, ChevronDown, ChevronUp } from "lucide-react";
9+
import { AlertDescription } from "components/primitives/alert";
10+
import { Button } from "components/primitives/button";
11+
import { Collapsible, CollapsibleContent } from "components/primitives/collapsible";
12+
import { cn } from "lib/utils";
13+
14+
const NUM_BANNERS_SHOWN = 3;
15+
16+
/** Priority-based background + text color classes */
17+
const priorityStyles: Record<Priority, string> = {
18+
[Priority.low]: "tw:bg-muted tw:text-muted-foreground",
19+
[Priority.medium]: "tw:bg-primary/10 tw:text-primary",
20+
[Priority.high]: "tw:bg-caution/15 tw:text-caution-foreground tw:dark:text-caution tw:dark:bg-caution/20",
21+
[Priority.critical]: "tw:bg-destructive/10 tw:text-destructive tw:dark:bg-destructive/20"
22+
};
23+
24+
/** Map Priority → CTA button variant */
25+
const priorityButtonVariant: Record<Priority, "default" | "outline" | "destructive"> = {
26+
[Priority.low]: "default",
27+
[Priority.medium]: "default",
28+
[Priority.high]: "outline",
29+
[Priority.critical]: "destructive"
30+
};
31+
32+
/**
33+
* Filters notifications:
34+
* 1. Removes entries with errors
35+
* 2. Deduplicates by correlationId, keeping the most recent
36+
* 3. Keeps only triggered (not resolved) and unseen
37+
* 4. Sorts by priority (critical → low)
38+
*/
39+
function filterNotifications(notifications: Notification[]): Notification[] {
40+
const priorityOrder = [Priority.critical, Priority.high, Priority.medium, Priority.low];
41+
const map = new Map<string, Notification>();
42+
43+
notifications
44+
.filter((n) => !n.errors)
45+
.forEach((n) => {
46+
const existing = map.get(n.correlationId);
47+
if (!existing || new Date(n.timestamp) > new Date(existing.timestamp)) {
48+
map.set(n.correlationId, n);
49+
}
50+
});
51+
52+
return Array.from(map.values())
53+
.filter((n) => n.status === "triggered")
54+
.filter((n) => !n.seen)
55+
.sort((a, b) => priorityOrder.indexOf(a.priority) - priorityOrder.indexOf(b.priority));
56+
}
57+
58+
/* ── Main banner list ───────────────────────────────────────────────── */
59+
60+
export function BannerNotifications() {
61+
const [notifications, setNotifications] = useState<Notification[]>([]);
62+
63+
const oneMonthAgoTimestamp = useMemo(() => {
64+
const now = new Date();
65+
now.setMonth(now.getMonth() - 1);
66+
return Math.floor(now.getTime() / 1000);
67+
}, []);
68+
69+
const notificationsCall = useApi.notificationsGetBanner({ timestamp: oneMonthAgoTimestamp });
70+
71+
useEffect(() => {
72+
if (notificationsCall.data) {
73+
setNotifications(filterNotifications(notificationsCall.data));
74+
}
75+
}, [notificationsCall.data]);
76+
77+
// Revalidate every minute
78+
useEffect(() => {
79+
const interval = setInterval(() => notificationsCall.revalidate(), 60 * 1000);
80+
return () => clearInterval(interval);
81+
}, []);
82+
83+
if (!notifications.length) return null;
84+
85+
return (
86+
<div className="tw:flex tw:flex-col tw:gap-card tw:px-page-x tw:pt-page-y">
87+
{notifications.slice(0, NUM_BANNERS_SHOWN).map((n) => (
88+
<BannerNotificationCard
89+
key={n.id}
90+
notification={n}
91+
onClose={() => setNotifications((prev) => prev.filter((x) => x.id !== n.id))}
92+
/>
93+
))}
94+
</div>
95+
);
96+
}
97+
98+
/* ── Single banner card ─────────────────────────────────────────────── */
99+
100+
function BannerNotificationCard({ notification, onClose }: { notification: Notification; onClose: () => void }) {
101+
const defaultOpen = notification.priority === Priority.critical;
102+
const [open, setOpen] = useState(defaultOpen);
103+
104+
const handleClose = () => {
105+
api.notificationSetSeenByCorrelationID({ correlationId: notification.correlationId });
106+
onClose();
107+
};
108+
109+
const isExternalUrl =
110+
notification.callToAction && !dappmanagerAliases.some((alias) => notification.callToAction!.url.includes(alias));
111+
112+
const buttonVariant = priorityButtonVariant[notification.priority];
113+
114+
return (
115+
<Collapsible open={open} onOpenChange={setOpen}>
116+
<div
117+
className={cn(
118+
"tw:relative tw:rounded-lg tw:border tw:px-3 tw:py-2.5 tw:pr-10 tw:text-sm tw:shadow-sm tw:cursor-pointer",
119+
priorityStyles[notification.priority]
120+
)}
121+
onClick={() => setOpen((o) => !o)}
122+
>
123+
{/* Close button (top-right) */}
124+
<div className="tw:absolute tw:top-2 tw:right-2">
125+
<Button
126+
variant="ghost"
127+
size="icon-xs"
128+
onClick={(e) => {
129+
e.stopPropagation();
130+
handleClose();
131+
}}
132+
aria-label="Dismiss notification"
133+
>
134+
<X />
135+
</Button>
136+
</div>
137+
138+
{/* Title row */}
139+
<div className="tw:flex tw:items-center tw:gap-1.5 tw:font-medium">
140+
{notification.title}
141+
{open ? <ChevronUp className="tw:size-3.5" /> : <ChevronDown className="tw:size-3.5" />}
142+
</div>
143+
144+
{/* Collapsible body */}
145+
<CollapsibleContent>
146+
<AlertDescription className="tw:mt-1.5">
147+
<ReactMarkdown
148+
children={notification.body}
149+
components={{ a: ({ ...props }) => <a target="_blank" rel="noopener noreferrer" {...props} /> }}
150+
/>
151+
152+
{notification.callToAction && (
153+
<NavLink
154+
to={resolveDappnodeUrl(notification.callToAction.url, window.location)}
155+
{...(isExternalUrl ? externalUrlProps : {})}
156+
className="tw:inline-block tw:mt-2"
157+
>
158+
<Button variant={buttonVariant} size="sm">
159+
{notification.callToAction.title}
160+
</Button>
161+
</NavLink>
162+
)}
163+
</AlertDescription>
164+
</CollapsibleContent>
165+
</div>
166+
</Collapsible>
167+
);
168+
}

0 commit comments

Comments
 (0)