From e1f2cc975dbe52f307fb46ffc2ea1360323ac05e Mon Sep 17 00:00:00 2001 From: LukeGus Date: Wed, 30 Sep 2026 16:21:22 -0500 Subject: [PATCH] feat: restore the alert feed homepage widget --- packages/plugin-sdk/src/testing.ts | 9 + plugins/alerts/locales/en.json | 8 + plugins/alerts/manifest.json | 3 +- .../alerts/src/frontend/AlertFeedWidget.tsx | 197 ++++++++++++++++++ plugins/alerts/src/frontend/index.tsx | 26 +++ .../alerts/tests/frontend/activate.test.tsx | 47 +++++ plugins/homepage/locales/en.json | 4 +- .../src/frontend/dialogs/AddWidgetMenu.tsx | 14 +- plugins/homepage/src/frontend/types.ts | 2 + .../src/frontend/widgets/WidgetRegistry.ts | 1 + src/ui/plugin-host/testing-host.tsx | 13 ++ 11 files changed, 319 insertions(+), 5 deletions(-) create mode 100644 plugins/alerts/src/frontend/AlertFeedWidget.tsx diff --git a/packages/plugin-sdk/src/testing.ts b/packages/plugin-sdk/src/testing.ts index 04f606147..67b041562 100644 --- a/packages/plugin-sdk/src/testing.ts +++ b/packages/plugin-sdk/src/testing.ts @@ -2104,6 +2104,8 @@ export interface RenderedPluginApp { hostProtocols: () => string[]; hostEditorSections: () => string[]; dashboardCards: () => string[]; + /** Ids this plugin added to an extension point. */ + extensions: (pointId: string) => string[]; settingsComponents: () => string[]; slot: (slotId: string) => string[]; actions: () => string[]; @@ -2133,6 +2135,13 @@ export interface RenderedPluginApp { renderTab: (type: string, props?: Record) => HTMLElement; renderPanel: (id: string, props?: Record) => HTMLElement; renderDashboardCard: (id: string) => HTMLElement; + /** Renders one component of a contributed extension, "view" by default. */ + renderExtension: ( + pointId: string, + id: string, + component?: string, + props?: Record, + ) => HTMLElement; renderHostEditorSection: ( id: string, props?: Record, diff --git a/plugins/alerts/locales/en.json b/plugins/alerts/locales/en.json index fe8ebdadd..9d7514f0d 100644 --- a/plugins/alerts/locales/en.json +++ b/plugins/alerts/locales/en.json @@ -160,5 +160,13 @@ "save": "Save", "saving": "Saving...", "delete": "Delete" + }, + "widget": { + "name": "Alert Feed", + "description": "Your latest alerts, with a quick way to mark them read", + "maxItems": "Max items", + "showRead": "Show read alerts", + "allClear": "All clear", + "loading": "Loading..." } } diff --git a/plugins/alerts/manifest.json b/plugins/alerts/manifest.json index 696e6cb9a..c96c97764 100644 --- a/plugins/alerts/manifest.json +++ b/plugins/alerts/manifest.json @@ -141,7 +141,8 @@ ] } ] - } + }, + "uses": ["homepage.widgets"] }, "locales": "locales", "platforms": ["linux", "win32", "darwin"] diff --git a/plugins/alerts/src/frontend/AlertFeedWidget.tsx b/plugins/alerts/src/frontend/AlertFeedWidget.tsx new file mode 100644 index 000000000..f56ba881a --- /dev/null +++ b/plugins/alerts/src/frontend/AlertFeedWidget.tsx @@ -0,0 +1,197 @@ +import { useEffect, useState } from "react"; +import { + AlertCircle, + AlertTriangle, + Bell, + Check, + CheckCircle, + Info, +} from "lucide-react"; +import { useTranslation } from "@termix/plugin-sdk/frontend"; +import { Input } from "@termix/plugin-sdk/ui"; +import type { AlertItem } from "../types"; +import type { AlertsApi } from "./api"; +import { timeAgo } from "./format"; +import { useAlertsVersion, type AlertsStore } from "./store"; + +export interface AlertFeedConfig { + maxItems: number; + showRead: boolean; + /** 2.8 name for showRead, still found in saved homepages. */ + showAcknowledged?: boolean; +} + +export const DEFAULT_ALERT_FEED_CONFIG: AlertFeedConfig = { + maxItems: 10, + showRead: false, +}; + +function SeverityIcon({ severity }: { severity: AlertItem["severity"] }) { + if (severity === "critical") + return ; + if (severity === "warning") + return ; + if (severity === "success") + return ; + return ; +} + +export function showsRead(config: Partial): boolean { + return config.showRead ?? config.showAcknowledged ?? false; +} + +export function createAlertFeedWidget(api: AlertsApi, store: AlertsStore) { + function AlertFeedWidget({ + widget, + config, + }: { + widget: { title?: string | null }; + config: AlertFeedConfig; + }) { + const { t, language } = useTranslation(); + const version = useAlertsVersion(store); + const maxItems = Math.max(1, config.maxItems || 10); + const showRead = showsRead(config); + const [items, setItems] = useState(null); + + useEffect(() => { + let cancelled = false; + api + .items({ unread: showRead ? undefined : true, limit: maxItems }) + .then((result) => { + if (!cancelled) setItems(result.items.slice(0, maxItems)); + }) + .catch(() => { + if (!cancelled) setItems([]); + }); + return () => { + cancelled = true; + }; + }, [maxItems, showRead, version]); + + const markRead = async (id: number) => { + try { + await api.setRead([id]); + setItems((prev) => + (prev ?? []).flatMap((item) => + item.id !== id + ? [item] + : showRead + ? [{ ...item, readAt: new Date().toISOString() }] + : [], + ), + ); + } catch { + // the next reload shows the real state + } + }; + + if (items === null) { + return ( +
+ {t("widget.loading")} +
+ ); + } + + return ( +
+
+ + {widget.title || t("widget.name")} +
+ {items.length === 0 ? ( +
+ + + {t("widget.allClear")} + +
+ ) : ( +
+ {items.map((item) => ( +
+ +
+ + {item.title} + + + {timeAgo(item.createdAt, language)} + +
+ {!item.readAt && ( + + )} +
+ ))} +
+ )} +
+ ); + } + return AlertFeedWidget; +} + +export function AlertFeedEditForm({ + config, + onChange, +}: { + config: AlertFeedConfig; + onChange: (config: AlertFeedConfig) => void; +}) { + const { t } = useTranslation(); + return ( +
+
+ + + onChange({ + ...config, + maxItems: Math.min(50, Math.max(1, Number(e.target.value))), + }) + } + className="h-8 text-xs" + /> +
+ +
+ ); +} + +/** The widget's name in the homepage add menu, in this plugin's locale. */ +export function AlertFeedLabel({ part }: { part: "name" | "description" }) { + const { t } = useTranslation(); + return <>{t(part === "name" ? "widget.name" : "widget.description")}; +} diff --git a/plugins/alerts/src/frontend/index.tsx b/plugins/alerts/src/frontend/index.tsx index d649fdb19..9b447d9e4 100644 --- a/plugins/alerts/src/frontend/index.tsx +++ b/plugins/alerts/src/frontend/index.tsx @@ -10,6 +10,16 @@ import { AlertsView } from "./AlertsView"; import { createSectionRequests } from "./sections"; import { InboxView } from "./InboxView"; import { createAlertsStore, useUnreadCount } from "./store"; +import { createAlertsApi } from "./api"; +import { + AlertFeedEditForm, + AlertFeedLabel, + DEFAULT_ALERT_FEED_CONFIG, + createAlertFeedWidget, +} from "./AlertFeedWidget"; + +// The homepage grid step, in pixels. +const GRID_SIZE = 30; const VIEW_ID = "alerts"; @@ -71,6 +81,22 @@ export function activate(app: TermixApp): void { component: Toaster as ComponentType>, }); + app.registerExtension("homepage.widgets", { + id: "alert_feed", + name: "Alert Feed", + description: "Your latest alerts", + category: "monitoring", + icon: , + defaultConfig: DEFAULT_ALERT_FEED_CONFIG, + defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 10 }, + minSize: { w: GRID_SIZE * 6, h: GRID_SIZE * 5 }, + components: { + view: createAlertFeedWidget(createAlertsApi(app.api), store) as never, + editForm: AlertFeedEditForm as never, + label: AlertFeedLabel as never, + }, + }); + app.registerAction("alerts.open", () => openTab()); app.registerAction("alerts.openChannels", () => { sections.request("channels"); diff --git a/plugins/alerts/tests/frontend/activate.test.tsx b/plugins/alerts/tests/frontend/activate.test.tsx index 897af3fb1..d6fd726b9 100644 --- a/plugins/alerts/tests/frontend/activate.test.tsx +++ b/plugins/alerts/tests/frontend/activate.test.tsx @@ -148,3 +148,50 @@ describe("the alerts panel", () => { ); }); }); + +describe("the alert feed homepage widget", () => { + it("registers under the 2.8 widget id", async () => { + rendered = await renderWithApp(plugin, { manifest, locales }); + expect(rendered.registered.extensions("homepage.widgets")).toEqual([ + "alert_feed", + ]); + }); + + it("shows unread alerts and marks one read", async () => { + const { api, calls } = stubApi(); + rendered = await renderWithApp(plugin, { manifest, locales, api }); + const view = rendered.renderExtension( + "homepage.widgets", + "alert_feed", + "view", + { + widget: { title: null }, + config: { maxItems: 5, showRead: false }, + }, + ); + await waitFor(() => + expect(view.textContent).toContain("Certificate renewal failed"), + ); + expect(calls).toContainEqual(["GET", "/items?unread=true&limit=5"]); + fireEvent.click(view.querySelector('[title="Mark as read"]')!); + await waitFor(() => + expect(calls).toContainEqual([ + "POST", + "/items/read", + { ids: [2], read: true }, + ]), + ); + }); + + it("reads the 2.8 showAcknowledged option", async () => { + const { api, calls } = stubApi(); + rendered = await renderWithApp(plugin, { manifest, locales, api }); + rendered.renderExtension("homepage.widgets", "alert_feed", "view", { + widget: {}, + config: { maxItems: 3, showAcknowledged: true }, + }); + await waitFor(() => + expect(calls).toContainEqual(["GET", "/items?limit=3"]), + ); + }); +}); diff --git a/plugins/homepage/locales/en.json b/plugins/homepage/locales/en.json index 272eaa126..760b3a445 100644 --- a/plugins/homepage/locales/en.json +++ b/plugins/homepage/locales/en.json @@ -18,6 +18,7 @@ "homepage": { "title": "Homepage", "addWidget": "Add Widget", + "noWidgetsInCategory": "No widgets in this category", "editWidget": "Edit Widget", "zoomIn": "Zoom In", "zoomOut": "Zoom Out", @@ -168,8 +169,7 @@ "noServices": "No services set up", "configureLocation": "Set a location in the widget settings", "bannerTextPlaceholder": "Banner text", - "serviceNamePlaceholder": "My service", - "noWidgetsInCategory": "No widgets in this category" + "serviceNamePlaceholder": "My service" }, "permissions": { "use": { diff --git a/plugins/homepage/src/frontend/dialogs/AddWidgetMenu.tsx b/plugins/homepage/src/frontend/dialogs/AddWidgetMenu.tsx index 20c027b4a..53e1d8773 100644 --- a/plugins/homepage/src/frontend/dialogs/AddWidgetMenu.tsx +++ b/plugins/homepage/src/frontend/dialogs/AddWidgetMenu.tsx @@ -136,9 +136,19 @@ export function AddWidgetMenu({ state, onAdd, onClose }: AddWidgetMenuProps) { > {type.icon}
- {type.name} + + {type.labelComponent ? ( + + ) : ( + type.name + )} + - {type.description} + {type.labelComponent ? ( + + ) : ( + type.description + )}
diff --git a/plugins/homepage/src/frontend/types.ts b/plugins/homepage/src/frontend/types.ts index bcf97fd37..77cc6dd43 100644 --- a/plugins/homepage/src/frontend/types.ts +++ b/plugins/homepage/src/frontend/types.ts @@ -365,6 +365,8 @@ export interface WidgetTypeDefinition> { minSize: { w: number; h: number }; component: React.ComponentType>; editFormComponent?: React.ComponentType>; + /** Renders the translated name or description in the add menu. */ + labelComponent?: React.ComponentType<{ part: "name" | "description" }>; } export interface DragState { diff --git a/plugins/homepage/src/frontend/widgets/WidgetRegistry.ts b/plugins/homepage/src/frontend/widgets/WidgetRegistry.ts index 033b263f0..92f5f3e73 100644 --- a/plugins/homepage/src/frontend/widgets/WidgetRegistry.ts +++ b/plugins/homepage/src/frontend/widgets/WidgetRegistry.ts @@ -49,6 +49,7 @@ function toDefinition( ...(rest as unknown as WidgetTypeDefinition), component: view, editFormComponent: components?.editForm, + labelComponent: components?.label, }; } diff --git a/src/ui/plugin-host/testing-host.tsx b/src/ui/plugin-host/testing-host.tsx index 1ac73791f..c7f997c85 100644 --- a/src/ui/plugin-host/testing-host.tsx +++ b/src/ui/plugin-host/testing-host.tsx @@ -71,6 +71,7 @@ import { } from "./shell-bridge"; import { PluginViewPlaceholder } from "./PluginViewPlaceholder"; import { resolveLayoutTabTarget } from "@/shell/shell-layout"; +import { getExtension, listExtensions } from "./extension-registry"; import type { WorkspaceTabSnapshot } from "@/types/ui-types"; function recordingShell(calls: ShellCall[]): TabShellCallbacks { @@ -236,6 +237,8 @@ export async function renderPlugin( mine(hostEditorSectionList()).map((section) => section.id), dashboardCards: () => mine(registeredDashboardCardList()).map((card) => card.id), + extensions: (pointId) => + mine(listExtensions(pointId)).map((extension) => extension.id), settingsComponents: () => listSettingsComponents() .filter((key) => key.startsWith(`${pluginId}:`)) @@ -318,6 +321,16 @@ export async function renderPlugin( ); }, + renderExtension(pointId, id, component = "view", props = {}) { + const extension = getExtension(pointId, id); + const Component = + extension?.pluginId === pluginId + ? extension.components?.[component] + : undefined; + if (!Component) missing(`an extension "${component}" component`, id); + return wrap(); + }, + renderDashboardCard(id) { const card = getRegisteredDashboardCard(id) ?? missing("a card", id); const Component = card.component;