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
+85
View File
@@ -0,0 +1,85 @@
<script lang="ts">
import AppHealthBadge from './AppHealthBadge.svelte';
interface AppWithStatus {
id: string;
name: string;
url: string;
icon: string | null;
iconType: string;
description: string | null;
category: string | null;
statuses: Array<{ status: string; responseTime: number | null; checkedAt: string | Date }>;
}
interface Props {
app: AppWithStatus;
}
let { app }: Props = $props();
const currentStatus = $derived(app.statuses?.[0]?.status ?? 'unknown');
const iconDisplay = $derived.by(() => {
if (!app.icon) return null;
switch (app.iconType) {
case 'emoji':
return { kind: 'emoji' as const, value: app.icon };
case 'url':
return { kind: 'image' as const, src: app.icon };
case 'simple':
return {
kind: 'image' as const,
src: `https://cdn.simpleicons.org/${app.icon.toLowerCase()}`
};
default:
return { kind: 'text' as const, value: app.icon };
}
});
</script>
<a
href={app.url}
target="_blank"
rel="noopener noreferrer"
class="group flex flex-col rounded-lg border border-border bg-card p-4 transition-colors hover:border-primary/50 hover:bg-accent/50"
title={app.description ?? app.name}
>
<div class="mb-3 flex items-start justify-between">
<div
class="flex h-10 w-10 items-center justify-center rounded-lg bg-muted text-lg text-muted-foreground"
>
{#if iconDisplay?.kind === 'emoji'}
<span class="text-xl">{iconDisplay.value}</span>
{:else if iconDisplay?.kind === 'image'}
<img
src={iconDisplay.src}
alt="{app.name} icon"
class="h-6 w-6 rounded object-contain"
/>
{:else if iconDisplay?.kind === 'text'}
<span class="text-xs font-medium">{iconDisplay.value}</span>
{:else}
<span class="text-xs font-bold">{app.name.charAt(0).toUpperCase()}</span>
{/if}
</div>
<AppHealthBadge status={currentStatus} />
</div>
<h3 class="truncate text-sm font-semibold text-card-foreground group-hover:text-primary">
{app.name}
</h3>
{#if app.description}
<p class="mt-1 line-clamp-2 text-xs text-muted-foreground">{app.description}</p>
{/if}
{#if app.category}
<span
class="mt-2 inline-block self-start rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground"
>
{app.category}
</span>
{/if}
</a>
+230
View File
@@ -0,0 +1,230 @@
<script lang="ts">
import { superForm, type SuperValidated } from 'sveltekit-superforms';
import type { z } from 'zod';
import type { createAppSchema } from '$lib/utils/validators.js';
import AppIconPicker from './AppIconPicker.svelte';
type AppSchema = z.infer<typeof createAppSchema>;
interface Props {
form: SuperValidated<AppSchema>;
action?: string;
}
let { form: formData, action = '?/create' }: Props = $props();
const { form, errors, enhance, submitting } = superForm(formData, {
resetForm: true
});
let showAdvanced = $state(false);
</script>
<form method="POST" {action} use:enhance class="space-y-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="name" class="mb-1 block text-sm font-medium text-card-foreground">
Name <span class="text-destructive">*</span>
</label>
<input
id="name"
name="name"
type="text"
bind:value={$form.name}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="My Application"
/>
{#if $errors.name}
<p class="mt-1 text-sm text-destructive">{$errors.name[0]}</p>
{/if}
</div>
<div>
<label for="url" class="mb-1 block text-sm font-medium text-card-foreground">
URL <span class="text-destructive">*</span>
</label>
<input
id="url"
name="url"
type="url"
bind:value={$form.url}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="https://my-app.local:8080"
/>
{#if $errors.url}
<p class="mt-1 text-sm text-destructive">{$errors.url[0]}</p>
{/if}
</div>
</div>
<div>
<label for="description" class="mb-1 block text-sm font-medium text-card-foreground">
Description
</label>
<input
id="description"
name="description"
type="text"
bind:value={$form.description}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="Brief description of this app"
/>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="category" class="mb-1 block text-sm font-medium text-card-foreground">
Category
</label>
<input
id="category"
name="category"
type="text"
bind:value={$form.category}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="e.g. Media, Monitoring, Storage"
/>
</div>
<div>
<label for="tags" class="mb-1 block text-sm font-medium text-card-foreground">
Tags
</label>
<input
id="tags"
name="tags"
type="text"
bind:value={$form.tags}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="Comma-separated tags"
/>
</div>
</div>
<AppIconPicker
iconType={$form.iconType ?? 'lucide'}
iconValue={$form.icon ?? ''}
onchange={(type, value) => {
$form.iconType = type;
$form.icon = value;
}}
/>
<input type="hidden" name="icon" value={$form.icon ?? ''} />
<input type="hidden" name="iconType" value={$form.iconType ?? 'lucide'} />
<button
type="button"
onclick={() => (showAdvanced = !showAdvanced)}
class="text-sm text-muted-foreground hover:text-foreground"
>
{showAdvanced ? 'Hide' : 'Show'} Healthcheck Settings
</button>
{#if showAdvanced}
<div class="space-y-4 rounded-md border border-border p-4">
<div class="flex items-center gap-2">
<input
id="healthcheckEnabled"
name="healthcheckEnabled"
type="checkbox"
bind:checked={$form.healthcheckEnabled}
class="rounded border-input"
/>
<label for="healthcheckEnabled" class="text-sm text-card-foreground">
Enable Healthcheck
</label>
</div>
{#if $form.healthcheckEnabled}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<label
for="healthcheckMethod"
class="mb-1 block text-sm font-medium text-card-foreground"
>
Method
</label>
<select
id="healthcheckMethod"
name="healthcheckMethod"
bind:value={$form.healthcheckMethod}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="GET">GET</option>
<option value="HEAD">HEAD</option>
</select>
</div>
<div>
<label
for="healthcheckExpectedStatus"
class="mb-1 block text-sm font-medium text-card-foreground"
>
Expected Status
</label>
<input
id="healthcheckExpectedStatus"
name="healthcheckExpectedStatus"
type="number"
bind:value={$form.healthcheckExpectedStatus}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
min="100"
max="599"
/>
</div>
<div>
<label
for="healthcheckTimeout"
class="mb-1 block text-sm font-medium text-card-foreground"
>
Timeout (ms)
</label>
<input
id="healthcheckTimeout"
name="healthcheckTimeout"
type="number"
bind:value={$form.healthcheckTimeout}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
min="1000"
max="30000"
step="1000"
/>
</div>
</div>
<div>
<label
for="healthcheckInterval"
class="mb-1 block text-sm font-medium text-card-foreground"
>
Interval (seconds)
</label>
<input
id="healthcheckInterval"
name="healthcheckInterval"
type="number"
bind:value={$form.healthcheckInterval}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
min="30"
max="86400"
/>
</div>
{/if}
</div>
{/if}
<div class="flex justify-end">
<button
type="submit"
disabled={$submitting}
class="rounded-md bg-primary px-6 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{#if $submitting}
Saving...
{:else}
Save App
{/if}
</button>
</div>
</form>
@@ -0,0 +1,25 @@
<script lang="ts">
interface Props {
status: string;
}
let { status }: Props = $props();
const config = $derived.by(() => {
switch (status) {
case 'online':
return { color: 'bg-green-500', text: 'Online' };
case 'offline':
return { color: 'bg-red-500', text: 'Offline' };
case 'degraded':
return { color: 'bg-yellow-500', text: 'Degraded' };
default:
return { color: 'bg-gray-500', text: 'Unknown' };
}
});
</script>
<span class="inline-flex items-center gap-1.5 text-xs">
<span class="inline-block h-2 w-2 rounded-full {config.color}"></span>
<span class="text-muted-foreground">{config.text}</span>
</span>
@@ -0,0 +1,65 @@
<script lang="ts">
interface Props {
iconType: string;
iconValue: string;
onchange?: (type: string, value: string) => void;
}
let { iconType = $bindable('lucide'), iconValue = $bindable(''), onchange }: Props = $props();
function handleTypeChange(e: Event) {
const target = e.target as HTMLSelectElement;
iconType = target.value;
iconValue = '';
onchange?.(iconType, iconValue);
}
function handleValueChange(e: Event) {
const target = e.target as HTMLInputElement;
iconValue = target.value;
onchange?.(iconType, iconValue);
}
</script>
<div class="space-y-2">
<label class="block text-sm font-medium text-card-foreground">Icon</label>
<div class="flex gap-2">
<select
value={iconType}
onchange={handleTypeChange}
class="rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="lucide">Lucide Icon</option>
<option value="simple">Simple Icons</option>
<option value="url">Image URL</option>
<option value="emoji">Emoji</option>
</select>
<input
type="text"
value={iconValue}
oninput={handleValueChange}
placeholder={iconType === 'lucide'
? 'e.g. globe, server, home'
: iconType === 'simple'
? 'e.g. github, docker'
: iconType === 'url'
? 'https://example.com/icon.png'
: 'e.g. 🌐'}
class="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
{#if iconType === 'emoji' && iconValue}
<div class="text-2xl">{iconValue}</div>
{:else if iconType === 'url' && iconValue}
<img src={iconValue} alt="Icon preview" class="h-8 w-8 rounded object-contain" />
{:else if iconType === 'simple' && iconValue}
<img
src="https://cdn.simpleicons.org/{iconValue.toLowerCase()}"
alt="{iconValue} icon"
class="h-8 w-8"
/>
{/if}
</div>