feat: add monitoring dashboard (Prometheus + Grafana + Umami + Admin)

Phase 1: Infrastructure
- Add docker/prometheus/prometheus.yml with scrape configs for all services
- Add docker/grafana/provisioning for auto-configured datasources
- Add docker/grafana/dashboards (system-overview, backends-docker)
- Update docker-compose.macmini.yml with monitoring services:
  - prometheus, grafana, node-exporter, cadvisor
  - postgres-exporter, redis-exporter, umami
- Add grafana.mana.how and analytics.mana.how to Caddyfile

Phase 2: Backend Metrics
- Create packages/shared-nestjs-metrics with:
  - MetricsModule (auto /metrics endpoint)
  - MetricsService (Counter, Histogram, Gauge helpers)
  - MetricsMiddleware (auto HTTP request tracking)

Phase 3: Umami Web Analytics
- Add Umami tracking scripts to all landing pages
- Add Umami tracking scripts to all web apps
- Create scripts/mac-mini/setup-umami-db.sh

Phase 4: Admin Dashboard (ManaCore Web)
- Add admin routes: /admin, /admin/users, /admin/system
- Create StatCard, QuickLinks, UserTable components
- Add Admin link to navigation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-01-23 15:31:39 +01:00
parent ad7a84feef
commit 6d86a08d63
36 changed files with 2779 additions and 559 deletions

View file

