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:
@@ -78,7 +78,7 @@ describe('Umgestellte Handler (Verwalten)', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['list', 'logo', 'subscribe', 'unsubscribe'])('NextcloudStatusController.%s bleibt auf Benutzen-Ebene', (name) => {
|
||||
it.each(['list', 'logo', 'subscribe', 'unsubscribe', 'recentAlerts'])('NextcloudStatusController.%s bleibt auf Benutzen-Ebene', (name) => {
|
||||
const fn = handler(NextcloudStatusController, name);
|
||||
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBeUndefined();
|
||||
expect(Reflect.getMetadata(ROLES_KEY, fn)).toBeUndefined();
|
||||
|
||||
@@ -37,7 +37,7 @@ const ROW = {
|
||||
|
||||
function makeService() {
|
||||
const prisma = {
|
||||
nextcloudInstance: { findFirst: vi.fn(), updateMany: vi.fn() },
|
||||
nextcloudInstance: { findFirst: vi.fn(), findMany: vi.fn(), updateMany: vi.fn() },
|
||||
nextcloudAlertSubscription: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
upsert: vi.fn(),
|
||||
@@ -123,6 +123,83 @@ describe('NextcloudAlertService Abonnements', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('NextcloudAlertService.listRecentAlerts', () => {
|
||||
const T = (iso: string) => new Date(iso);
|
||||
let ctx: ReturnType<typeof makeService>;
|
||||
beforeEach(() => {
|
||||
vi.mocked(forTenant).mockClear();
|
||||
ctx = makeService();
|
||||
});
|
||||
|
||||
const inst = (over: Record<string, unknown>) => ({
|
||||
id: 'i1',
|
||||
customerName: 'Kunde A',
|
||||
baseUrl: 'https://cloud.a.de',
|
||||
alertState: 'red',
|
||||
alertReason: 'unreachable',
|
||||
alertChangedAt: T('2026-10-02T12:00:00Z'),
|
||||
...over,
|
||||
});
|
||||
|
||||
it('liefert nur Uebergaenge der letzten 24 Stunden nach dem Einschalten der Glocke, mit down/up', async () => {
|
||||
ctx.prisma.nextcloudAlertSubscription.findMany.mockResolvedValue([
|
||||
{ instanceId: 'i1', createdAt: T('2026-10-02T10:00:00Z') },
|
||||
{ instanceId: 'i2', createdAt: T('2026-10-02T13:00:00Z') }, // Uebergang lag davor
|
||||
{ instanceId: 'i3', createdAt: T('2026-10-02T09:00:00Z') },
|
||||
]);
|
||||
ctx.prisma.nextcloudInstance.findMany.mockResolvedValue([
|
||||
inst({
|
||||
id: 'i3',
|
||||
alertState: 'ok',
|
||||
alertReason: null,
|
||||
alertChangedAt: T('2026-10-02T12:30:00Z'),
|
||||
}),
|
||||
inst({ id: 'i1' }),
|
||||
inst({ id: 'i2', alertChangedAt: T('2026-10-02T11:00:00Z') }),
|
||||
]);
|
||||
const now = T('2026-10-02T14:00:00Z');
|
||||
const { alerts } = await ctx.service.listRecentAlerts('t1', 'u1', now);
|
||||
expect(forTenant).toHaveBeenCalledWith(ctx.prisma, 't1', 'u1');
|
||||
expect(ctx.prisma.nextcloudAlertSubscription.findMany.mock.calls[0][0].where).toEqual({
|
||||
tenantId: 't1',
|
||||
userId: 'u1',
|
||||
});
|
||||
const args = ctx.prisma.nextcloudInstance.findMany.mock.calls[0][0];
|
||||
expect(args.where).toEqual({
|
||||
tenantId: 't1',
|
||||
id: { in: ['i1', 'i2', 'i3'] },
|
||||
alertChangedAt: { gte: T('2026-10-01T14:00:00Z') },
|
||||
});
|
||||
expect(args.select).not.toHaveProperty('logoData');
|
||||
expect(alerts).toEqual([
|
||||
{
|
||||
instanceId: 'i1',
|
||||
customerName: 'Kunde A',
|
||||
baseUrl: 'https://cloud.a.de',
|
||||
kind: 'down',
|
||||
reason: 'unreachable',
|
||||
changedAt: '2026-10-02T12:00:00.000Z',
|
||||
},
|
||||
{
|
||||
instanceId: 'i3',
|
||||
customerName: 'Kunde A',
|
||||
baseUrl: 'https://cloud.a.de',
|
||||
kind: 'up',
|
||||
reason: null,
|
||||
changedAt: '2026-10-02T12:30:00.000Z',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('ohne Abonnements: leer, kein Zugriff auf die Clouds (fremde Abonnements erscheinen nie)', async () => {
|
||||
ctx.prisma.nextcloudAlertSubscription.findMany.mockResolvedValue([]);
|
||||
expect(await ctx.service.listRecentAlerts('t1', 'u1', T('2026-10-02T14:00:00Z'))).toEqual({
|
||||
alerts: [],
|
||||
});
|
||||
expect(ctx.prisma.nextcloudInstance.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('NextcloudAlertService.evaluateAfterCheck', () => {
|
||||
let ctx: ReturnType<typeof makeService>;
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -26,6 +26,20 @@ export interface AlertCheckedRow {
|
||||
alertState: string;
|
||||
}
|
||||
|
||||
/** Eine Meldung fuer die Anzeige in Tessera (D-K7). */
|
||||
export interface RecentAlert {
|
||||
instanceId: string;
|
||||
customerName: string;
|
||||
baseUrl: string;
|
||||
kind: 'down' | 'up';
|
||||
/** Rote Grundkennung (z. B. 'unreachable'); nur bei `down`. */
|
||||
reason: string | null;
|
||||
changedAt: string;
|
||||
}
|
||||
|
||||
/** Zeitraum, in dem ein Uebergang noch als Meldung in Tessera erscheint. */
|
||||
export const RECENT_ALERT_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export interface AlertEvaluation {
|
||||
kind: AlertTransition;
|
||||
/** Der laufende Versand (nur fuer Tests zum Abwarten; der Pruefpfad wartet nie darauf). */
|
||||
@@ -108,6 +122,68 @@ export class NextcloudAlertService {
|
||||
return new Set(rows.map((r) => r.instanceId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Letzte Uebergaenge der Clouds, fuer die DER AUFRUFER die Glocke eingeschaltet
|
||||
* hat (quick-261002-kxc, L-04, D-K7, T-kxc-02): je Cloud der juengste
|
||||
* Uebergang der letzten 24 Stunden, sofern er NACH dem Einschalten der Glocke
|
||||
* lag. Der Browser zeigt daraus je (Cloud, Zeitpunkt) hoechstens einmal eine
|
||||
* Meldung. Nur skalare Felder, die die Modulseite ohnehin zeigt.
|
||||
*/
|
||||
async listRecentAlerts(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
now: Date = new Date(),
|
||||
): Promise<{ alerts: RecentAlert[] }> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
const subs: { instanceId: string; createdAt: Date }[] =
|
||||
await tenantPrisma.nextcloudAlertSubscription.findMany({
|
||||
where: { tenantId, userId },
|
||||
select: { instanceId: true, createdAt: true },
|
||||
});
|
||||
if (subs.length === 0) return { alerts: [] };
|
||||
|
||||
const since = new Date(now.getTime() - RECENT_ALERT_WINDOW_MS);
|
||||
const instances: {
|
||||
id: string;
|
||||
customerName: string;
|
||||
baseUrl: string;
|
||||
alertState: string;
|
||||
alertReason: string | null;
|
||||
alertChangedAt: Date | null;
|
||||
}[] = await tenantPrisma.nextcloudInstance.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
id: { in: subs.map((s) => s.instanceId) },
|
||||
alertChangedAt: { gte: since },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
customerName: true,
|
||||
baseUrl: true,
|
||||
alertState: true,
|
||||
alertReason: true,
|
||||
alertChangedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
const subscribedAt = new Map(subs.map((s) => [s.instanceId, s.createdAt]));
|
||||
const alerts: RecentAlert[] = [];
|
||||
for (const i of instances) {
|
||||
const subscribed = subscribedAt.get(i.id);
|
||||
if (!i.alertChangedAt || !subscribed || i.alertChangedAt < subscribed) continue;
|
||||
alerts.push({
|
||||
instanceId: i.id,
|
||||
customerName: i.customerName,
|
||||
baseUrl: i.baseUrl,
|
||||
kind: i.alertState === 'red' ? 'down' : 'up',
|
||||
reason: i.alertState === 'red' ? i.alertReason : null,
|
||||
changedAt: i.alertChangedAt.toISOString(),
|
||||
});
|
||||
}
|
||||
alerts.sort((a, b) => a.changedAt.localeCompare(b.changedAt));
|
||||
return { alerts };
|
||||
}
|
||||
|
||||
/**
|
||||
* Entscheidet nach einer Pruefung, ob eine Meldung faellig ist, beansprucht
|
||||
* den Uebergang und startet den Versand im Hintergrund. Wartet nur auf den
|
||||
|
||||
@@ -15,7 +15,13 @@ describe('NextcloudStatusController Metadaten', () => {
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, NextcloudStatusController)).toContain(ModuleGuard);
|
||||
});
|
||||
|
||||
it.each(['list', 'logo', 'subscribe', 'unsubscribe'])('%s bleibt auf Benutzen-Ebene', (name) => {
|
||||
it.each([
|
||||
'list',
|
||||
'logo',
|
||||
'subscribe',
|
||||
'unsubscribe',
|
||||
'recentAlerts',
|
||||
])('%s bleibt auf Benutzen-Ebene', (name) => {
|
||||
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto[name] as object)).toBeUndefined();
|
||||
expect(Reflect.getMetadata(ROLES_KEY, proto[name] as object)).toBeUndefined();
|
||||
});
|
||||
@@ -86,6 +92,16 @@ describe('NextcloudStatusController Glocke (quick-261002-kxc)', () => {
|
||||
expect(service.checkAllForTenant).toHaveBeenCalledWith('t1', 'u1');
|
||||
});
|
||||
|
||||
it('GET alerts: Benutzer aus dem Token, Mandant aus der Anfrage', async () => {
|
||||
expect(pathOf('recentAlerts')).toBe('alerts');
|
||||
expect(methodOf('recentAlerts')).toBe(0); // RequestMethod.GET
|
||||
const alerts = { listRecentAlerts: vi.fn().mockResolvedValue({ alerts: [] }) };
|
||||
const controller = new NextcloudStatusController({} as never, alerts as never);
|
||||
const req = { tenantId: 't1', query: { userId: 'fremd' } } as never;
|
||||
expect(await controller.recentAlerts(req, { id: 'u1' } as never)).toEqual({ alerts: [] });
|
||||
expect(alerts.listRecentAlerts).toHaveBeenCalledWith('t1', 'u1');
|
||||
});
|
||||
|
||||
it('ohne Mandantenkontext -> 403', async () => {
|
||||
const controller = new NextcloudStatusController({} as never, {} as never);
|
||||
await expect(controller.subscribe({} as never, { id: 'u1' } as never, 'i1')).rejects.toThrow(
|
||||
|
||||
@@ -61,6 +61,16 @@ export class NextcloudStatusController {
|
||||
return this.service.listForTenant(this.requireTenantId(req), user.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Letzte Uebergaenge der Clouds mit eingeschalteter Glocke des Aufrufers
|
||||
* (Meldung in Tessera, quick-261002-kxc, L-04). Nur Klassen-`@UseModule`,
|
||||
* kein Verwalten, keine Rolle; der Benutzer kommt aus dem Token.
|
||||
*/
|
||||
@Get('alerts')
|
||||
async recentAlerts(@Req() req: AuthenticatedRequest, @CurrentUser() user: AuthUser) {
|
||||
return this.alerts.listRecentAlerts(this.requireTenantId(req), user.id);
|
||||
}
|
||||
|
||||
@Post('instances')
|
||||
@ModuleManage('nextcloud-status')
|
||||
async create(@Req() req: AuthenticatedRequest, @Body() dto: CreateNextcloudInstanceDto) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -201,3 +201,36 @@ export async function unsubscribe(id: string): Promise<void> {
|
||||
});
|
||||
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to unsubscribe'));
|
||||
}
|
||||
|
||||
/** Ein abgewiesener Aufruf der Meldungsabfrage; `status` ist der HTTP-Status (401, 403 ...). */
|
||||
export class NextcloudRequestError extends Error {
|
||||
public readonly status: number;
|
||||
|
||||
constructor(status: number) {
|
||||
super(`Nextcloud status request failed (${status})`);
|
||||
this.name = 'NextcloudRequestError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
/** Ein Uebergang einer abonnierten Cloud (Meldung in Tessera). */
|
||||
export interface NextcloudAlert {
|
||||
instanceId: string;
|
||||
customerName: string;
|
||||
baseUrl: string;
|
||||
kind: 'down' | 'up';
|
||||
/** Rote Grundkennung, nur bei `down` (z. B. 'unreachable'). */
|
||||
reason: string | null;
|
||||
/** ISO-Zeitpunkt des Uebergangs. */
|
||||
changedAt: string;
|
||||
}
|
||||
|
||||
/** GET /modules/nextcloud-status/alerts — letzte Uebergaenge der Clouds mit eingeschalteter Glocke. */
|
||||
export async function listAlerts(): Promise<{ alerts: NextcloudAlert[] }> {
|
||||
const res = await fetch(`${API_URL}/modules/nextcloud-status/alerts`, {
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!res.ok) throw new NextcloudRequestError(res.status);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -1867,6 +1867,16 @@
|
||||
"httpStatus": "Server antwortet mit Fehler {code}",
|
||||
"fallback": "Verbindungsfehler"
|
||||
},
|
||||
"notify": {
|
||||
"up": "Nextcloud {name}: wieder in Ordnung",
|
||||
"down": {
|
||||
"unreachable": "Nextcloud {name}: nicht erreichbar",
|
||||
"invalidResponse": "Nextcloud {name}: keine gültige Antwort",
|
||||
"maintenance": "Nextcloud {name}: im Wartungsmodus",
|
||||
"needsDbUpgrade": "Nextcloud {name}: Datenbank-Aktualisierung ausstehend",
|
||||
"eolPassed": "Nextcloud {name}: Support abgelaufen"
|
||||
}
|
||||
},
|
||||
"sort": {
|
||||
"label": "Sortieren nach",
|
||||
"name": "Kundenname",
|
||||
|
||||
@@ -1867,6 +1867,16 @@
|
||||
"httpStatus": "Server responds with error {code}",
|
||||
"fallback": "Connection error"
|
||||
},
|
||||
"notify": {
|
||||
"up": "Nextcloud {name}: back in order",
|
||||
"down": {
|
||||
"unreachable": "Nextcloud {name}: unreachable",
|
||||
"invalidResponse": "Nextcloud {name}: no valid response",
|
||||
"maintenance": "Nextcloud {name}: in maintenance mode",
|
||||
"needsDbUpgrade": "Nextcloud {name}: database upgrade pending",
|
||||
"eolPassed": "Nextcloud {name}: support expired"
|
||||
}
|
||||
},
|
||||
"sort": {
|
||||
"label": "Sort by",
|
||||
"name": "Customer name",
|
||||
|
||||
Reference in New Issue
Block a user