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:
@@ -7,6 +7,7 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
|
|||||||
### Neu
|
### Neu
|
||||||
|
|
||||||
- Neues Modul „Nextcloud-Status“ in der Gruppe „Infrastruktur“. Es zeigt für jede eingetragene Nextcloud-Cloud Ihrer Kunden eine Kachel mit Logo (oder Initialen), Kundenname, Adresse (öffnet in einem neuen Tab), installierter Version, Ampelfarbe mit kurzer Begründung und dem Zeitpunkt der letzten Prüfung; oben steht die neueste Nextcloud-Version. Grün heißt: neuester Stand seiner Version und der Support läuft noch mehr als drei Monate. Gelb heißt: ein Update steht an oder der Support endet in den nächsten drei Monaten. Rot heißt: der Support ist abgelaufen, die Cloud ist nicht erreichbar, im Wartungsmodus oder wartet auf eine Datenbank-Aktualisierung. Grau heißt: Bewertung nicht möglich, zum Beispiel wenn die Versionsdaten gerade nicht abrufbar sind. Tessera prüft jede Cloud automatisch einmal pro Stunde; „Jetzt prüfen“ und ein Knopf je Kachel prüfen sofort. Die Kacheln lassen sich nach Kundenname, Status (Rot zuerst), Version oder Support-Ende sortieren, die Wahl merkt sich Tessera für jeden Benutzer. Clouds eintragen, ändern, entfernen und Logos hinterlegen (Bild hochladen bis 1 MB oder eine https-Bildadresse) dürfen Administratoren und Benutzer mit der Freigabestufe „Verwalten“; alle anderen mit Freigabe sehen die Kacheln. Aktivieren Sie das Modul als Administrator im Marktplatz und erteilen Sie die Freigabe.
|
- Neues Modul „Nextcloud-Status“ in der Gruppe „Infrastruktur“. Es zeigt für jede eingetragene Nextcloud-Cloud Ihrer Kunden eine Kachel mit Logo (oder Initialen), Kundenname, Adresse (öffnet in einem neuen Tab), installierter Version, Ampelfarbe mit kurzer Begründung und dem Zeitpunkt der letzten Prüfung; oben steht die neueste Nextcloud-Version. Grün heißt: neuester Stand seiner Version und der Support läuft noch mehr als drei Monate. Gelb heißt: ein Update steht an oder der Support endet in den nächsten drei Monaten. Rot heißt: der Support ist abgelaufen, die Cloud ist nicht erreichbar, im Wartungsmodus oder wartet auf eine Datenbank-Aktualisierung. Grau heißt: Bewertung nicht möglich, zum Beispiel wenn die Versionsdaten gerade nicht abrufbar sind. Tessera prüft jede Cloud automatisch einmal pro Stunde; „Jetzt prüfen“ und ein Knopf je Kachel prüfen sofort. Die Kacheln lassen sich nach Kundenname, Status (Rot zuerst), Version oder Support-Ende sortieren, die Wahl merkt sich Tessera für jeden Benutzer. Clouds eintragen, ändern, entfernen und Logos hinterlegen (Bild hochladen bis 1 MB oder eine https-Bildadresse) dürfen Administratoren und Benutzer mit der Freigabestufe „Verwalten“; alle anderen mit Freigabe sehen die Kacheln. Aktivieren Sie das Modul als Administrator im Marktplatz und erteilen Sie die Freigabe.
|
||||||
|
- Nextcloud-Status: Auf jeder Kachel gibt es jetzt eine Glocke „Benachrichtigen“, die jeder Benutzer mit Zugriff auf das Modul für sich ein- und ausschalten kann. Ist sie an, meldet Tessera per E-Mail und, solange Tessera geöffnet ist, als Benachrichtigung auf dem Bildschirm (in der Desktop-App als Windows-Benachrichtigung), wenn die Cloud eine Störung hat – nicht erreichbar, keine gültige Antwort, Wartungsmodus, ausstehende Datenbank-Aktualisierung oder abgelaufener Support – und wenn sie wieder in Ordnung ist. Pro Änderung kommt genau eine Nachricht, solange die Störung anhält, nicht jede Stunde neu. Ein einzelner fehlgeschlagener Abruf löst keine Meldung aus: Die Kachel zeigt weiter den letzten guten Stand mit dem Hinweis „Prüfung fehlgeschlagen, wird in wenigen Minuten wiederholt“, und Tessera prüft nach etwa fünf Minuten erneut. Für die E-Mails muss der Mailversand eingerichtet sein und im Benutzerprofil eine E-Mail-Adresse stehen. Außerdem nennt die Kachel bei „Nicht erreichbar“ jetzt den Grund in Klartext, zum Beispiel „Zertifikat passt nicht zur Adresse“, „Adresse nicht gefunden“ oder „Zeitüberschreitung“, statt eines technischen Fehlercodes.
|
||||||
- Neue Dashboard-Kachel „Nextcloud-Status“: drei Zähler für Grün, Gelb und Rot und darunter die roten und gelben Clouds mit Kundenname und Grund. Ein Klick öffnet das Modul. Die Kachel erscheint nur für Benutzer, die das Modul nutzen dürfen.
|
- Neue Dashboard-Kachel „Nextcloud-Status“: drei Zähler für Grün, Gelb und Rot und darunter die roten und gelben Clouds mit Kundenname und Grund. Ein Klick öffnet das Modul. Die Kachel erscheint nur für Benutzer, die das Modul nutzen dürfen.
|
||||||
- Neue Gruppe „Finanzbuchhaltung“ in der Seitenleiste mit zwei Modulen. Beide aktiviert ein Administrator im Marktplatz; wer sie nutzen soll, bekommt zusätzlich die Freigabe.
|
- Neue Gruppe „Finanzbuchhaltung“ in der Seitenleiste mit zwei Modulen. Beide aktiviert ein Administrator im Marktplatz; wer sie nutzen soll, bekommt zusätzlich die Freigabe.
|
||||||
- Kantinenabrechnung: Die CSV-Datei der Kantine hochladen (Excel-Export mit UTF-8 oder Windows-1252 ist beides in Ordnung). Tessera zeigt Zeilenzahl, Abrechnungsmonat und Gesamtbetrag, nennt fehlerhafte Zeilen mit Zeilennummer und weist auf unterschiedliche Abrechnungsmonate hin. Ist alles in Ordnung, laden Sie mit einem Klick die DATEV-Lohndatei herunter. Beraternummer, Mandantennummer und Lohnart trägt ein Administrator einmalig ein; bis dahin ist der Download gesperrt. Die hochgeladenen Daten werden nicht gespeichert.
|
- Kantinenabrechnung: Die CSV-Datei der Kantine hochladen (Excel-Export mit UTF-8 oder Windows-1252 ist beides in Ordnung). Tessera zeigt Zeilenzahl, Abrechnungsmonat und Gesamtbetrag, nennt fehlerhafte Zeilen mit Zeilennummer und weist auf unterschiedliche Abrechnungsmonate hin. Ist alles in Ordnung, laden Sie mit einem Klick die DATEV-Lohndatei herunter. Beraternummer, Mandantennummer und Lohnart trägt ein Administrator einmalig ein; bis dahin ist der Download gesperrt. Die hochgeladenen Daten werden nicht gespeichert.
|
||||||
|
|||||||
@@ -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);
|
const fn = handler(NextcloudStatusController, name);
|
||||||
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBeUndefined();
|
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBeUndefined();
|
||||||
expect(Reflect.getMetadata(ROLES_KEY, fn)).toBeUndefined();
|
expect(Reflect.getMetadata(ROLES_KEY, fn)).toBeUndefined();
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const ROW = {
|
|||||||
|
|
||||||
function makeService() {
|
function makeService() {
|
||||||
const prisma = {
|
const prisma = {
|
||||||
nextcloudInstance: { findFirst: vi.fn(), updateMany: vi.fn() },
|
nextcloudInstance: { findFirst: vi.fn(), findMany: vi.fn(), updateMany: vi.fn() },
|
||||||
nextcloudAlertSubscription: {
|
nextcloudAlertSubscription: {
|
||||||
findMany: vi.fn().mockResolvedValue([]),
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
upsert: vi.fn(),
|
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', () => {
|
describe('NextcloudAlertService.evaluateAfterCheck', () => {
|
||||||
let ctx: ReturnType<typeof makeService>;
|
let ctx: ReturnType<typeof makeService>;
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -26,6 +26,20 @@ export interface AlertCheckedRow {
|
|||||||
alertState: string;
|
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 {
|
export interface AlertEvaluation {
|
||||||
kind: AlertTransition;
|
kind: AlertTransition;
|
||||||
/** Der laufende Versand (nur fuer Tests zum Abwarten; der Pruefpfad wartet nie darauf). */
|
/** 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));
|
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
|
* Entscheidet nach einer Pruefung, ob eine Meldung faellig ist, beansprucht
|
||||||
* den Uebergang und startet den Versand im Hintergrund. Wartet nur auf den
|
* 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);
|
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(MODULE_MANAGE_KEY, proto[name] as object)).toBeUndefined();
|
||||||
expect(Reflect.getMetadata(ROLES_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');
|
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 () => {
|
it('ohne Mandantenkontext -> 403', async () => {
|
||||||
const controller = new NextcloudStatusController({} as never, {} as never);
|
const controller = new NextcloudStatusController({} as never, {} as never);
|
||||||
await expect(controller.subscribe({} as never, { id: 'u1' } as never, 'i1')).rejects.toThrow(
|
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);
|
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')
|
@Post('instances')
|
||||||
@ModuleManage('nextcloud-status')
|
@ModuleManage('nextcloud-status')
|
||||||
async create(@Req() req: AuthenticatedRequest, @Body() dto: CreateNextcloudInstanceDto) {
|
async create(@Req() req: AuthenticatedRequest, @Body() dto: CreateNextcloudInstanceDto) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { Header } from '@/components/layout/header';
|
import { Header } from '@/components/layout/header';
|
||||||
import { Sidebar } from '@/components/layout/sidebar';
|
import { Sidebar } from '@/components/layout/sidebar';
|
||||||
import { CustomModuleFrameHost } from '@/components/modules/custom-module-frame-host';
|
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 { ReleaseNoticeHost } from '@/components/release-notice/release-notice-host';
|
||||||
import { ReminderNotifier } from '@/components/reminders/reminder-notifier';
|
import { ReminderNotifier } from '@/components/reminders/reminder-notifier';
|
||||||
import { installErrorBuffer } from '@/lib/error-buffer';
|
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
|
Benachrichtigung auf jeder Portalseite ausloest — nicht nur, wenn die
|
||||||
Kachel auf dem Dashboard sichtbar ist. Rendert nichts. */}
|
Kachel auf dem Dashboard sichtbar ist. Rendert nichts. */}
|
||||||
<ReminderNotifier />
|
<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
|
{/* Eigene Module offen halten (30.09.2026): die <iframe>s leben hier im
|
||||||
Rahmen und ueberstehen so das Wegnavigieren; die Modulseite zeigt nur
|
Rahmen und ueberstehen so das Wegnavigieren; die Modulseite zeigt nur
|
||||||
einen Platzhalter, ueber den der Behaelter das aktive Modul legt. */}
|
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'));
|
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}",
|
"httpStatus": "Server antwortet mit Fehler {code}",
|
||||||
"fallback": "Verbindungsfehler"
|
"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": {
|
"sort": {
|
||||||
"label": "Sortieren nach",
|
"label": "Sortieren nach",
|
||||||
"name": "Kundenname",
|
"name": "Kundenname",
|
||||||
|
|||||||
@@ -1867,6 +1867,16 @@
|
|||||||
"httpStatus": "Server responds with error {code}",
|
"httpStatus": "Server responds with error {code}",
|
||||||
"fallback": "Connection error"
|
"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": {
|
"sort": {
|
||||||
"label": "Sort by",
|
"label": "Sort by",
|
||||||
"name": "Customer name",
|
"name": "Customer name",
|
||||||
|
|||||||
@@ -338,8 +338,9 @@ Das Modul „Nextcloud-Status“ (Gruppe Infrastruktur) aktivieren Sie wie jedes
|
|||||||
|
|
||||||
- **Eintragen:** Kundenname und Adresse der Cloud (zum Beispiel `https://cloud.kunde.de`; ein Zusatz wie `/status.php` oder `/index.php` wird entfernt, ein Unterpfad wie `/nextcloud` bleibt). Zugangsdaten werden nicht gebraucht und nicht gespeichert: Tessera ruft nur die öffentliche Seite `<Adresse>/status.php` ab und zeigt davon Version, Wartungsmodus und Erreichbarkeit.
|
- **Eintragen:** Kundenname und Adresse der Cloud (zum Beispiel `https://cloud.kunde.de`; ein Zusatz wie `/status.php` oder `/index.php` wird entfernt, ein Unterpfad wie `/nextcloud` bleibt). Zugangsdaten werden nicht gebraucht und nicht gespeichert: Tessera ruft nur die öffentliche Seite `<Adresse>/status.php` ab und zeigt davon Version, Wartungsmodus und Erreichbarkeit.
|
||||||
- **Netzwerk:** Der API-Container braucht ausgehenden Zugriff auf die eingetragenen Clouds und auf `endoflife.date` (Quelle der Versionsdaten). Interne Adressen sind ausdrücklich erlaubt. Pro Abruf gelten ein Zeitlimit von 10 Sekunden, höchstens drei Weiterleitungen und eine Größenbegrenzung der Antwort.
|
- **Netzwerk:** Der API-Container braucht ausgehenden Zugriff auf die eingetragenen Clouds und auf `endoflife.date` (Quelle der Versionsdaten). Interne Adressen sind ausdrücklich erlaubt. Pro Abruf gelten ein Zeitlimit von 10 Sekunden, höchstens drei Weiterleitungen und eine Größenbegrenzung der Antwort.
|
||||||
- **Zertifikat:** Das Zertifikat der Cloud wird geprüft. Ein abgelaufenes oder nicht vertrauenswürdiges Zertifikat erscheint als „Nicht erreichbar“, der Fehlercode steht klein darunter.
|
- **Zertifikat:** Das Zertifikat der Cloud wird geprüft. Ein abgelaufenes oder nicht vertrauenswürdiges Zertifikat erscheint als „Nicht erreichbar“, die Kachel nennt den Grund in Klartext (zum Beispiel „Zertifikat passt nicht zur Adresse“ oder „Zertifikat abgelaufen“), der technische Fehlercode erscheint als Tooltip.
|
||||||
- **Rhythmus:** Tessera prüft jede Cloud einmal pro Stunde, auch nach einem Neustart und auf einer frischen Datenbank. Die Versionsdaten werden höchstens alle 12 Stunden neu geholt; ist `endoflife.date` nicht erreichbar, bleiben die zuletzt abgerufenen Daten erhalten, und ohne Daten zeigen die Kacheln grau „Bewertung nicht möglich“.
|
- **Rhythmus:** Tessera prüft jede Cloud einmal pro Stunde, auch nach einem Neustart und auf einer frischen Datenbank. Schlägt eine Prüfung fehl, wiederholt Tessera sie nach etwa fünf Minuten; erst nach dem zweiten Fehlschlag gilt die Cloud als „nicht erreichbar“. Die Versionsdaten werden höchstens alle 12 Stunden neu geholt; ist `endoflife.date` nicht erreichbar, bleiben die zuletzt abgerufenen Daten erhalten, und ohne Daten zeigen die Kacheln grau „Bewertung nicht möglich“.
|
||||||
|
- **Benachrichtigungen:** Jeder Benutzer mit Zugriff auf das Modul kann über die Glocke auf einer Kachel für sich Benachrichtigungen einschalten. Tessera schickt dann eine E-Mail und, solange Tessera geöffnet ist, eine Meldung auf dem Bildschirm, wenn die Cloud rot wird und wenn sie wieder in Ordnung ist (eine Nachricht je Änderung). Für die E-Mails muss der Mailversand eingerichtet sein (Kapitel 6) und der Empfänger eine Adresse im Profil haben. Ob das Konto aktiv ist und das Modul noch genutzt werden darf, prüft Tessera erst beim Senden; sonst wird nichts verschickt. Scheitert der Versand, versucht Tessera es bis zu dreimal. Ändern Sie die Adresse einer roten Cloud und die neue Adresse antwortet in Ordnung, geht an die Abonnenten die Nachricht „wieder in Ordnung“.
|
||||||
- **Logo:** Entweder ein hochgeladenes Bild (PNG, JPEG, GIF oder WebP, höchstens 1 MB; SVG wird bewusst nicht angenommen) oder die https-Adresse eines Bildes, das der Browser des Betrachters direkt lädt – der Server ruft diese Adresse nie ab. Beides zugleich gibt es nicht: ein Upload ersetzt die Adresse und umgekehrt.
|
- **Logo:** Entweder ein hochgeladenes Bild (PNG, JPEG, GIF oder WebP, höchstens 1 MB; SVG wird bewusst nicht angenommen) oder die https-Adresse eines Bildes, das der Browser des Betrachters direkt lädt – der Server ruft diese Adresse nie ab. Beides zugleich gibt es nicht: ein Upload ersetzt die Adresse und umgekehrt.
|
||||||
|
|
||||||
## 6. SMTP
|
## 6. SMTP
|
||||||
|
|||||||
@@ -197,6 +197,13 @@ Das Modul zeigt, ob die Nextcloud-Clouds Ihrer Kunden erreichbar und auf einem a
|
|||||||
|
|
||||||
**Clouds pflegen** dürfen Administratoren und Benutzer mit der Freigabestufe „Verwalten“: „Cloud hinzufügen“ (Kundenname und Adresse, optional ein Logo), Bearbeiten und Entfernen über die Kachel. Beim Logo haben Sie die Wahl zwischen einem hochgeladenen Bild (PNG, JPEG, GIF oder WebP, höchstens 1 MB) und der https-Adresse eines Bildes, das Ihr Browser selbst lädt. Wer nur die Stufe „Benutzen“ hat, sieht die Kacheln und kann sortieren, aber nichts ändern.
|
**Clouds pflegen** dürfen Administratoren und Benutzer mit der Freigabestufe „Verwalten“: „Cloud hinzufügen“ (Kundenname und Adresse, optional ein Logo), Bearbeiten und Entfernen über die Kachel. Beim Logo haben Sie die Wahl zwischen einem hochgeladenen Bild (PNG, JPEG, GIF oder WebP, höchstens 1 MB) und der https-Adresse eines Bildes, das Ihr Browser selbst lädt. Wer nur die Stufe „Benutzen“ hat, sieht die Kacheln und kann sortieren, aber nichts ändern.
|
||||||
|
|
||||||
|
**Benachrichtigen:** Auf jeder Kachel sitzt eine Glocke, die jeder Benutzer mit Zugriff auf das Modul sieht – auch mit der Stufe „Benutzen“. Mit einem Klick schalten Sie die Benachrichtigung für diese eine Cloud ein oder aus; die Glocke gilt nur für Sie und bleibt auch nach dem Neuladen erhalten. Beim ersten Einschalten fragt der Browser einmalig, ob Tessera Benachrichtigungen anzeigen darf. Ist die Glocke an, bekommen Sie eine Nachricht,
|
||||||
|
|
||||||
|
- wenn die Cloud **rot** wird (nicht erreichbar, keine gültige Antwort, Wartungsmodus, ausstehende Datenbank-Aktualisierung oder abgelaufener Support), und
|
||||||
|
- wenn sie danach **wieder in Ordnung** ist (gelb oder grün).
|
||||||
|
|
||||||
|
Pro Änderung kommt genau eine Nachricht; solange die Störung anhält, wiederholt sie sich nicht. Die Nachricht kommt als E-Mail und, solange Tessera in Ihrem Browser oder in der Desktop-App geöffnet ist, zusätzlich als Benachrichtigung auf dem Bildschirm (in der Desktop-App als Windows-Benachrichtigung). Die E-Mail erhalten Sie nur, wenn der Mailversand eingerichtet ist und in Ihrem Profil eine E-Mail-Adresse steht; außerdem müssen Sie das Modul zum Zeitpunkt der Störung noch nutzen dürfen. „Nicht erreichbar“ meldet Tessera erst, wenn **zwei Prüfungen hintereinander** fehlgeschlagen sind, damit ein kurzer Aussetzer keinen Alarm auslöst. Nach dem ersten Fehlschlag zeigt die Kachel weiter den letzten guten Stand und den Hinweis „Prüfung fehlgeschlagen, wird in wenigen Minuten wiederholt“; Tessera prüft dann nach etwa fünf Minuten erneut. Wartungsmodus, ausstehende Datenbank-Aktualisierung und abgelaufener Support werden sofort gemeldet. Bei „Nicht erreichbar“ nennt die Kachel den Grund in Klartext, zum Beispiel „Zertifikat passt nicht zur Adresse“, „Zertifikat abgelaufen“, „Adresse nicht gefunden“, „Verbindung abgelehnt“ oder „Zeitüberschreitung“; fahren Sie mit der Maus darüber, sehen Sie den technischen Code.
|
||||||
|
|
||||||
**Die Dashboard-Kachel „Nextcloud-Status“:** Wer das Modul nutzen darf, kann sich eine Übersicht auch auf das Dashboard legen („Widget hinzufügen“). Sie zeigt drei Zähler (Grün, Gelb, Rot, bei Bedarf „Ohne Bewertung“) und darunter die roten und gelben Clouds mit Kundenname und Grund; sind alle Clouds in Ordnung, steht dort eine kurze Bestätigung. Ein Klick öffnet das Modul. Die Kachel liest nur den gespeicherten Stand und löst selbst keine Prüfung aus.
|
**Die Dashboard-Kachel „Nextcloud-Status“:** Wer das Modul nutzen darf, kann sich eine Übersicht auch auf das Dashboard legen („Widget hinzufügen“). Sie zeigt drei Zähler (Grün, Gelb, Rot, bei Bedarf „Ohne Bewertung“) und darunter die roten und gelben Clouds mit Kundenname und Grund; sind alle Clouds in Ordnung, steht dort eine kurze Bestätigung. Ein Klick öffnet das Modul. Die Kachel liest nur den gespeicherten Stand und löst selbst keine Prüfung aus.
|
||||||
|
|
||||||
### Kantinenabrechnung
|
### Kantinenabrechnung
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user