feat(mvp): phase 4 - app registry & healthcheck

Add app CRUD API endpoints, healthcheck service with node-cron scheduler,
icon resolver (Lucide, Simple Icons, CDN, uploads), app management UI
with Superforms, health badge component, and Docker health endpoint.
This commit is contained in:
2026-03-24 20:53:50 +03:00
parent 2c001df322
commit 4d941f566f
17 changed files with 962 additions and 21 deletions
@@ -0,0 +1,39 @@
import cron from 'node-cron';
import { checkAllApps } from '$lib/server/services/healthcheckService.js';
let scheduledTask: cron.ScheduledTask | null = null;
/**
* Start the healthcheck scheduler.
* Runs checkAllApps on a cron schedule (default: every 60 seconds).
*/
export function startScheduler(cronExpression: string = '* * * * *'): void {
if (scheduledTask) {
return;
}
scheduledTask = cron.schedule(cronExpression, async () => {
try {
await checkAllApps();
} catch {
// Swallow errors to prevent scheduler crash
}
});
// Run an initial check shortly after startup
setTimeout(() => {
checkAllApps().catch(() => {
// Swallow initial check errors
});
}, 5000);
}
/**
* Stop the healthcheck scheduler.
*/
export function stopScheduler(): void {
if (scheduledTask) {
scheduledTask.stop();
scheduledTask = null;
}
}
@@ -0,0 +1,83 @@
import * as appService from './appService.js';
import { AppStatusValue } from '$lib/utils/constants.js';
export interface HealthcheckResult {
readonly appId: string;
readonly status: string;
readonly responseTime: number | null;
}
/**
* Perform a health check on a single app by making an HTTP request to its URL.
*/
export async function checkAppHealth(app: {
readonly id: string;
readonly url: string;
readonly healthcheckMethod: string;
readonly healthcheckExpectedStatus: number;
readonly healthcheckTimeout: number;
}): Promise<HealthcheckResult> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), app.healthcheckTimeout);
const start = Date.now();
try {
const response = await fetch(app.url, {
method: app.healthcheckMethod,
signal: controller.signal,
redirect: 'follow',
headers: {
'User-Agent': 'WebAppLauncher-Healthcheck/1.0'
}
});
const responseTime = Date.now() - start;
const status =
response.status === app.healthcheckExpectedStatus
? AppStatusValue.ONLINE
: AppStatusValue.DEGRADED;
return { appId: app.id, status, responseTime };
} catch (err) {
const responseTime = Date.now() - start;
if (err instanceof DOMException && err.name === 'AbortError') {
return { appId: app.id, status: AppStatusValue.OFFLINE, responseTime };
}
return { appId: app.id, status: AppStatusValue.OFFLINE, responseTime: null };
} finally {
clearTimeout(timeoutId);
}
}
/**
* Check all apps that have healthcheck enabled, record their statuses.
*/
export async function checkAllApps(): Promise<readonly HealthcheckResult[]> {
const targets = await appService.getHealthcheckTargets();
if (targets.length === 0) {
return [];
}
const results = await Promise.allSettled(targets.map((target) => checkAppHealth(target)));
const outcomes: HealthcheckResult[] = [];
for (const result of results) {
if (result.status === 'fulfilled') {
const { appId, status, responseTime } = result.value;
try {
await appService.recordStatus(appId, status, responseTime);
} catch {
// Log but don't fail the whole batch
}
outcomes.push(result.value);
}
}
return outcomes;
}
+50
View File
@@ -0,0 +1,50 @@
import type { IconType } from '$lib/utils/constants.js';
export interface ResolvedIcon {
readonly type: IconType;
readonly value: string;
readonly src?: string;
}
/**
* Resolve an icon reference into a renderable object.
*
* - 'lucide' → { type, value } — render via lucide-svelte component lookup
* - 'simple' → { type, value, src } — SVG path from simple-icons
* - 'url' → { type, value, src } — direct image URL
* - 'emoji' → { type, value } — render as text
*/
export function resolveIcon(iconType: IconType, iconValue: string | null): ResolvedIcon | null {
if (!iconValue) {
return null;
}
switch (iconType) {
case 'lucide':
return { type: 'lucide', value: iconValue };
case 'simple': {
try {
// simple-icons exports an object keyed by slug prefixed with 'si'
// e.g., siGithub, siDocker. We look up by slug.
const slug = iconValue.toLowerCase().replace(/[^a-z0-9]/g, '');
return {
type: 'simple',
value: iconValue,
src: `https://cdn.simpleicons.org/${slug}`
};
} catch {
return { type: 'simple', value: iconValue };
}
}
case 'url':
return { type: 'url', value: iconValue, src: iconValue };
case 'emoji':
return { type: 'emoji', value: iconValue };
default:
return { type: 'lucide', value: iconValue };
}
}