feat(nextcloud-status): Clouds verwalten, Logos, stündliche Prüfung und Sortierung
- Ändern, Entfernen, Logo-Upload und -Abruf, Sammelprüfung nur mit Verwalten - Stündlicher Auftrag beim Start registriert, Prüfung je Cloud an ihren Mandanten gebunden - Sortierung nach Kundenname, Status, Version und Support-Ende, je Benutzer gemerkt - Formular zum Anlegen und Bearbeiten, Zugriffsinventar angepasst Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { NextcloudInstance, RatingLevel } from '@/lib/nextcloud-status-api';
|
||||
import { readSortPreference, sortClouds, writeSortPreference } from './sort-clouds';
|
||||
|
||||
function make(
|
||||
name: string,
|
||||
level: RatingLevel = 'green',
|
||||
version: string | null = '35.0.1',
|
||||
eolDate: string | null = null,
|
||||
): NextcloudInstance {
|
||||
return {
|
||||
id: name,
|
||||
customerName: name,
|
||||
baseUrl: `https://${name}.de`,
|
||||
logoUrl: null,
|
||||
hasUploadedLogo: false,
|
||||
logoVersion: 0,
|
||||
status: {
|
||||
checkedAt: null,
|
||||
reachable: true,
|
||||
maintenance: false,
|
||||
needsDbUpgrade: false,
|
||||
versionString: version,
|
||||
edition: null,
|
||||
errorKind: null,
|
||||
errorDetail: null,
|
||||
},
|
||||
rating: { level, reason: 'current', updateTo: null, eolDate, cycle: null },
|
||||
};
|
||||
}
|
||||
|
||||
const names = (list: NextcloudInstance[]) => list.map((i) => i.customerName);
|
||||
|
||||
describe('sortClouds', () => {
|
||||
it('name: A-Z mit deutscher Sortierung, ohne Beachtung der Gross-/Kleinschreibung', () => {
|
||||
const list = [make('zebra'), make('Ärger'), make('Anton'), make('berta')];
|
||||
expect(names(sortClouds(list, 'name'))).toEqual(['Anton', 'Ärger', 'berta', 'zebra']);
|
||||
});
|
||||
|
||||
it('status: rot, gelb, grau, gruen, bei Gleichstand nach Name', () => {
|
||||
const list = [
|
||||
make('g2', 'green'),
|
||||
make('r2', 'red'),
|
||||
make('u1', 'unknown'),
|
||||
make('y1', 'yellow'),
|
||||
make('r1', 'red'),
|
||||
make('g1', 'green'),
|
||||
];
|
||||
expect(names(sortClouds(list, 'status'))).toEqual(['r1', 'r2', 'y1', 'u1', 'g1', 'g2']);
|
||||
});
|
||||
|
||||
it('version: aelteste zuerst, ohne Version zuletzt', () => {
|
||||
const list = [
|
||||
make('a', 'green', '34.0.10'),
|
||||
make('b', 'green', null),
|
||||
make('c', 'green', '33.0.9'),
|
||||
make('d', 'green', '34.0.2'),
|
||||
];
|
||||
expect(names(sortClouds(list, 'version'))).toEqual(['c', 'd', 'a', 'b']);
|
||||
});
|
||||
|
||||
it('eol: frueheste zuerst, ohne Datum zuletzt', () => {
|
||||
const list = [
|
||||
make('a', 'green', '1.0.0', '2027-06-30'),
|
||||
make('b'),
|
||||
make('c', 'green', '1.0.0', '2026-09-30'),
|
||||
];
|
||||
expect(names(sortClouds(list, 'eol'))).toEqual(['c', 'a', 'b']);
|
||||
});
|
||||
|
||||
it('veraendert die Eingabe nicht', () => {
|
||||
const list = [make('b'), make('a')];
|
||||
const copy = [...list];
|
||||
sortClouds(list, 'name');
|
||||
expect(list).toEqual(copy);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sortier-Vorliebe', () => {
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('merkt die Auswahl je Benutzer unter eigenem Schluessel', () => {
|
||||
writeSortPreference('u1', 'status');
|
||||
expect(window.localStorage.getItem('tessera:nextcloud-status:sort:u1')).toBe('status');
|
||||
expect(readSortPreference('u1')).toBe('status');
|
||||
expect(readSortPreference('u2')).toBe('name');
|
||||
});
|
||||
|
||||
it('ein unbekannter gespeicherter Wert ergibt name', () => {
|
||||
window.localStorage.setItem('tessera:nextcloud-status:sort:u1', 'quatsch');
|
||||
expect(readSortPreference('u1')).toBe('name');
|
||||
});
|
||||
|
||||
it('ein gesperrter Speicher wirft nie', () => {
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||
throw new Error('gesperrt');
|
||||
});
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new Error('gesperrt');
|
||||
});
|
||||
expect(readSortPreference('u1')).toBe('name');
|
||||
expect(() => writeSortPreference('u1', 'eol')).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { NextcloudInstance, RatingLevel } from '@/lib/nextcloud-status-api';
|
||||
|
||||
/**
|
||||
* Sortierung der Cloud-Kacheln (quick-261002-k67, L-07, D-G) — reine
|
||||
* Funktionen, kein React.
|
||||
*/
|
||||
export type SortKey = 'name' | 'status' | 'version' | 'eol';
|
||||
|
||||
export const SORT_KEYS: readonly SortKey[] = ['name', 'status', 'version', 'eol'];
|
||||
|
||||
/** Rot zuerst, dann Gelb, Grau, Gruen. */
|
||||
const LEVEL_ORDER: Record<RatingLevel, number> = { red: 0, yellow: 1, unknown: 2, green: 3 };
|
||||
|
||||
function byName(a: NextcloudInstance, b: NextcloudInstance): number {
|
||||
return a.customerName.localeCompare(b.customerName, 'de', { sensitivity: 'base' });
|
||||
}
|
||||
|
||||
function versionTriple(raw: string | null): [number, number, number] | null {
|
||||
if (!raw) return null;
|
||||
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(raw.trim());
|
||||
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
||||
}
|
||||
|
||||
function compareVersions(a: NextcloudInstance, b: NextcloudInstance): number {
|
||||
const va = versionTriple(a.status.versionString);
|
||||
const vb = versionTriple(b.status.versionString);
|
||||
if (!va && !vb) return 0;
|
||||
if (!va) return 1; // unbekannt zuletzt
|
||||
if (!vb) return -1;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (va[i] !== vb[i]) return va[i] - vb[i];
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function compareEol(a: NextcloudInstance, b: NextcloudInstance): number {
|
||||
const ea = a.rating.eolDate;
|
||||
const eb = b.rating.eolDate;
|
||||
if (!ea && !eb) return 0;
|
||||
if (!ea) return 1; // unbekannt zuletzt
|
||||
if (!eb) return -1;
|
||||
return ea < eb ? -1 : ea > eb ? 1 : 0;
|
||||
}
|
||||
|
||||
/** Liefert eine neue, sortierte Liste; die Eingabe bleibt unveraendert. Gleiche Werte nach Kundenname. */
|
||||
export function sortClouds(items: NextcloudInstance[], key: SortKey): NextcloudInstance[] {
|
||||
const compare = (a: NextcloudInstance, b: NextcloudInstance): number => {
|
||||
switch (key) {
|
||||
case 'status':
|
||||
return LEVEL_ORDER[a.rating.level] - LEVEL_ORDER[b.rating.level];
|
||||
case 'version':
|
||||
return compareVersions(a, b);
|
||||
case 'eol':
|
||||
return compareEol(a, b);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
return [...items].sort((a, b) => compare(a, b) || byName(a, b));
|
||||
}
|
||||
|
||||
function storageKey(userId: string): string {
|
||||
return `tessera:nextcloud-status:sort:${userId}`;
|
||||
}
|
||||
|
||||
/** Gemerkte Sortierung dieses Benutzers; unbekannter Wert oder gesperrter Speicher ergeben 'name'. */
|
||||
export function readSortPreference(userId: string): SortKey {
|
||||
try {
|
||||
const stored = window.localStorage.getItem(storageKey(userId));
|
||||
return (SORT_KEYS as readonly string[]).includes(stored ?? '') ? (stored as SortKey) : 'name';
|
||||
} catch {
|
||||
return 'name';
|
||||
}
|
||||
}
|
||||
|
||||
/** Merkt die Sortierung; ein gesperrter Speicher wird still uebergangen. */
|
||||
export function writeSortPreference(userId: string, key: SortKey): void {
|
||||
try {
|
||||
window.localStorage.setItem(storageKey(userId), key);
|
||||
} catch {
|
||||
/* Speicher gesperrt oder voll — die Auswahl gilt dann nur fuer diese Sitzung */
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user