feat(nextcloud-status): Meldung in Tessera, Anleitung und Changelog

- Endpunkt für letzte Übergänge der abonnierten Clouds, globaler Melder im Portalrahmen (Desktop: Windows-Benachrichtigung)
- Anleitung für Anwender und Administration, Changelog

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 15:30:45 +02:00
parent 11a70c9ee9
commit 6c4bff6f6c
15 changed files with 542 additions and 8 deletions
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
import { Header } from '@/components/layout/header';
import { Sidebar } from '@/components/layout/sidebar';
import { CustomModuleFrameHost } from '@/components/modules/custom-module-frame-host';
import { NextcloudAlertNotifier } from '@/components/nextcloud-status/nextcloud-alert-notifier';
import { ReleaseNoticeHost } from '@/components/release-notice/release-notice-host';
import { ReminderNotifier } from '@/components/reminders/reminder-notifier';
import { installErrorBuffer } from '@/lib/error-buffer';
@@ -47,6 +48,10 @@ export function AppShell({ children }: { children: React.ReactNode }) {
Benachrichtigung auf jeder Portalseite ausloest — nicht nur, wenn die
Kachel auf dem Dashboard sichtbar ist. Rendert nichts. */}
<ReminderNotifier />
{/* Nextcloud-Status (quick-261002-kxc): Meldung bei Stoerung und
Wiederherstellung einer abonnierten Cloud, ebenfalls GLOBAL im Rahmen
(Windows-Benachrichtigung in der Desktop-App). Rendert nichts. */}
<NextcloudAlertNotifier />
{/* Eigene Module offen halten (30.09.2026): die <iframe>s leben hier im
Rahmen und ueberstehen so das Wegnavigieren; die Modulseite zeigt nur
einen Platzhalter, ueber den der Behaelter das aktive Modul legt. */}
@@ -0,0 +1,154 @@
import { act, render } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, values?: Record<string, string>) =>
values?.name ? `${key}:${values.name}` : key,
}));
vi.mock('@/lib/nextcloud-status-api', async () => {
const actual = await vi.importActual<typeof import('@/lib/nextcloud-status-api')>(
'@/lib/nextcloud-status-api',
);
return { NextcloudRequestError: actual.NextcloudRequestError, listAlerts: vi.fn() };
});
vi.mock('@/lib/reminder-notify', async () => {
const actual =
await vi.importActual<typeof import('@/lib/reminder-notify')>('@/lib/reminder-notify');
return { ...actual, showReminderNotification: vi.fn(async () => undefined) };
});
import { listAlerts, NextcloudRequestError } from '@/lib/nextcloud-status-api';
import { resetNotifiedMemoryForTests, showReminderNotification } from '@/lib/reminder-notify';
import { NextcloudAlertNotifier } from './nextcloud-alert-notifier';
const mockList = listAlerts as ReturnType<typeof vi.fn>;
const mockShow = showReminderNotification as ReturnType<typeof vi.fn>;
const mockFetch = vi.fn();
const NOW = new Date('2026-10-02T12:00:00.000Z');
function alert(over: Record<string, unknown> = {}) {
return {
instanceId: 'i1',
customerName: 'Kunde A',
baseUrl: 'https://cloud.a.de',
kind: 'down',
reason: 'unreachable',
changedAt: '2026-10-02T11:55:00.000Z',
...over,
};
}
function withAccess(has: boolean) {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: async () =>
has ? [{ slug: 'proxmox' }, { slug: 'nextcloud-status' }] : [{ slug: 'proxmox' }],
});
}
async function advance(ms: number) {
await act(async () => {
await vi.advanceTimersByTimeAsync(ms);
});
}
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
window.localStorage.clear();
resetNotifiedMemoryForTests();
mockList.mockReset();
mockShow.mockClear();
mockFetch.mockReset();
vi.stubGlobal('fetch', mockFetch);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe('NextcloudAlertNotifier', () => {
it('fragt ohne Modulzugriff die Meldungen nie ab', async () => {
withAccess(false);
render(<NextcloudAlertNotifier />);
await advance(0);
await advance(180_000);
expect(mockFetch).toHaveBeenCalled();
expect(mockList).not.toHaveBeenCalled();
expect(mockShow).not.toHaveBeenCalled();
});
it('mit Modulzugriff: eine Meldung je (Cloud, Zeitpunkt), nie doppelt ueber mehrere Abfragen', async () => {
withAccess(true);
mockList.mockResolvedValue({ alerts: [alert()] });
render(<NextcloudAlertNotifier />);
await advance(0);
expect(mockShow).toHaveBeenCalledTimes(1);
await advance(60_000);
await advance(60_000);
expect(mockList.mock.calls.length).toBeGreaterThanOrEqual(3);
expect(mockShow).toHaveBeenCalledTimes(1);
expect(mockShow.mock.calls[0][0]).toMatchObject({
title: 'notify.down.unreachable:Kunde A',
body: 'https://cloud.a.de',
tag: 'nextcloud-alert-i1-2026-10-02T11:55:00.000Z',
});
});
it('ein neuer Uebergang derselben Cloud wird erneut gemeldet', async () => {
withAccess(true);
mockList.mockResolvedValue({ alerts: [alert()] });
render(<NextcloudAlertNotifier />);
await advance(0);
mockList.mockResolvedValue({
alerts: [alert({ kind: 'up', reason: null, changedAt: '2026-10-02T12:05:00.000Z' })],
});
await advance(60_000);
expect(mockShow).toHaveBeenCalledTimes(2);
expect(mockShow.mock.calls[1][0].title).toBe('notify.up:Kunde A');
});
it.each([
['unreachable', 'notify.down.unreachable'],
['invalid-response', 'notify.down.invalidResponse'],
['maintenance', 'notify.down.maintenance'],
['needs-db-upgrade', 'notify.down.needsDbUpgrade'],
['eol-passed', 'notify.down.eolPassed'],
['etwas-neues', 'notify.down.unreachable'],
])('Titel je roter Grund: %s', async (reason, key) => {
withAccess(true);
mockList.mockResolvedValue({ alerts: [alert({ reason })] });
render(<NextcloudAlertNotifier />);
await advance(0);
expect(mockShow.mock.calls[0][0].title).toBe(`${key}:Kunde A`);
});
it('pausiert bei 403 bis zum naechsten Fokus', async () => {
withAccess(true);
mockList.mockRejectedValue(new NextcloudRequestError(403));
render(<NextcloudAlertNotifier />);
await advance(0);
const after403 = mockList.mock.calls.length;
await advance(180_000);
expect(mockList.mock.calls.length).toBe(after403);
mockList.mockResolvedValue({ alerts: [] });
await act(async () => {
window.dispatchEvent(new Event('focus'));
await vi.advanceTimersByTimeAsync(0);
});
expect(mockList.mock.calls.length).toBe(after403 + 1);
});
it('pausiert bei 401 der Zugriffspruefung, ohne die Meldungen abzufragen', async () => {
mockFetch.mockResolvedValue({ ok: false, status: 401, json: async () => ({}) });
render(<NextcloudAlertNotifier />);
await advance(0);
await advance(120_000);
expect(mockList).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,131 @@
'use client';
import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useRef } from 'react';
import { listAlerts, type NextcloudAlert, NextcloudRequestError } from '@/lib/nextcloud-status-api';
import { claimNotification, showReminderNotification, withNotifyLock } from '@/lib/reminder-notify';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
const MODULE_SLUG = 'nextcloud-status';
/** Abstand der Abfrage beim Server. */
const POLL_MS = 60_000;
/** Rote Grundkennung der API -> Schluessel unter `nextcloudStatus.notify.down`. */
const DOWN_KEYS: Record<string, string> = {
unreachable: 'unreachable',
'invalid-response': 'invalidResponse',
maintenance: 'maintenance',
'needs-db-upgrade': 'needsDbUpgrade',
'eol-passed': 'eolPassed',
};
/**
* Globaler Melder fuer Nextcloud-Status (quick-261002-kxc, L-04): zeigt, solange
* Tessera offen ist, fuer jeden Uebergang einer Cloud mit eingeschalteter
* Glocke EINE Meldung — im Browser als Web-Benachrichtigung, in der
* Desktop-App als Windows-Benachrichtigung. Haengt im Portal-Rahmen
* (`AppShell`) neben `ReminderNotifier`, rendert nichts.
*
* Bewusst dieselben Helfer wie die Erinnerungen (`reminder-notify.ts`): derselbe
* Tauri-Weg (`plugin:notification|notify`) liefert die Windows-Meldung, und
* dieselbe Merkliste je Client (`claimNotification`, mehrere Tabs teilen sie
* ueber `withNotifyLock`) sorgt dafuer, dass jeder (Cloud, Zeitpunkt) hoechstens
* einmal gemeldet wird.
*
* Ohne Modulzugriff (Eintrag fehlt in `GET /modules/active`) fragt der Melder
* die Meldungen NIE ab. Die Zugriffspruefung wiederholt sich beim Einhaengen
* und bei Rueckkehr in den Vordergrund. Bei 401/403 ruht die Abfrage bis zum
* naechsten Fokus.
*/
export function NextcloudAlertNotifier() {
const t = useTranslations('nextcloudStatus');
const pausedRef = useRef(false);
const accessRef = useRef(false);
// Uebersetzung ueber einen Ref, damit die Effekte unten nicht bei jedem Render neu aufgebaut werden.
const textRef = useRef(t);
textRef.current = t;
const titleOf = useCallback((alert: NextcloudAlert): string => {
const tr = textRef.current;
if (alert.kind === 'up') return tr('notify.up', { name: alert.customerName });
const key = DOWN_KEYS[alert.reason ?? ''] ?? 'unreachable';
return tr(`notify.down.${key}`, { name: alert.customerName });
}, []);
const poll = useCallback(async () => {
if (pausedRef.current || !accessRef.current) return;
let alerts: NextcloudAlert[];
try {
alerts = (await listAlerts()).alerts;
} catch (err) {
if (err instanceof NextcloudRequestError && (err.status === 401 || err.status === 403)) {
pausedRef.current = true;
}
// Andere Fehler: still ignorieren, der naechste Takt versucht es erneut.
return;
}
if (alerts.length === 0) return;
await withNotifyLock(async () => {
for (const alert of alerts) {
const key = `nextcloud-alert|${alert.instanceId}|${alert.changedAt}`;
if (!claimNotification(key, Date.now())) continue;
await showReminderNotification({
title: titleOf(alert),
body: alert.baseUrl,
tag: `nextcloud-alert-${alert.instanceId}-${alert.changedAt}`,
});
}
});
}, [titleOf]);
/** Hat der Benutzer das Modul? Nur dann wird abgefragt. */
const checkAccess = useCallback(async () => {
try {
const response = await fetch(`${API_URL}/modules/active`, {
credentials: 'include',
cache: 'no-store',
});
if (response.status === 401 || response.status === 403) {
pausedRef.current = true;
accessRef.current = false;
return;
}
if (!response.ok) {
accessRef.current = false;
return;
}
const modules = (await response.json()) as Array<{ slug: string }>;
accessRef.current = Array.isArray(modules) && modules.some((m) => m.slug === MODULE_SLUG);
} catch {
accessRef.current = false;
}
}, []);
useEffect(() => {
let cancelled = false;
const start = async () => {
await checkAccess();
if (!cancelled) await poll();
};
void start();
const interval = setInterval(() => void poll(), POLL_MS);
const resume = () => {
pausedRef.current = false;
void start();
};
const onVisible = () => {
if (document.visibilityState === 'visible') resume();
};
document.addEventListener('visibilitychange', onVisible);
window.addEventListener('focus', resume);
return () => {
cancelled = true;
clearInterval(interval);
document.removeEventListener('visibilitychange', onVisible);
window.removeEventListener('focus', resume);
};
}, [checkAccess, poll]);
return null;
}