Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 3 additions & 8 deletions create-a-container/client/src/app/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@ import {
DropdownItem,
DropdownSeparator,
SidebarMobileToggle,
useCommandPalette,
useThemeContext,
} from '@mieweb/ui';
import { LogOut, Moon, Search, Settings, Sun } from 'lucide-react';
import { LogOut, Moon, Settings, Sun } from 'lucide-react';
import { useNavigate } from 'react-router';
import { useLogoutMutation, useSession } from '@/lib/auth';
import { NotificationsBell } from './NotificationsBell';

function initialsOf(name: string | undefined) {
if (!name) return '?';
Expand All @@ -27,7 +27,6 @@ function initialsOf(name: string | undefined) {
export function AppTopHeader() {
const { data: session } = useSession();
const { resolvedTheme, setTheme } = useThemeContext();
const palette = useCommandPalette();
const logout = useLogoutMutation();
const navigate = useNavigate();

Expand All @@ -48,11 +47,7 @@ export function AppTopHeader() {
</AppHeaderSection>
<AppHeaderSection align="right">
<AppHeaderActions>
<AppHeaderIconButton
icon={<Search className="size-4" />}
label="Search (⌘K)"
onClick={palette.open}
/>
<NotificationsBell />
<AppHeaderIconButton
icon={isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
label={isDark ? 'Switch to light theme' : 'Switch to dark theme'}
Expand Down
110 changes: 110 additions & 0 deletions create-a-container/client/src/app/NotificationsBell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* Notification bell — replaces the (unused) header search button. Polls the
* owner-scoped notification feed, shows an unread badge, and renders the shared
* @mieweb/ui NotificationCenter (list, empty/loading states, mark-(all-)read)
* inside a dropdown.
*
* The feed is returned unacked-first + newest-first, so the unread count is
* derived directly from the fetched rows (capped by the server's page size —
* displayed as "N+" when saturated).
*/
import { useMemo } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
AppHeaderIconButton,
Dropdown,
NotificationCenter,
type Notification as UINotification,
} from '@mieweb/ui';
import { Bell } from 'lucide-react';
import { keys, queries } from '@/lib/queries';
import type { AppNotification, NotificationSeverity } from '@/lib/types';

// The server caps the list; keep this in sync with the API default (20).
const PAGE_SIZE = 20;

// Map our event severity onto the shared component's icon `type` and `priority`.
const SEVERITY_TYPE: Record<NotificationSeverity, UINotification['type']> = {
info: 'system',
warning: 'alert',
critical: 'alert',
};
const SEVERITY_PRIORITY: Record<NotificationSeverity, UINotification['priority']> = {
info: 'normal',
warning: 'high',
critical: 'urgent',
};

/** A concise title, e.g. "critical · freeze — pve1 · CT 392". */
function titleFor(n: AppNotification): string {
const lead = [n.severity, n.action].filter(Boolean).join(' · ');
const target = [n.node, n.ctid ? `CT ${n.ctid}` : null].filter(Boolean).join(' · ');
return target ? `${lead} — ${target}` : lead;
}

/** Adapt an API notification to the @mieweb/ui NotificationCenter shape. */
function toUINotification(n: AppNotification): UINotification {
return {
id: String(n.id),
type: SEVERITY_TYPE[n.severity],
title: titleFor(n),
message: n.message,
timestamp: n.eventAt || n.createdAt,
isRead: !!n.acknowledgedAt,
senderName: n.source,
priority: SEVERITY_PRIORITY[n.severity],
};
}

export function NotificationsBell() {
const qc = useQueryClient();

const { data, isLoading } = useQuery({
queryKey: keys.notifications(),
queryFn: queries.listNotifications,
refetchInterval: 30000,
});

const notifications = useMemo(() => data ?? [], [data]);
const uiNotifications = useMemo(() => notifications.map(toUINotification), [notifications]);
const unreadCount = notifications.filter((n) => !n.acknowledgedAt).length;

const invalidate = () => qc.invalidateQueries({ queryKey: keys.notifications() });

const ackOne = useMutation({
mutationFn: (id: number) => queries.ackNotification(id),
onSuccess: invalidate,
});
const ackAll = useMutation({
mutationFn: () => queries.ackAllNotifications(),
onSuccess: invalidate,
});

return (
<Dropdown
placement="bottom-end"
width={380}
trigger={
<AppHeaderIconButton
icon={<Bell className="size-4" />}
label={unreadCount > 0 ? `Notifications (${unreadCount} unread)` : 'Notifications'}
badge={unreadCount > 0 ? unreadCount : undefined}
/>
}
>
<NotificationCenter
notifications={uiNotifications}
isLoading={isLoading}
maxVisible={PAGE_SIZE}
emptyMessage="No notifications."
onMarkRead={(id) => ackOne.mutate(Number(id))}
onMarkAllRead={() => ackAll.mutate()}
// Sits inside the Dropdown's own panel; drop the component's border and
// shadow so it renders flush rather than as a card-within-a-card.
// (className is appended after the component's base classes, so these
// utilities win.)
className="!border-0 !shadow-none"
/>
</Dropdown>
);
}
10 changes: 10 additions & 0 deletions create-a-container/client/src/lib/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { api } from './api';
import type {
Agent,
ApiKey,
AppNotification,
Container,
ContainerMetadata,
ContainerNewBootstrap,
Expand Down Expand Up @@ -37,6 +38,7 @@ export const keys = {
externalDomains: () => ['external-domains'] as const,
externalDomain: (id: number | string) => ['external-domains', String(id)] as const,
agents: () => ['agents'] as const,
notifications: () => ['notifications'] as const,
users: () => ['users'] as const,
user: (uid: number | string) => ['users', String(uid)] as const,
groups: () => ['groups'] as const,
Expand Down Expand Up @@ -65,6 +67,14 @@ export const queries = {
// Agents
listAgents: () => api.get<Agent[]>('/api/v1/agents'),

// Notifications (owner-scoped). The list is unacked-first + newest-first, so
// the bell derives its unread badge from the returned rows.
listNotifications: () => api.get<AppNotification[]>('/api/v1/notifications'),
ackNotification: (id: number) =>
api.post<AppNotification>(`/api/v1/notifications/${id}/ack`),
ackAllNotifications: () =>
api.post<{ acknowledged: number }>('/api/v1/notifications/all/ack'),

// Containers
listContainers: (
siteId: number | string,
Expand Down
23 changes: 23 additions & 0 deletions create-a-container/client/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,29 @@ export interface Agent {
secondsSinceCheckin: number | null;
}

export type NotificationSeverity = 'info' | 'warning' | 'critical';

/**
* A node-side event surfaced in the notification bell. Named AppNotification to
* avoid clashing with the DOM's global `Notification` type.
*/
export interface AppNotification {
id: number;
source: string;
severity: NotificationSeverity;
node: string | null;
ctid: string | null;
owner: string | null;
action: string | null;
message: string;
evidence: Record<string, unknown> | null;
eventAt: string | null;
acknowledgedAt: string | null;
acknowledgedBy: string | null;
createdAt: string;
updatedAt: string;
}

export interface ExternalDomain {
id: number;
name: string;
Expand Down
91 changes: 91 additions & 0 deletions create-a-container/docs/notification-webhook.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Notification webhook contract

Node-side tools (e.g. `lxc-oomd`) report events to the `create-a-container`
manager over an authenticated HTTP webhook. The manager persists each event and
surfaces it to the owning user in the UI notification bell.

This is the integration target for the `lxc-oomd` packaged hook script (see
issue #431 / #434).

## Endpoint

```
POST /api/v1/notifications
Authorization: Bearer <admin-api-key>
Content-Type: application/json
```

Authentication is an **admin API key** (mint one on the API Keys page as an
admin user). Non-admin keys receive `403`; missing/invalid keys receive `401`.

The read/acknowledge endpoints (`GET /api/v1/notifications`,
`POST /api/v1/notifications/all/ack`, `POST /api/v1/notifications/{id}/ack`) are
owner-scoped and used by the web UI; the hook script only needs the `POST`
above.

## Payload

```jsonc
{
"source": "lxc-oomd", // required. Emitter name.
"severity": "critical", // required. one of: info | warning | critical
"node": "opensource-phxdc-pve1", // optional. hypervisor node name
"ctid": 392, // optional. container id (int or string)
"owner": "mbachelder", // optional. Users.uid; see "Owner resolution"
"action": "freeze", // optional. freeze | kill | bump | quarantine | detect | ...
"message": "CT 392 frozen: memory PSI full avg10=83 for 45s", // required
"evidence": { // optional. free-form structured detail
"psiFullAvg10": 83.6,
"refaultRate": "...",
"topProcs": ["..."]
},
"ts": 1771234560 // optional. epoch seconds; stored as eventAt
}
```

Field notes:

- **`severity`** must be one of `info`, `warning`, `critical`. Anything else is
a `400`.
- **`ctid`** accepts a number or string and is stored as a string (matching the
hypervisor container id).
- **`ts`** is epoch **seconds** (not milliseconds); the manager records it as
`eventAt`. When omitted, `eventAt` is null and only the server receipt time
(`createdAt`) is available.
- **`evidence`** is stored verbatim as JSON; put anything not covered by a
first-class field here.
- Unknown top-level keys are ignored.

## Owner resolution

Visibility in the UI is per-owner: a user sees only notifications whose `owner`
matches their username (`Users.uid`).

- If the payload includes `owner`, it is used as-is.
- If `owner` is omitted, the manager resolves it best-effort from `node` +
`ctid` via the Containers table (node name → node, then `containerId` on that
node → owning `username`).
- If resolution fails (unknown node/ctid), the event is **still stored** with a
null owner. It will not appear in any user's bell. Prefer sending an explicit
`owner`, or a resolvable `node` + `ctid`, when the event should reach a human.

## Example

```sh
curl -sS -X POST https://<manager-host>/api/v1/notifications \
-H "Authorization: Bearer $ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"source": "lxc-oomd",
"severity": "critical",
"node": "opensource-phxdc-pve1",
"ctid": 392,
"action": "freeze",
"message": "CT 392 frozen: memory PSI full avg10=83 for 45s",
"evidence": { "psiFullAvg10": 83.6 },
"ts": '"$(date +%s)"'
}'
```

A `201` response returns the persisted notification (including the assigned
`id` and the resolved `owner`) so the caller can log it.
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
'use strict';

/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('Notifications', {
id: { allowNull: false, autoIncrement: true, primaryKey: true, type: Sequelize.INTEGER },
// Emitter of the event, e.g. 'lxc-oomd'.
source: { type: Sequelize.STRING, allowNull: false },
// 'info' | 'warning' | 'critical' (validated in the app layer).
severity: { type: Sequelize.STRING, allowNull: false },
// Hypervisor node name the event originated on.
node: { type: Sequelize.STRING, allowNull: true },
// Container id on the hypervisor (CTID/VMID). STRING to match
// Containers.containerId, which was widened to a string.
ctid: { type: Sequelize.STRING, allowNull: true },
// Owning user (Users.uid). Drives per-user UI visibility. Resolved from
// node+ctid at ingest time when the payload omits it.
owner: { type: Sequelize.STRING, allowNull: true },
// 'freeze' | 'kill' | 'bump' | 'quarantine' | 'detect' | ... (free-form).
action: { type: Sequelize.STRING, allowNull: true },
message: { type: Sequelize.TEXT, allowNull: false },
// Arbitrary structured evidence blob (PSI figures, top procs, ...).
evidence: { type: Sequelize.JSON, allowNull: true },
// When the event happened on the node (from the payload's `ts` epoch).
// Distinct from createdAt, which is when the manager recorded it.
eventAt: { type: Sequelize.DATE, allowNull: true },
// Ack state. NULL = unread/unacknowledged.
acknowledgedAt: { type: Sequelize.DATE, allowNull: true },
acknowledgedBy: { type: Sequelize.STRING, allowNull: true },
createdAt: { allowNull: false, type: Sequelize.DATE },
updatedAt: { allowNull: false, type: Sequelize.DATE },
});

// Owner-scoped, unacked-first listing is the hot path (the bell dropdown).
await queryInterface.addIndex('Notifications', ['owner', 'acknowledgedAt'], {
name: 'notifications_owner_acknowledged_at',
});
await queryInterface.addIndex('Notifications', ['createdAt'], {
name: 'notifications_created_at',
});
},

async down(queryInterface) {
await queryInterface.dropTable('Notifications');
},
};
39 changes: 39 additions & 0 deletions create-a-container/models/notification.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
'use strict';

const { Model } = require('sequelize');

module.exports = (sequelize, DataTypes) => {
class Notification extends Model {
static associate(/* models */) {
// Intentionally unassociated. `owner` is a loose reference to Users.uid
// and `node`/`ctid` loosely reference a Container; events must survive
// deletion of the container or user they describe, so no FK constraints.
}
}

Notification.init(
{
source: { type: DataTypes.STRING, allowNull: false },
severity: { type: DataTypes.STRING, allowNull: false },
node: { type: DataTypes.STRING, allowNull: true },
ctid: { type: DataTypes.STRING, allowNull: true },
owner: { type: DataTypes.STRING, allowNull: true },
action: { type: DataTypes.STRING, allowNull: true },
message: { type: DataTypes.TEXT, allowNull: false },
evidence: { type: DataTypes.JSON, allowNull: true },
eventAt: { type: DataTypes.DATE, allowNull: true },
acknowledgedAt: { type: DataTypes.DATE, allowNull: true },
acknowledgedBy: { type: DataTypes.STRING, allowNull: true },
},
{
sequelize,
modelName: 'Notification',
indexes: [
{ fields: ['owner', 'acknowledgedAt'], name: 'notifications_owner_acknowledged_at' },
{ fields: ['createdAt'], name: 'notifications_created_at' },
],
}
);

return Notification;
};
Loading
Loading