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,5 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-i18n';
|
||||
import { onMount } from 'svelte';
|
||||
import AppHealthBadge from './AppHealthBadge.svelte';
|
||||
import SparklineChart from './SparklineChart.svelte';
|
||||
|
||||
interface AppWithStatus {
|
||||
id: string;
|
||||
@@ -12,14 +15,40 @@
|
||||
statuses: Array<{ status: string; responseTime: number | null; checkedAt: string | Date }>;
|
||||
}
|
||||
|
||||
interface StatusPoint {
|
||||
status: string;
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
app: AppWithStatus;
|
||||
}
|
||||
|
||||
let { app }: Props = $props();
|
||||
|
||||
let historyData: StatusPoint[] = $state([]);
|
||||
let uptimePercent: number | null = $state(null);
|
||||
let historyLoading = $state(true);
|
||||
|
||||
const currentStatus = $derived(app.statuses?.[0]?.status ?? 'unknown');
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/apps/${app.id}/history`);
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
if (json.success && json.data) {
|
||||
historyData = json.data.history ?? [];
|
||||
uptimePercent = json.data.uptimePercent ?? null;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silently fail — sparkline is non-critical
|
||||
} finally {
|
||||
historyLoading = false;
|
||||
}
|
||||
});
|
||||
|
||||
const iconDisplay = $derived.by(() => {
|
||||
if (!app.icon) return null;
|
||||
|
||||
@@ -75,6 +104,18 @@
|
||||
<p class="mt-1 line-clamp-2 text-xs text-muted-foreground">{app.description}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Sparkline -->
|
||||
{#if historyLoading}
|
||||
<div class="mt-2 h-5 w-20 animate-pulse rounded bg-muted"></div>
|
||||
{:else if historyData.length > 0}
|
||||
<div class="mt-2 flex items-center gap-1.5">
|
||||
<SparklineChart data={historyData} />
|
||||
{#if uptimePercent !== null}
|
||||
<span class="text-[10px] text-muted-foreground">{uptimePercent}% {$t('app.uptime')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/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"
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
interface StatusPoint {
|
||||
status: string;
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: StatusPoint[];
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
let { data, width = 80, height = 20 }: Props = $props();
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
online: '#22c55e',
|
||||
offline: '#ef4444',
|
||||
degraded: '#eab308',
|
||||
unknown: '#6b7280'
|
||||
};
|
||||
|
||||
const barWidth = $derived(data.length > 0 ? Math.max(1, (width - 2) / data.length) : 1);
|
||||
|
||||
const bars = $derived(
|
||||
data.map((point, i) => ({
|
||||
x: 1 + i * barWidth,
|
||||
color: STATUS_COLORS[point.status] ?? STATUS_COLORS.unknown
|
||||
}))
|
||||
);
|
||||
</script>
|
||||
|
||||
<svg
|
||||
{width}
|
||||
{height}
|
||||
viewBox="0 0 {width} {height}"
|
||||
role="img"
|
||||
aria-label="Status history sparkline"
|
||||
class="inline-block align-middle"
|
||||
>
|
||||
{#each bars as bar}
|
||||
<rect
|
||||
x={bar.x}
|
||||
y={2}
|
||||
width={Math.max(0.5, barWidth - 0.5)}
|
||||
height={height - 4}
|
||||
fill={bar.color}
|
||||
rx="0.5"
|
||||
opacity="0.85"
|
||||
/>
|
||||
{/each}
|
||||
</svg>
|
||||
Reference in New Issue
Block a user