mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-01 16:15:24 +00:00
revert: drop the alert feed widget, the notification center replaces it
This commit is contained in:
1 parent
84c94bf9a6
commit
0d94ad5e5f
11 files changed
+5
-319
No files matched your search
@@ -2104,8 +2104,6 @@ export interface RenderedPluginApp {
|
|||||||
hostProtocols: () => string[];
|
hostProtocols: () => string[];
|
||||||
hostEditorSections: () => string[];
|
hostEditorSections: () => string[];
|
||||||
dashboardCards: () => string[];
|
dashboardCards: () => string[];
|
||||||
/** Ids this plugin added to an extension point. */
|
|
||||||
extensions: (pointId: string) => string[];
|
|
||||||
settingsComponents: () => string[];
|
settingsComponents: () => string[];
|
||||||
slot: (slotId: string) => string[];
|
slot: (slotId: string) => string[];
|
||||||
actions: () => string[];
|
actions: () => string[];
|
||||||
@@ -2135,13 +2133,6 @@ export interface RenderedPluginApp {
|
|||||||
renderTab: (type: string, props?: Record<string, unknown>) => HTMLElement;
|
renderTab: (type: string, props?: Record<string, unknown>) => HTMLElement;
|
||||||
renderPanel: (id: string, props?: Record<string, unknown>) => HTMLElement;
|
renderPanel: (id: string, props?: Record<string, unknown>) => HTMLElement;
|
||||||
renderDashboardCard: (id: string) => HTMLElement;
|
renderDashboardCard: (id: string) => HTMLElement;
|
||||||
/** Renders one component of a contributed extension, "view" by default. */
|
|
||||||
renderExtension: (
|
|
||||||
pointId: string,
|
|
||||||
id: string,
|
|
||||||
component?: string,
|
|
||||||
props?: Record<string, unknown>,
|
|
||||||
) => HTMLElement;
|
|
||||||
renderHostEditorSection: (
|
renderHostEditorSection: (
|
||||||
id: string,
|
id: string,
|
||||||
props?: Record<string, unknown>,
|
props?: Record<string, unknown>,
|
||||||
|
|||||||
@@ -160,13 +160,5 @@
|
|||||||
"save": "Save",
|
"save": "Save",
|
||||||
"saving": "Saving...",
|
"saving": "Saving...",
|
||||||
"delete": "Delete"
|
"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..."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,8 +141,7 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
}
|
||||||
"uses": ["homepage.widgets"]
|
|
||||||
},
|
},
|
||||||
"locales": "locales",
|
"locales": "locales",
|
||||||
"platforms": ["linux", "win32", "darwin"]
|
"platforms": ["linux", "win32", "darwin"]
|
||||||
|
|||||||
@@ -1,197 +0,0 @@
|
|||||||
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 <AlertCircle size={11} className="shrink-0 text-red-500" />;
|
|
||||||
if (severity === "warning")
|
|
||||||
return <AlertTriangle size={11} className="shrink-0 text-amber-500" />;
|
|
||||||
if (severity === "success")
|
|
||||||
return <CheckCircle size={11} className="shrink-0 text-green-500" />;
|
|
||||||
return <Info size={11} className="shrink-0 text-blue-400" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function showsRead(config: Partial<AlertFeedConfig>): 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<AlertItem[] | null>(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 (
|
|
||||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
|
||||||
{t("widget.loading")}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
|
||||||
<div className="flex items-center gap-1.5 px-2 py-1.5 border-b border-border/50 text-[11px] font-semibold text-muted-foreground">
|
|
||||||
<Bell size={11} />
|
|
||||||
<span className="truncate">{widget.title || t("widget.name")}</span>
|
|
||||||
</div>
|
|
||||||
{items.length === 0 ? (
|
|
||||||
<div className="flex flex-1 flex-col items-center justify-center gap-2">
|
|
||||||
<CheckCircle size={20} className="text-green-500" />
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{t("widget.allClear")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex-1 overflow-auto">
|
|
||||||
{items.map((item) => (
|
|
||||||
<div
|
|
||||||
key={item.id}
|
|
||||||
className={`flex items-start gap-2 px-2 py-1.5 border-b border-border/30 ${item.readAt ? "opacity-50" : ""}`}
|
|
||||||
>
|
|
||||||
<SeverityIcon severity={item.severity} />
|
|
||||||
<div className="flex flex-col min-w-0 flex-1">
|
|
||||||
<span className="text-[10px] font-semibold text-foreground truncate">
|
|
||||||
{item.title}
|
|
||||||
</span>
|
|
||||||
<span className="text-[9px] text-muted-foreground truncate">
|
|
||||||
{timeAgo(item.createdAt, language)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{!item.readAt && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
title={t("inbox.markRead")}
|
|
||||||
onClick={() => void markRead(item.id)}
|
|
||||||
className="shrink-0 p-0.5 text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<Check size={11} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return AlertFeedWidget;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AlertFeedEditForm({
|
|
||||||
config,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
config: AlertFeedConfig;
|
|
||||||
onChange: (config: AlertFeedConfig) => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label className="text-xs font-medium text-muted-foreground">
|
|
||||||
{t("widget.maxItems")}
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={50}
|
|
||||||
value={config.maxItems}
|
|
||||||
onChange={(e) =>
|
|
||||||
onChange({
|
|
||||||
...config,
|
|
||||||
maxItems: Math.min(50, Math.max(1, Number(e.target.value))),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="h-8 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={showsRead(config)}
|
|
||||||
onChange={(e) =>
|
|
||||||
onChange({
|
|
||||||
...config,
|
|
||||||
showRead: e.target.checked,
|
|
||||||
showAcknowledged: undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="accent-accent-brand"
|
|
||||||
/>
|
|
||||||
{t("widget.showRead")}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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")}</>;
|
|
||||||
}
|
|
||||||
@@ -10,16 +10,6 @@ import { AlertsView } from "./AlertsView";
|
|||||||
import { createSectionRequests } from "./sections";
|
import { createSectionRequests } from "./sections";
|
||||||
import { InboxView } from "./InboxView";
|
import { InboxView } from "./InboxView";
|
||||||
import { createAlertsStore, useUnreadCount } from "./store";
|
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";
|
const VIEW_ID = "alerts";
|
||||||
|
|
||||||
@@ -81,22 +71,6 @@ export function activate(app: TermixApp): void {
|
|||||||
component: Toaster as ComponentType<Record<string, unknown>>,
|
component: Toaster as ComponentType<Record<string, unknown>>,
|
||||||
});
|
});
|
||||||
|
|
||||||
app.registerExtension("homepage.widgets", {
|
|
||||||
id: "alert_feed",
|
|
||||||
name: "Alert Feed",
|
|
||||||
description: "Your latest alerts",
|
|
||||||
category: "monitoring",
|
|
||||||
icon: <Bell size={14} />,
|
|
||||||
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.open", () => openTab());
|
||||||
app.registerAction("alerts.openChannels", () => {
|
app.registerAction("alerts.openChannels", () => {
|
||||||
sections.request("channels");
|
sections.request("channels");
|
||||||
|
|||||||
@@ -148,50 +148,3 @@ 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"]),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -18,7 +18,6 @@
|
|||||||
"homepage": {
|
"homepage": {
|
||||||
"title": "Homepage",
|
"title": "Homepage",
|
||||||
"addWidget": "Add Widget",
|
"addWidget": "Add Widget",
|
||||||
"noWidgetsInCategory": "No widgets in this category",
|
|
||||||
"editWidget": "Edit Widget",
|
"editWidget": "Edit Widget",
|
||||||
"zoomIn": "Zoom In",
|
"zoomIn": "Zoom In",
|
||||||
"zoomOut": "Zoom Out",
|
"zoomOut": "Zoom Out",
|
||||||
@@ -169,7 +168,8 @@
|
|||||||
"noServices": "No services set up",
|
"noServices": "No services set up",
|
||||||
"configureLocation": "Set a location in the widget settings",
|
"configureLocation": "Set a location in the widget settings",
|
||||||
"bannerTextPlaceholder": "Banner text",
|
"bannerTextPlaceholder": "Banner text",
|
||||||
"serviceNamePlaceholder": "My service"
|
"serviceNamePlaceholder": "My service",
|
||||||
|
"noWidgetsInCategory": "No widgets in this category"
|
||||||
},
|
},
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"use": {
|
"use": {
|
||||||
|
|||||||
@@ -136,19 +136,9 @@ export function AddWidgetMenu({ state, onAdd, onClose }: AddWidgetMenuProps) {
|
|||||||
>
|
>
|
||||||
<span className="text-accent-brand shrink-0">{type.icon}</span>
|
<span className="text-accent-brand shrink-0">{type.icon}</span>
|
||||||
<div className="flex flex-col min-w-0">
|
<div className="flex flex-col min-w-0">
|
||||||
<span className="font-medium truncate">
|
<span className="font-medium truncate">{type.name}</span>
|
||||||
{type.labelComponent ? (
|
|
||||||
<type.labelComponent part="name" />
|
|
||||||
) : (
|
|
||||||
type.name
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] text-muted-foreground truncate">
|
<span className="text-[10px] text-muted-foreground truncate">
|
||||||
{type.labelComponent ? (
|
{type.description}
|
||||||
<type.labelComponent part="description" />
|
|
||||||
) : (
|
|
||||||
type.description
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -365,8 +365,6 @@ export interface WidgetTypeDefinition<C = Record<string, unknown>> {
|
|||||||
minSize: { w: number; h: number };
|
minSize: { w: number; h: number };
|
||||||
component: React.ComponentType<WidgetComponentProps<C>>;
|
component: React.ComponentType<WidgetComponentProps<C>>;
|
||||||
editFormComponent?: React.ComponentType<WidgetEditFormProps<C>>;
|
editFormComponent?: React.ComponentType<WidgetEditFormProps<C>>;
|
||||||
/** Renders the translated name or description in the add menu. */
|
|
||||||
labelComponent?: React.ComponentType<{ part: "name" | "description" }>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DragState {
|
export interface DragState {
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ function toDefinition(
|
|||||||
...(rest as unknown as WidgetTypeDefinition),
|
...(rest as unknown as WidgetTypeDefinition),
|
||||||
component: view,
|
component: view,
|
||||||
editFormComponent: components?.editForm,
|
editFormComponent: components?.editForm,
|
||||||
labelComponent: components?.label,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,6 @@ import {
|
|||||||
} from "./shell-bridge";
|
} from "./shell-bridge";
|
||||||
import { PluginViewPlaceholder } from "./PluginViewPlaceholder";
|
import { PluginViewPlaceholder } from "./PluginViewPlaceholder";
|
||||||
import { resolveLayoutTabTarget } from "@/shell/shell-layout";
|
import { resolveLayoutTabTarget } from "@/shell/shell-layout";
|
||||||
import { getExtension, listExtensions } from "./extension-registry";
|
|
||||||
import type { WorkspaceTabSnapshot } from "@/types/ui-types";
|
import type { WorkspaceTabSnapshot } from "@/types/ui-types";
|
||||||
|
|
||||||
function recordingShell(calls: ShellCall[]): TabShellCallbacks {
|
function recordingShell(calls: ShellCall[]): TabShellCallbacks {
|
||||||
@@ -237,8 +236,6 @@ export async function renderPlugin(
|
|||||||
mine(hostEditorSectionList()).map((section) => section.id),
|
mine(hostEditorSectionList()).map((section) => section.id),
|
||||||
dashboardCards: () =>
|
dashboardCards: () =>
|
||||||
mine(registeredDashboardCardList()).map((card) => card.id),
|
mine(registeredDashboardCardList()).map((card) => card.id),
|
||||||
extensions: (pointId) =>
|
|
||||||
mine(listExtensions(pointId)).map((extension) => extension.id),
|
|
||||||
settingsComponents: () =>
|
settingsComponents: () =>
|
||||||
listSettingsComponents()
|
listSettingsComponents()
|
||||||
.filter((key) => key.startsWith(`${pluginId}:`))
|
.filter((key) => key.startsWith(`${pluginId}:`))
|
||||||
@@ -321,16 +318,6 @@ 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(<Component {...props} />);
|
|
||||||
},
|
|
||||||
|
|
||||||
renderDashboardCard(id) {
|
renderDashboardCard(id) {
|
||||||
const card = getRegisteredDashboardCard(id) ?? missing("a card", id);
|
const card = getRegisteredDashboardCard(id) ?? missing("a card", id);
|
||||||
const Component = card.component;
|
const Component = card.component;
|
||||||
|
|||||||
Reference in new issue
Block a user