@ -37,6 +37,12 @@ const {
rel="stylesheet"
/>
<!-- Umami Analytics -->
<script
defer
src="https://analytics.mana.how/script.js"
data-website-id="CALENDAR_LANDING_WEBSITE_ID"></script>
<title>{title}</title>
</head>
<body class="antialiased">

View file

@ -6,6 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Calendar</title>
%sveltekit.head%
<!-- Umami Analytics -->
<script defer src="https://analytics.mana.how/script.js" data-website-id="CALENDAR_WEB_WEBSITE_ID"></script>
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>

View file

@ -42,6 +42,12 @@ const {
rel="stylesheet"
/>
<!-- Umami Analytics -->
<script
defer
src="https://analytics.mana.how/script.js"
data-website-id="CHAT_LANDING_WEBSITE_ID"></script>
<title>{title}</title>
</head>
<body class="min-h-screen bg-background-page text-text-primary antialiased">

View file

@ -5,6 +5,8 @@
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
<!-- Umami Analytics -->
<script defer src="https://analytics.mana.how/script.js" data-website-id="CHAT_WEB_WEBSITE_ID"></script>
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>

View file

@ -0,0 +1,51 @@
---
import '../styles/global.css';
interface Props {
title?: string;
description?: string;
}
const {
title = 'Clock - Time Tracking & Focus',
description = 'Track your time, stay focused, and boost productivity. Pomodoro timer, time tracking, and focus sessions. Start free today.',
} = Astro.props;
---
<!doctype html>
<html lang="de" class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="description" content={description} />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<!-- SEO Meta Tags -->
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<!-- Preconnect to Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<!-- Umami Analytics -->
<script
defer
src="https://analytics.mana.how/script.js"
data-website-id="CLOCK_LANDING_WEBSITE_ID"></script>
<title>{title}</title>
</head>
<body class="antialiased">
<slot />
</body>
</html>

View file

@ -5,6 +5,8 @@
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
<!-- Umami Analytics -->
<script defer src="https://analytics.mana.how/script.js" data-website-id="CLOCK_WEB_WEBSITE_ID"></script>
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>

View file

@ -5,6 +5,8 @@
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
<!-- Umami Analytics -->
<script defer src="https://analytics.mana.how/script.js" data-website-id="CONTACTS_WEB_WEBSITE_ID"></script>
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>

View file

@ -71,8 +71,8 @@ const lang = getLangFromUrl(Astro.url);
<!-- Umami Analytics -->
<script
defer
src="https://umami.manacore.ai/script.js"
data-website-id="dbafd5bb-f192-4cea-a857-593956c5ef00"></script>
src="https://analytics.mana.how/script.js"
data-website-id="MANACORE_LANDING_WEBSITE_ID"></script>
<!-- Alternate language links for SEO -->
<AlternateLinks />

View file

@ -5,6 +5,8 @@
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
<!-- Umami Analytics -->
<script defer src="https://analytics.mana.how/script.js" data-website-id="MANACORE_WEB_WEBSITE_ID"></script>
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>

View file

@ -0,0 +1,50 @@
<script lang="ts">
interface Link {
name: string;
url: string;
description: string;
icon: 'grafana' | 'analytics' | 'docker' | 'api' | 'external';
}
let { links }: { links: Link[] } = $props();
const icons = {
grafana: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />`,
analytics: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 8v8m-4-5v5m-4-2v2m-2 4h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />`,
docker: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />`,
api: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />`,
external: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />`,
};
</script>
<div class="rounded-lg border bg-card p-6 shadow-sm">
<h3 class="text-lg font-semibold mb-4">Quick Links</h3>
<div class="grid gap-3">
{#each links as link}
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-3 p-3 rounded-lg border bg-background hover:bg-muted/50 transition-colors group"
>
<div class="rounded-md bg-primary/10 p-2">
<svg class="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
{@html icons[link.icon]}
</svg>
</div>
<div class="flex-1 min-w-0">
<p class="font-medium text-sm truncate">{link.name}</p>
<p class="text-xs text-muted-foreground truncate">{link.description}</p>
</div>
<svg
class="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</a>
{/each}
</div>
</div>

View file

@ -0,0 +1,53 @@
<script lang="ts">
interface Props {
title: string;
value: string | number;
change?: number;
changeLabel?: string;
icon?: 'users' | 'activity' | 'chart' | 'clock' | 'shield' | 'alert';
loading?: boolean;
}
let { title, value, change, changeLabel, icon = 'chart', loading = false }: Props = $props();
const icons = {
users: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />`,
activity: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />`,
chart: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />`,
clock: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />`,
shield: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />`,
alert: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />`,
};
</script>
<div class="rounded-lg border bg-card p-6 shadow-sm">
{#if loading}
<div class="flex items-center gap-4 animate-pulse">
<div class="rounded-full bg-muted p-3 h-12 w-12"></div>
<div class="flex-1">
<div class="h-4 bg-muted rounded w-24 mb-2"></div>
<div class="h-8 bg-muted rounded w-16"></div>
</div>
</div>
{:else}
<div class="flex items-center gap-4">
<div class="rounded-full bg-primary/10 p-3">
<svg class="h-6 w-6 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
{@html icons[icon]}
</svg>
</div>
<div>
<p class="text-sm text-muted-foreground">{title}</p>
<p class="text-2xl font-bold">{value}</p>
{#if change !== undefined}
<p class="text-sm {change >= 0 ? 'text-green-500' : 'text-red-500'}">
{change >= 0 ? '+' : ''}{change}%
{#if changeLabel}
<span class="text-muted-foreground">{changeLabel}</span>
{/if}
</p>
{/if}
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,128 @@
<script lang="ts">
interface User {
id: string;
email: string;
name?: string;
createdAt: string;
lastActiveAt?: string;
role: string;
}
interface Props {
users: User[];
loading?: boolean;
}
let { users, loading = false }: Props = $props();
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
function formatRelativeTime(dateStr: string | undefined): string {
if (!dateStr) return '-';
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'gerade eben';
if (diffMins < 60) return `vor ${diffMins} Min`;
if (diffHours < 24) return `vor ${diffHours} Std`;
if (diffDays < 7) return `vor ${diffDays} Tagen`;
return formatDate(dateStr);
}
</script>
<div class="rounded-lg border bg-card shadow-sm overflow-hidden">
<div class="p-4 border-b">
<h3 class="text-lg font-semibold">Benutzer</h3>
</div>
{#if loading}
<div class="p-4 space-y-3">
{#each Array(5) as _}
<div class="animate-pulse flex items-center gap-4">
<div class="h-10 w-10 bg-muted rounded-full"></div>
<div class="flex-1">
<div class="h-4 bg-muted rounded w-1/4 mb-2"></div>
<div class="h-3 bg-muted rounded w-1/3"></div>
</div>
</div>
{/each}
</div>
{:else}
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-muted/50">
<tr>
<th
class="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider"
>
Benutzer
</th>
<th
class="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider"
>
Rolle
</th>
<th
class="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider"
>
Registriert
</th>
<th
class="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider"
>
Letzte Aktivität
</th>
</tr>
</thead>
<tbody class="divide-y">
{#each users as user}
<tr class="hover:bg-muted/30 transition-colors">
<td class="px-4 py-3">
<div class="flex items-center gap-3">
<div class="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center">
<span class="text-sm font-medium text-primary">
{(user.name || user.email)[0].toUpperCase()}
</span>
</div>
<div>
<p class="font-medium text-sm">{user.name || '-'}</p>
<p class="text-xs text-muted-foreground">{user.email}</p>
</div>
</div>
</td>
<td class="px-4 py-3">
<span
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium
{user.role === 'admin'
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300'}"
>
{user.role}
</span>
</td>
<td class="px-4 py-3 text-sm text-muted-foreground">
{formatDate(user.createdAt)}
</td>
<td class="px-4 py-3 text-sm text-muted-foreground">
{formatRelativeTime(user.lastActiveAt)}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{#if users.length === 0}
<div class="p-8 text-center text-muted-foreground">Keine Benutzer gefunden</div>
{/if}
{/if}
</div>

View file

@ -80,7 +80,8 @@
let userEmail = $derived(authStore.user?.email);
// Navigation items for ManaCore
const navItems: PillNavItem[] = [
// Admin link is conditionally added based on user role
let baseNavItems: PillNavItem[] = [
{ href: '/dashboard', label: 'Dashboard', icon: 'home' },
{ href: '/credits', label: 'Credits', icon: 'creditCard' },
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
@ -88,6 +89,13 @@
{ href: '/settings', label: 'Settings', icon: 'settings' },
];
// TODO: Check user role from authStore and add admin link if admin
// For now, always show admin link for testing
const navItems: PillNavItem[] = [
...baseNavItems,
{ href: '/admin', label: 'Admin', icon: 'shield' },
];
// Navigation shortcuts (Ctrl+1-5)
const navRoutes = navItems.map((item) => item.href);

View file

@ -0,0 +1,72 @@
<script lang="ts">
import { page } from '$app/stores';
import type { Snippet } from 'svelte';
let { children }: { children: Snippet } = $props();
const tabs = [
{ href: '/admin', label: 'Overview', icon: 'home' },
{ href: '/admin/users', label: 'Users', icon: 'users' },
{ href: '/admin/system', label: 'System', icon: 'server' },
];
const icons: Record<string, string> = {
home: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />`,
users: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />`,
server: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />`,
};
function isActive(href: string, pathname: string): boolean {
if (href === '/admin') {
return pathname === '/admin';
}
return pathname.startsWith(href);
}
</script>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold">Admin Dashboard</h1>
<p class="text-muted-foreground">System monitoring and management</p>
</div>
<div class="flex items-center gap-2 px-3 py-1.5 rounded-full bg-red-100 dark:bg-red-900/30">
<svg
class="h-4 w-4 text-red-600 dark:text-red-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg>
<span class="text-sm font-medium text-red-600 dark:text-red-400">Admin</span>
</div>
</div>
<nav class="flex gap-1 border-b pb-px">
{#each tabs as tab}
{@const active = isActive(tab.href, $page.url.pathname)}
<a
href={tab.href}
class="flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-t-lg transition-colors
{active
? 'text-primary border-b-2 border-primary -mb-px bg-primary/5'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'}"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
{@html icons[tab.icon]}
</svg>
{tab.label}
</a>
{/each}
</nav>
<div>
{@render children()}
</div>
</div>

View file

@ -0,0 +1,155 @@
<script lang="ts">
import { onMount } from 'svelte';
import StatCard from '$lib/components/admin/StatCard.svelte';
import QuickLinks from '$lib/components/admin/QuickLinks.svelte';
interface Stats {
totalUsers: number;
newUsers7d: number;
newUsers30d: number;
activeSessions: number;
uniqueUsers24h: number;
loginSuccess7d: number;
loginFailed7d: number;
}
let stats = $state<Stats | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
const quickLinks = [
{
name: 'Grafana Dashboard',
url: 'https://grafana.mana.how',
description: 'System & Backend Metrics',
icon: 'grafana' as const,
},
{
name: 'Umami Analytics',
url: 'https://analytics.mana.how',
description: 'Web Analytics',
icon: 'analytics' as const,
},
{
name: 'Docker Dashboard',
url: 'https://grafana.mana.how/d/backends-docker',
description: 'Container Metrics',
icon: 'docker' as const,
},
{
name: 'System Overview',
url: 'https://grafana.mana.how/d/system-overview',
description: 'CPU, Memory, Disk',
icon: 'grafana' as const,
},
];
onMount(async () => {
try {
// TODO: Replace with actual API call to fetch admin stats
// const response = await fetch('/api/admin/stats');
// stats = await response.json();
// Mock data for now
await new Promise((resolve) => setTimeout(resolve, 500));
stats = {
totalUsers: 42,
newUsers7d: 8,
newUsers30d: 23,
activeSessions: 15,
uniqueUsers24h: 12,
loginSuccess7d: 156,
loginFailed7d: 3,
};
} catch (e) {
error = 'Failed to load stats';
} finally {
loading = false;
}
});
let userGrowthPercent = $derived(
stats
? Math.round((stats.newUsers7d / Math.max(stats.totalUsers - stats.newUsers7d, 1)) * 100)
: 0
);
</script>
<div class="space-y-6">
<!-- Stats Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard title="Total Users" value={stats?.totalUsers ?? '-'} icon="users" {loading} />
<StatCard
title="New Users (7d)"
value={stats?.newUsers7d ?? '-'}
change={userGrowthPercent}
changeLabel="vs previous"
icon="users"
{loading}
/>
<StatCard
title="Active Sessions"
value={stats?.activeSessions ?? '-'}
icon="activity"
{loading}
/>
<StatCard
title="Unique Users (24h)"
value={stats?.uniqueUsers24h ?? '-'}
icon="clock"
{loading}
/>
</div>
<!-- Security & Quick Links -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Security Overview -->
<div class="rounded-lg border bg-card p-6 shadow-sm">
<h3 class="text-lg font-semibold mb-4">Security (Last 7 Days)</h3>
{#if loading}
<div class="space-y-4 animate-pulse">
<div class="h-4 bg-muted rounded w-full"></div>
<div class="h-4 bg-muted rounded w-3/4"></div>
</div>
{:else if stats}
<div class="space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="h-2 w-2 rounded-full bg-green-500"></div>
<span class="text-sm">Successful Logins</span>
</div>
<span class="font-mono text-sm">{stats.loginSuccess7d}</span>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="h-2 w-2 rounded-full bg-red-500"></div>
<span class="text-sm">Failed Logins</span>
</div>
<span class="font-mono text-sm">{stats.loginFailed7d}</span>
</div>
<div class="pt-2 border-t">
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">Success Rate</span>
<span class="font-medium text-green-600">
{Math.round(
(stats.loginSuccess7d / (stats.loginSuccess7d + stats.loginFailed7d)) * 100
)}%
</span>
</div>
</div>
</div>
{/if}
</div>
<!-- Quick Links -->
<QuickLinks links={quickLinks} />
</div>
{#if error}
<div
class="rounded-lg border border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-900/20 p-4"
>
<p class="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
{/if}
</div>

View file

@ -0,0 +1,180 @@
<script lang="ts">
import { onMount } from 'svelte';
import QuickLinks from '$lib/components/admin/QuickLinks.svelte';
interface ServiceHealth {
name: string;
status: 'healthy' | 'degraded' | 'unhealthy' | 'unknown';
url: string;
lastCheck?: string;
}
let services = $state<ServiceHealth[]>([]);
let loading = $state(true);
const monitoringLinks = [
{
name: 'Grafana - System Overview',
url: 'https://grafana.mana.how/d/system-overview',
description: 'CPU, Memory, Disk, Network',
icon: 'grafana' as const,
},
{
name: 'Grafana - Backends & Docker',
url: 'https://grafana.mana.how/d/backends-docker',
description: 'Container metrics & API performance',
icon: 'docker' as const,
},
{
name: 'Prometheus',
url: 'https://grafana.mana.how/explore',
description: 'Raw metrics & queries',
icon: 'api' as const,
},
{
name: 'Umami Analytics',
url: 'https://analytics.mana.how',
description: 'Web analytics dashboard',
icon: 'analytics' as const,
},
];
const statusColors = {
healthy: 'bg-green-500',
degraded: 'bg-yellow-500',
unhealthy: 'bg-red-500',
unknown: 'bg-gray-400',
};
const statusLabels = {
healthy: 'Healthy',
degraded: 'Degraded',
unhealthy: 'Unhealthy',
unknown: 'Unknown',
};
onMount(async () => {
// TODO: Replace with actual health check API
await new Promise((resolve) => setTimeout(resolve, 500));
services = [
{ name: 'Mana Core Auth', status: 'healthy', url: 'https://auth.mana.how' },
{ name: 'ManaCore Web', status: 'healthy', url: 'https://mana.how' },
{ name: 'Chat Backend', status: 'healthy', url: 'https://chat-api.mana.how' },
{ name: 'Chat Web', status: 'healthy', url: 'https://chat.mana.how' },
{ name: 'Todo Backend', status: 'healthy', url: 'https://todo-api.mana.how' },
{ name: 'Todo Web', status: 'healthy', url: 'https://todo.mana.how' },
{ name: 'Calendar Backend', status: 'healthy', url: 'https://calendar-api.mana.how' },
{ name: 'Calendar Web', status: 'healthy', url: 'https://calendar.mana.how' },
{ name: 'Clock Backend', status: 'healthy', url: 'https://clock-api.mana.how' },
{ name: 'Clock Web', status: 'healthy', url: 'https://clock.mana.how' },
{ name: 'Contacts Backend', status: 'healthy', url: 'https://contacts-api.mana.how' },
{ name: 'Contacts Web', status: 'healthy', url: 'https://contacts.mana.how' },
{ name: 'PostgreSQL', status: 'healthy', url: '-' },
{ name: 'Redis', status: 'healthy', url: '-' },
{ name: 'Grafana', status: 'healthy', url: 'https://grafana.mana.how' },
{ name: 'Umami', status: 'healthy', url: 'https://analytics.mana.how' },
];
loading = false;
});
let healthyCount = $derived(services.filter((s) => s.status === 'healthy').length);
let totalCount = $derived(services.length);
</script>
<div class="space-y-6">
<!-- System Status Overview -->
<div class="rounded-lg border bg-card p-6 shadow-sm">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold">System Status</h3>
{#if !loading}
<div class="flex items-center gap-2">
<div
class="h-2.5 w-2.5 rounded-full {healthyCount === totalCount
? 'bg-green-500'
: 'bg-yellow-500'}"
></div>
<span class="text-sm font-medium">
{healthyCount}/{totalCount} Services Healthy
</span>
</div>
{/if}
</div>
{#if loading}
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
{#each Array(8) as _}
<div class="animate-pulse h-12 bg-muted rounded-lg"></div>
{/each}
</div>
{:else}
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
{#each services as service}
<div class="flex items-center gap-3 p-3 rounded-lg border bg-background">
<div class="h-2.5 w-2.5 rounded-full {statusColors[service.status]}"></div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">{service.name}</p>
<p class="text-xs text-muted-foreground">{statusLabels[service.status]}</p>
</div>
{#if service.url !== '-'}
<a
href={service.url}
target="_blank"
rel="noopener noreferrer"
class="text-muted-foreground hover:text-foreground"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</a>
{/if}
</div>
{/each}
</div>
{/if}
</div>
<!-- Monitoring Links -->
<QuickLinks links={monitoringLinks} />
<!-- Environment Info -->
<div class="rounded-lg border bg-card p-6 shadow-sm">
<h3 class="text-lg font-semibold mb-4">Environment</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-3">
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">Server</span>
<span class="font-mono">Mac Mini (mana.how)</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">Domain</span>
<span class="font-mono">*.mana.how</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">SSL</span>
<span class="font-mono text-green-600">Caddy (Auto)</span>
</div>
</div>
<div class="space-y-3">
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">Database</span>
<span class="font-mono">PostgreSQL 16</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">Cache</span>
<span class="font-mono">Redis 7</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">Tunnel</span>
<span class="font-mono">Cloudflare</span>
</div>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,124 @@
<script lang="ts">
import { onMount } from 'svelte';
import UserTable from '$lib/components/admin/UserTable.svelte';
interface User {
id: string;
email: string;
name?: string;
createdAt: string;
lastActiveAt?: string;
role: string;
}
let users = $state<User[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let searchQuery = $state('');
let filteredUsers = $derived(
searchQuery
? users.filter(
(u) =>
u.email.toLowerCase().includes(searchQuery.toLowerCase()) ||
u.name?.toLowerCase().includes(searchQuery.toLowerCase())
)
: users
);
onMount(async () => {
try {
// TODO: Replace with actual API call
// const response = await fetch('/api/admin/users');
// users = await response.json();
// Mock data for now
await new Promise((resolve) => setTimeout(resolve, 500));
users = [
{
id: '1',
email: 'admin@mana.how',
name: 'Admin User',
createdAt: '2024-01-15T10:00:00Z',
lastActiveAt: new Date().toISOString(),
role: 'admin',
},
{
id: '2',
email: 'user1@example.com',
name: 'Max Mustermann',
createdAt: '2024-06-20T14:30:00Z',
lastActiveAt: new Date(Date.now() - 3600000).toISOString(),
role: 'user',
},
{
id: '3',
email: 'user2@example.com',
name: 'Erika Musterfrau',
createdAt: '2024-09-01T08:15:00Z',
lastActiveAt: new Date(Date.now() - 86400000).toISOString(),
role: 'user',
},
{
id: '4',
email: 'user3@example.com',
createdAt: '2024-12-10T16:45:00Z',
lastActiveAt: new Date(Date.now() - 172800000).toISOString(),
role: 'user',
},
{
id: '5',
email: 'newuser@example.com',
name: 'New User',
createdAt: new Date(Date.now() - 86400000).toISOString(),
role: 'user',
},
];
} catch (e) {
error = 'Failed to load users';
} finally {
loading = false;
}
});
</script>
<div class="space-y-6">
<!-- Search & Filters -->
<div class="flex items-center gap-4">
<div class="relative flex-1 max-w-md">
<svg
class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
type="text"
placeholder="Search users..."
bind:value={searchQuery}
class="w-full pl-10 pr-4 py-2 border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
<span class="text-sm text-muted-foreground">
{filteredUsers.length} of {users.length} users
</span>
</div>
<!-- User Table -->
<UserTable users={filteredUsers} {loading} />
{#if error}
<div
class="rounded-lg border border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-900/20 p-4"
>
<p class="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
{/if}
</div>

View file

@ -42,6 +42,12 @@ const {
rel="stylesheet"
/>
<!-- Umami Analytics -->
<script
defer
src="https://analytics.mana.how/script.js"
data-website-id="MANADECK_LANDING_WEBSITE_ID"></script>
<title>{title}</title>
</head>
<body class="min-h-screen bg-background-page text-text-primary antialiased">

View file

@ -25,6 +25,8 @@
<meta name="msapplication-config" content="none" />
%sveltekit.head%
<!-- Umami Analytics -->
<script defer src="https://analytics.mana.how/script.js" data-website-id="TODO_WEB_WEBSITE_ID"></script>
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>