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:
@@ -1,11 +1,13 @@
|
||||
import cron from 'node-cron';
|
||||
import { checkAllApps } from '$lib/server/services/healthcheckService.js';
|
||||
import { checkAllApps, pruneOldStatuses } from '$lib/server/services/healthcheckService.js';
|
||||
|
||||
let scheduledTask: cron.ScheduledTask | null = null;
|
||||
let cleanupTask: cron.ScheduledTask | null = null;
|
||||
|
||||
/**
|
||||
* Start the healthcheck scheduler.
|
||||
* Runs checkAllApps on a cron schedule (default: every 60 seconds).
|
||||
* Also starts an hourly cleanup job to prune old status records.
|
||||
*/
|
||||
export function startScheduler(cronExpression: string = '* * * * *'): void {
|
||||
if (scheduledTask) {
|
||||
@@ -20,6 +22,15 @@ export function startScheduler(cronExpression: string = '* * * * *'): void {
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup job: run every hour at minute 0
|
||||
cleanupTask = cron.schedule('0 * * * *', async () => {
|
||||
try {
|
||||
await pruneOldStatuses();
|
||||
} catch {
|
||||
// Swallow errors to prevent scheduler crash
|
||||
}
|
||||
});
|
||||
|
||||
// Run an initial check shortly after startup
|
||||
setTimeout(() => {
|
||||
checkAllApps().catch(() => {
|
||||
@@ -29,11 +40,15 @@ export function startScheduler(cronExpression: string = '* * * * *'): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the healthcheck scheduler.
|
||||
* Stop the healthcheck scheduler and cleanup job.
|
||||
*/
|
||||
export function stopScheduler(): void {
|
||||
if (scheduledTask) {
|
||||
scheduledTask.stop();
|
||||
scheduledTask = null;
|
||||
}
|
||||
if (cleanupTask) {
|
||||
cleanupTask.stop();
|
||||
cleanupTask = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user