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:
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user