feat(phase3): import/export, sparklines, user theme overrides

- JSON import/export with conflict resolution (skip/overwrite) + admin UI
- Ping history sparklines on AppWidget and AppCard (24h, 288 points)
- Hourly cleanup job for old AppStatus records
- User theme preferences (hue, saturation, mode, background, locale)
- Settings page with ThemeCustomizer (sliders, toggles, live preview)
- Prisma migration for user preference fields
- i18n translations for all new strings (EN/RU)
This commit is contained in:
2026-03-25 00:51:01 +03:00
parent d155b3ce4a
commit c6a7de895d
30 changed files with 1633 additions and 44 deletions
+154
View File
@@ -0,0 +1,154 @@
import { prisma } from '../prisma.js';
import { DEFAULTS } from '$lib/utils/constants.js';
export interface ExportData {
readonly version: string;
readonly exportedAt: string;
readonly apps: ReadonlyArray<ExportApp>;
readonly boards: ReadonlyArray<ExportBoard>;
readonly groups: ReadonlyArray<ExportGroup>;
readonly settings: ExportSettings;
}
export interface ExportApp {
readonly name: string;
readonly url: string;
readonly icon: string | null;
readonly iconType: string;
readonly description: string | null;
readonly category: string | null;
readonly tags: string;
readonly healthcheckEnabled: boolean;
readonly healthcheckInterval: number;
readonly healthcheckMethod: string;
readonly healthcheckExpectedStatus: number;
readonly healthcheckTimeout: number;
}
export interface ExportWidget {
readonly type: string;
readonly order: number;
readonly config: string;
readonly appName: string | null;
}
export interface ExportSection {
readonly title: string;
readonly icon: string | null;
readonly order: number;
readonly isExpandedByDefault: boolean;
readonly widgets: ReadonlyArray<ExportWidget>;
}
export interface ExportBoard {
readonly name: string;
readonly icon: string | null;
readonly description: string | null;
readonly isDefault: boolean;
readonly isGuestAccessible: boolean;
readonly backgroundConfig: string | null;
readonly sections: ReadonlyArray<ExportSection>;
}
export interface ExportGroup {
readonly name: string;
readonly description: string | null;
readonly isDefault: boolean;
}
export interface ExportSettings {
readonly authMode: string;
readonly registrationEnabled: boolean;
readonly defaultTheme: string;
readonly defaultPrimaryColor: string;
readonly healthcheckDefaults: string;
}
export async function exportAllData(): Promise<ExportData> {
const [apps, boards, groups, settings] = await Promise.all([
prisma.app.findMany({
orderBy: { name: 'asc' }
}),
prisma.board.findMany({
orderBy: { createdAt: 'asc' },
include: {
sections: {
orderBy: { order: 'asc' },
include: {
widgets: {
orderBy: { order: 'asc' },
include: { app: { select: { name: true } } }
}
}
}
}
}),
prisma.group.findMany({
orderBy: { name: 'asc' }
}),
prisma.systemSettings.upsert({
where: { id: DEFAULTS.SYSTEM_SETTINGS_ID },
update: {},
create: { id: DEFAULTS.SYSTEM_SETTINGS_ID }
})
]);
const exportApps: ReadonlyArray<ExportApp> = apps.map((app) => ({
name: app.name,
url: app.url,
icon: app.icon,
iconType: app.iconType,
description: app.description,
category: app.category,
tags: app.tags,
healthcheckEnabled: app.healthcheckEnabled,
healthcheckInterval: app.healthcheckInterval,
healthcheckMethod: app.healthcheckMethod,
healthcheckExpectedStatus: app.healthcheckExpectedStatus,
healthcheckTimeout: app.healthcheckTimeout
}));
const exportBoards: ReadonlyArray<ExportBoard> = boards.map((board) => ({
name: board.name,
icon: board.icon,
description: board.description,
isDefault: board.isDefault,
isGuestAccessible: board.isGuestAccessible,
backgroundConfig: board.backgroundConfig,
sections: board.sections.map((section) => ({
title: section.title,
icon: section.icon,
order: section.order,
isExpandedByDefault: section.isExpandedByDefault,
widgets: section.widgets.map((widget) => ({
type: widget.type,
order: widget.order,
config: widget.config,
appName: widget.app?.name ?? null
}))
}))
}));
const exportGroups: ReadonlyArray<ExportGroup> = groups.map((group) => ({
name: group.name,
description: group.description,
isDefault: group.isDefault
}));
const exportSettings: ExportSettings = {
authMode: settings.authMode,
registrationEnabled: settings.registrationEnabled,
defaultTheme: settings.defaultTheme,
defaultPrimaryColor: settings.defaultPrimaryColor,
healthcheckDefaults: settings.healthcheckDefaults
};
return {
version: '1.0',
exportedAt: new Date().toISOString(),
apps: exportApps,
boards: exportBoards,
groups: exportGroups,
settings: exportSettings
};
}
@@ -1,6 +1,10 @@
import * as appService from './appService.js';
import { prisma } from '../prisma.js';
import { AppStatusValue } from '$lib/utils/constants.js';
const MAX_RECORDS_PER_APP = 288;
const RETENTION_HOURS = 24;
export interface HealthcheckResult {
readonly appId: string;
readonly status: string;
@@ -81,3 +85,50 @@ export async function checkAllApps(): Promise<readonly HealthcheckResult[]> {
return outcomes;
}
/**
* Prune old AppStatus records.
* - Deletes records older than RETENTION_HOURS
* - Keeps at most MAX_RECORDS_PER_APP per app (deletes oldest excess)
*/
export async function pruneOldStatuses(): Promise<void> {
const cutoff = new Date(Date.now() - RETENTION_HOURS * 60 * 60 * 1000);
// Step 1: Delete all records older than retention period
await prisma.appStatus.deleteMany({
where: {
checkedAt: { lt: cutoff }
}
});
// Step 2: For each app, keep at most MAX_RECORDS_PER_APP
const apps = await prisma.app.findMany({
where: { healthcheckEnabled: true },
select: { id: true }
});
for (const app of apps) {
const count = await prisma.appStatus.count({
where: { appId: app.id }
});
if (count > MAX_RECORDS_PER_APP) {
const excess = count - MAX_RECORDS_PER_APP;
// Find the oldest records to delete
const oldestRecords = await prisma.appStatus.findMany({
where: { appId: app.id },
orderBy: { checkedAt: 'asc' },
take: excess,
select: { id: true }
});
if (oldestRecords.length > 0) {
await prisma.appStatus.deleteMany({
where: {
id: { in: oldestRecords.map((r) => r.id) }
}
});
}
}
}
}
+226
View File
@@ -0,0 +1,226 @@
import { prisma } from '../prisma.js';
import { importDataSchema } from '$lib/utils/validators.js';
import { DEFAULTS } from '$lib/utils/constants.js';
import type { ExportData } from './exportService.js';
export type ImportMode = 'skip' | 'overwrite';
export interface ImportResult {
readonly apps: { readonly created: number; readonly updated: number; readonly skipped: number };
readonly boards: { readonly created: number; readonly updated: number; readonly skipped: number };
readonly groups: { readonly created: number; readonly updated: number; readonly skipped: number };
readonly settingsUpdated: boolean;
}
export function validateImportData(data: unknown): { success: true; data: ExportData } | { success: false; errors: string[] } {
const parsed = importDataSchema.safeParse(data);
if (!parsed.success) {
const errors = parsed.error.errors.map((e) => `${e.path.join('.')}: ${e.message}`);
return { success: false, errors };
}
return { success: true, data: parsed.data as ExportData };
}
export async function importData(data: ExportData, mode: ImportMode): Promise<ImportResult> {
const result = {
apps: { created: 0, updated: 0, skipped: 0 },
boards: { created: 0, updated: 0, skipped: 0 },
groups: { created: 0, updated: 0, skipped: 0 },
settingsUpdated: false
};
await prisma.$transaction(async (tx) => {
// --- Import Apps ---
const appNameToId = new Map<string, string>();
for (const appData of data.apps) {
const existing = await tx.app.findFirst({ where: { name: appData.name } });
if (existing) {
appNameToId.set(appData.name, existing.id);
if (mode === 'skip') {
result.apps.skipped++;
continue;
}
// overwrite
await tx.app.update({
where: { id: existing.id },
data: {
url: appData.url,
icon: appData.icon,
iconType: appData.iconType,
description: appData.description,
category: appData.category,
tags: appData.tags,
healthcheckEnabled: appData.healthcheckEnabled,
healthcheckInterval: appData.healthcheckInterval,
healthcheckMethod: appData.healthcheckMethod,
healthcheckExpectedStatus: appData.healthcheckExpectedStatus,
healthcheckTimeout: appData.healthcheckTimeout
}
});
result.apps.updated++;
} else {
const created = await tx.app.create({
data: {
name: appData.name,
url: appData.url,
icon: appData.icon,
iconType: appData.iconType,
description: appData.description,
category: appData.category,
tags: appData.tags,
healthcheckEnabled: appData.healthcheckEnabled,
healthcheckInterval: appData.healthcheckInterval,
healthcheckMethod: appData.healthcheckMethod,
healthcheckExpectedStatus: appData.healthcheckExpectedStatus,
healthcheckTimeout: appData.healthcheckTimeout
}
});
appNameToId.set(appData.name, created.id);
result.apps.created++;
}
}
// --- Import Groups ---
for (const groupData of data.groups) {
const existing = await tx.group.findUnique({ where: { name: groupData.name } });
if (existing) {
if (mode === 'skip') {
result.groups.skipped++;
continue;
}
await tx.group.update({
where: { id: existing.id },
data: {
description: groupData.description,
isDefault: groupData.isDefault
}
});
result.groups.updated++;
} else {
await tx.group.create({
data: {
name: groupData.name,
description: groupData.description,
isDefault: groupData.isDefault
}
});
result.groups.created++;
}
}
// --- Import Boards (with sections and widgets) ---
for (const boardData of data.boards) {
const existing = await tx.board.findFirst({ where: { name: boardData.name } });
if (existing) {
if (mode === 'skip') {
result.boards.skipped++;
continue;
}
// Overwrite: update board, delete old sections, recreate
await tx.section.deleteMany({ where: { boardId: existing.id } });
await tx.board.update({
where: { id: existing.id },
data: {
icon: boardData.icon,
description: boardData.description,
isDefault: boardData.isDefault,
isGuestAccessible: boardData.isGuestAccessible,
backgroundConfig: boardData.backgroundConfig
}
});
for (const sectionData of boardData.sections) {
const section = await tx.section.create({
data: {
boardId: existing.id,
title: sectionData.title,
icon: sectionData.icon,
order: sectionData.order,
isExpandedByDefault: sectionData.isExpandedByDefault
}
});
for (const widgetData of sectionData.widgets) {
const appId = widgetData.appName ? (appNameToId.get(widgetData.appName) ?? null) : null;
await tx.widget.create({
data: {
sectionId: section.id,
type: widgetData.type,
order: widgetData.order,
config: widgetData.config,
appId
}
});
}
}
result.boards.updated++;
} else {
const board = await tx.board.create({
data: {
name: boardData.name,
icon: boardData.icon,
description: boardData.description,
isDefault: boardData.isDefault,
isGuestAccessible: boardData.isGuestAccessible,
backgroundConfig: boardData.backgroundConfig
}
});
for (const sectionData of boardData.sections) {
const section = await tx.section.create({
data: {
boardId: board.id,
title: sectionData.title,
icon: sectionData.icon,
order: sectionData.order,
isExpandedByDefault: sectionData.isExpandedByDefault
}
});
for (const widgetData of sectionData.widgets) {
const appId = widgetData.appName ? (appNameToId.get(widgetData.appName) ?? null) : null;
await tx.widget.create({
data: {
sectionId: section.id,
type: widgetData.type,
order: widgetData.order,
config: widgetData.config,
appId
}
});
}
}
result.boards.created++;
}
}
// --- Import Settings (always merge) ---
if (data.settings) {
const settingsData: Record<string, unknown> = {};
const s = data.settings;
if (s.authMode !== undefined) settingsData.authMode = s.authMode;
if (s.registrationEnabled !== undefined) settingsData.registrationEnabled = s.registrationEnabled;
if (s.defaultTheme !== undefined) settingsData.defaultTheme = s.defaultTheme;
if (s.defaultPrimaryColor !== undefined) settingsData.defaultPrimaryColor = s.defaultPrimaryColor;
if (s.healthcheckDefaults !== undefined) settingsData.healthcheckDefaults = s.healthcheckDefaults;
if (Object.keys(settingsData).length > 0) {
await tx.systemSettings.upsert({
where: { id: DEFAULTS.SYSTEM_SETTINGS_ID },
update: settingsData,
create: { id: DEFAULTS.SYSTEM_SETTINGS_ID, ...settingsData }
});
result.settingsUpdated = true;
}
}
});
return result;
}