aboutsummaryrefslogtreecommitdiff
path: root/frontend/src
diff options
context:
space:
mode:
authorzwlucas <lucas.fariamo08@gmail.com>2026-05-29 18:57:37 +0000
committerzwlucas <lucas.fariamo08@gmail.com>2026-05-29 18:57:37 +0000
commit6cc0bfd1d79074df790272b8091b1f0226d14283 (patch)
treecab18b0f708cf3b0eaeeeb98e2cf81459365b33e /frontend/src
downloadyaum-6cc0bfd1d79074df790272b8091b1f0226d14283.tar.gz
yaum-6cc0bfd1d79074df790272b8091b1f0226d14283.zip
feat: upload project
Signed-off-by: zwlucas <lucas.fariamo08@gmail.com>
Diffstat (limited to 'frontend/src')
-rw-r--r--frontend/src/app.css50
-rw-r--r--frontend/src/app.d.ts3
-rw-r--r--frontend/src/app.html13
-rw-r--r--frontend/src/hooks.server.js31
-rw-r--r--frontend/src/lib/api.ts39
-rw-r--r--frontend/src/lib/components/ActivityLog.svelte122
-rw-r--r--frontend/src/lib/components/AddServiceModal.svelte224
-rw-r--r--frontend/src/lib/components/ServiceCard.svelte90
-rw-r--r--frontend/src/lib/components/UptimeTimeline.svelte41
-rw-r--r--frontend/src/lib/realtime.ts42
-rw-r--r--frontend/src/lib/types.ts44
-rw-r--r--frontend/src/routes/(admin)/admin/+page.server.js27
-rw-r--r--frontend/src/routes/(admin)/admin/+page.svelte432
-rw-r--r--frontend/src/routes/+layout.svelte50
-rw-r--r--frontend/src/routes/+page.svelte164
-rw-r--r--frontend/src/routes/+page.ts33
-rw-r--r--frontend/src/routes/login/+page.server.js51
-rw-r--r--frontend/src/routes/login/+page.svelte61
-rw-r--r--frontend/src/routes/services/[id]/+page.svelte261
-rw-r--r--frontend/src/routes/status/[id]/+page.svelte278
-rw-r--r--frontend/src/routes/status/[id]/+page.ts22
21 files changed, 2078 insertions, 0 deletions
diff --git a/frontend/src/app.css b/frontend/src/app.css
new file mode 100644
index 0000000..c1d9e47
--- /dev/null
+++ b/frontend/src/app.css
@@ -0,0 +1,50 @@
+@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap');
+
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+:root {
+ --bg-primary: #0f1117;
+ --bg-secondary: #1a1d27;
+ --bg-card: #1e2130;
+ --border-color: #2a2e3d;
+ --text-primary: #e8eaed;
+ --text-secondary: #9aa0b0;
+ --text-muted: #5c6278;
+ --green: #22f06a;
+ --red: #ff4060;
+ --amber: #ffb020;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body {
+ margin: 0;
+ padding: 0;
+ background-color: var(--bg-primary);
+ color: var(--text-primary);
+ font-family: 'Inter', system-ui, -apple-system, sans-serif;
+ min-height: 100vh;
+}
+
+body {
+ background:
+ radial-gradient(ellipse 80% 60% at 50% -20%, rgba(34, 240, 106, 0.04) 0%, transparent 60%),
+ radial-gradient(ellipse 60% 50% at 80% 20%, rgba(255, 64, 96, 0.03) 0%, transparent 50%),
+ var(--bg-primary);
+}
+
+::-webkit-scrollbar {
+ width: 6px;
+}
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+::-webkit-scrollbar-thumb {
+ background: var(--border-color);
+ border-radius: 3px;
+}
diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts
new file mode 100644
index 0000000..779a3d4
--- /dev/null
+++ b/frontend/src/app.d.ts
@@ -0,0 +1,3 @@
+/// <reference types="@sveltejs/kit" />
+
+declare namespace App {}
diff --git a/frontend/src/app.html b/frontend/src/app.html
new file mode 100644
index 0000000..6293dcd
--- /dev/null
+++ b/frontend/src/app.html
@@ -0,0 +1,13 @@
+<!doctype html>
+<html lang="pt-BR">
+ <head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <link rel="icon" type="image/svg+xml" href="%sveltekit.assets%/favicon.svg" />
+ <title>YAUM — Yet Another Uptime Monitor</title>
+ %sveltekit.head%
+ </head>
+ <body data-sveltekit-prerender="true">
+ <div style="display: contents">%sveltekit.body%</div>
+ </body>
+</html>
diff --git a/frontend/src/hooks.server.js b/frontend/src/hooks.server.js
new file mode 100644
index 0000000..39c89e9
--- /dev/null
+++ b/frontend/src/hooks.server.js
@@ -0,0 +1,31 @@
+import { redirect } from '@sveltejs/kit';
+
+const API = 'http://localhost:8080/api';
+
+const PUBLIC_PREFIXES = ['/_app', '/api'];
+
+export async function handle({ event, resolve }) {
+ const isStatic = PUBLIC_PREFIXES.some((p) => event.url.pathname.startsWith(p));
+ const isLogin = event.url.pathname === '/login';
+ const isPublic = event.url.pathname === '/' || event.url.pathname.startsWith('/status/');
+
+ if (isStatic || isLogin || isPublic) {
+ return resolve(event);
+ }
+
+ if (event.url.pathname.startsWith('/admin')) {
+ const token = event.cookies.get('auth_token');
+ if (!token) {
+ throw redirect(302, '/login');
+ }
+ const res = await fetch(`${API}/auth/verify`, {
+ headers: { Authorization: `Bearer ${token}` }
+ });
+ if (!res.ok) {
+ throw redirect(302, '/login');
+ }
+ event.locals.token = token;
+ }
+
+ return resolve(event);
+}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
new file mode 100644
index 0000000..0be9148
--- /dev/null
+++ b/frontend/src/lib/api.ts
@@ -0,0 +1,39 @@
+import type { Service, Heartbeat, ServiceStats } from './types';
+
+const BASE = '/api';
+
+export async function listServices(): Promise<Service[]> {
+ const res = await fetch(`${BASE}/services`);
+ if (!res.ok) throw new Error('falha ao listar serviços');
+ return res.json();
+}
+
+export async function createService(data: { name: string; url: string }): Promise<Service> {
+ const res = await fetch(`${BASE}/services`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(data)
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ throw new Error(err.error || 'falha ao criar serviço');
+ }
+ return res.json();
+}
+
+export async function deleteService(id: number): Promise<void> {
+ const res = await fetch(`${BASE}/services/${id}`, { method: 'DELETE' });
+ if (!res.ok) throw new Error('falha ao remover serviço');
+}
+
+export async function getHistory(id: number): Promise<Heartbeat[]> {
+ const res = await fetch(`${BASE}/services/${id}/history`);
+ if (!res.ok) throw new Error('falha ao obter histórico');
+ return res.json();
+}
+
+export async function getServiceStats(id: number): Promise<ServiceStats> {
+ const res = await fetch(`${BASE}/services/${id}/stats`);
+ if (!res.ok) throw new Error('falha ao obter estatísticas');
+ return res.json();
+}
diff --git a/frontend/src/lib/components/ActivityLog.svelte b/frontend/src/lib/components/ActivityLog.svelte
new file mode 100644
index 0000000..4d7ff79
--- /dev/null
+++ b/frontend/src/lib/components/ActivityLog.svelte
@@ -0,0 +1,122 @@
+<script lang="ts">
+ import type { Heartbeat } from '$lib/types';
+
+ interface LogEntry {
+ kind: 'check' | 'created' | 'deleted' | 'transition';
+ serviceName: string;
+ serviceUrl: string;
+ timestamp: Date;
+ hb?: Heartbeat;
+ wasUp?: boolean;
+ }
+
+ let {
+ entries = []
+ }: {
+ entries: LogEntry[];
+ } = $props();
+
+ let el: HTMLDivElement;
+ let autoScroll = $state(true);
+
+ function handleScroll() {
+ if (!el) return;
+ const dist = el.scrollHeight - el.scrollTop - el.clientHeight;
+ autoScroll = dist < 40;
+ }
+
+ $effect(() => {
+ if (entries.length && autoScroll && el) {
+ el.scrollTop = el.scrollHeight;
+ }
+ });
+
+ function timeAgo(date: Date): string {
+ const sec = Math.floor((Date.now() - date.getTime()) / 1000);
+ if (sec < 10) return 'agora';
+ if (sec < 60) return `${sec}s`;
+ const min = Math.floor(sec / 60);
+ if (min === 1) return '1min';
+ if (min < 60) return `${min}min`;
+ return date.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
+ }
+</script>
+
+<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg-card)]">
+ <div class="flex items-center justify-between border-b border-[var(--border-color)] px-5 py-3">
+ <h2 class="text-xs font-semibold uppercase tracking-wider text-[var(--text-secondary)]">
+ Atividade Recente
+ </h2>
+ <span class="text-[10px] tabular-nums text-[var(--text-muted)]">{entries.length} eventos</span>
+ </div>
+ <div
+ bind:this={el}
+ onscroll={handleScroll}
+ class="scrollbar-thin space-y-0.5 overflow-y-auto px-3 py-2"
+ style="max-height: 320px"
+ >
+ {#if entries.length === 0}
+ <p class="py-8 text-center text-xs text-[var(--text-muted)]">Nenhuma atividade ainda</p>
+ {:else}
+ {#each entries as entry, i (i)}
+ <div
+ class="flex items-start gap-3 rounded-xl px-3 py-2.5 transition-colors hover:bg-[var(--border-color)]/30"
+ >
+ <div class="mt-0.5 shrink-0">
+ {#if entry.kind === 'created'}
+ <span class="flex h-5 w-5 items-center justify-center rounded-md bg-[var(--green)]/10 text-[10px] text-[var(--green)]">
+ <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
+ </svg>
+ </span>
+ {:else if entry.kind === 'deleted'}
+ <span class="flex h-5 w-5 items-center justify-center rounded-md bg-[var(--red)]/10 text-[10px] text-[var(--red)]">
+ <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
+ </svg>
+ </span>
+ {:else if entry.kind === 'transition'}
+ <span
+ class="flex h-5 w-5 items-center justify-center rounded-md text-[10px]"
+ style="background-color: {entry.hb?.is_up ? 'rgba(34,240,106,0.1)' : 'rgba(255,64,96,0.1)'}; color: {entry.hb?.is_up ? 'var(--green)' : 'var(--red)'}"
+ >
+ {entry.hb?.is_up ? '↑' : '↓'}
+ </span>
+ {:else}
+ <span
+ class="flex h-5 w-5 items-center justify-center rounded-md text-[10px]"
+ style="background-color: {entry.hb?.is_up ? 'rgba(34,240,106,0.1)' : 'rgba(255,64,96,0.1)'}; color: {entry.hb?.is_up ? 'var(--green)' : 'var(--red)'}"
+ >
+ {entry.hb?.is_up ? '✓' : '✗'}
+ </span>
+ {/if}
+ </div>
+ <div class="min-w-0 flex-1">
+ <div class="flex items-baseline gap-2">
+ <span class="truncate text-xs font-medium text-white">{entry.serviceName}</span>
+ <span class="shrink-0 text-[10px] text-[var(--text-muted)]">{timeAgo(entry.timestamp)}</span>
+ </div>
+ <p class="truncate text-[10px] text-[var(--text-muted)]">
+ {#if entry.kind === 'created'}
+ Serviço adicionado
+ {:else if entry.kind === 'deleted'}
+ Serviço removido
+ {:else if entry.kind === 'transition'}
+ {entry.hb?.is_up ? 'Recuperado' : 'Falha'} — {entry.hb?.response_time_ms ?? '?'}ms
+ {:else}
+ {entry.hb?.is_up ? 'Online' : 'Offline'} — {entry.hb?.status_code} / {entry.hb?.response_time_ms}ms
+ {/if}
+ </p>
+ </div>
+ </div>
+ {/each}
+ {/if}
+ </div>
+</div>
+
+<style>
+ .scrollbar-thin {
+ scrollbar-width: thin;
+ scrollbar-color: var(--border-color) transparent;
+ }
+</style>
diff --git a/frontend/src/lib/components/AddServiceModal.svelte b/frontend/src/lib/components/AddServiceModal.svelte
new file mode 100644
index 0000000..de1ba13
--- /dev/null
+++ b/frontend/src/lib/components/AddServiceModal.svelte
@@ -0,0 +1,224 @@
+<script lang="ts">
+ import type { Service } from '$lib/types';
+
+ let {
+ show = false,
+ onclose,
+ oncreated
+ }: {
+ show: boolean;
+ onclose: () => void;
+ oncreated: (svc: Service) => void;
+ } = $props();
+
+ let name = $state('');
+ let url = $state('');
+ let discordWebhookUrl = $state('');
+ let alertEmail = $state('');
+ let keywordToFind = $state('');
+ let error = $state('');
+ let saving = $state(false);
+
+ async function handleSubmit(e: Event) {
+ e.preventDefault();
+ error = '';
+
+ if (!name.trim() || !url.trim()) {
+ error = 'Preencha nome e URL';
+ return;
+ }
+ try {
+ new URL(url);
+ } catch {
+ error = 'URL inválida — deve começar com http:// ou https://';
+ return;
+ }
+
+ saving = true;
+ try {
+ const res = await fetch('/api/services', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ name: name.trim(),
+ url: url.trim(),
+ discord_webhook_url: discordWebhookUrl.trim() || undefined,
+ alert_email: alertEmail.trim() || undefined,
+ keyword_to_find: keywordToFind.trim() || undefined
+ })
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ throw new Error(err.error || 'Erro ao criar serviço');
+ }
+ const svc: Service = await res.json();
+ oncreated(svc);
+ name = '';
+ url = '';
+ discordWebhookUrl = '';
+ alertEmail = '';
+ keywordToFind = '';
+ onclose();
+ } catch (e: any) {
+ error = e.message;
+ } finally {
+ saving = false;
+ }
+ }
+
+ function handleBackdrop(e: MouseEvent) {
+ if (e.target === e.currentTarget) onclose();
+ }
+
+ function handleKeydown(e: KeyboardEvent) {
+ if (e.key === 'Escape') onclose();
+ }
+</script>
+
+<svelte:window onkeydown={handleKeydown} />
+
+{#if show}
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
+ <div
+ role="button"
+ tabindex="-1"
+ aria-label="Fechar modal"
+ class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
+ onclick={handleBackdrop}
+ >
+ <div
+ class="w-full max-w-md animate-[modalIn_0.2s_ease-out] rounded-2xl border border-[var(--border-color)] bg-[var(--bg-secondary)] p-6 shadow-2xl"
+ >
+ <div class="mb-5 flex items-center justify-between">
+ <h2 class="text-sm font-semibold uppercase tracking-wider text-[var(--text-secondary)]">
+ Novo Serviço
+ </h2>
+ <button
+ onclick={onclose}
+ class="rounded-lg p-1.5 text-[var(--text-muted)] transition-colors hover:bg-[var(--border-color)] hover:text-white"
+ aria-label="Fechar"
+ >
+ <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
+ </svg>
+ </button>
+ </div>
+
+ <form onsubmit={handleSubmit} class="space-y-4">
+ <div>
+ <label for="modal-name" class="mb-1.5 block text-xs font-medium text-[var(--text-secondary)]">
+ Nome do Serviço
+ </label>
+ <input
+ id="modal-name"
+ bind:value={name}
+ type="text"
+ placeholder="Meu Site"
+ class="w-full rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] px-3.5 py-2.5 text-sm text-white outline-none transition-all placeholder:text-[var(--text-muted)] focus:border-[var(--green)]/50 focus:ring-1 focus:ring-[var(--green)]/20"
+ />
+ </div>
+
+ <div>
+ <label for="modal-url" class="mb-1.5 block text-xs font-medium text-[var(--text-secondary)]">
+ URL
+ </label>
+ <input
+ id="modal-url"
+ bind:value={url}
+ type="url"
+ placeholder="https://exemplo.com"
+ class="w-full rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] px-3.5 py-2.5 text-sm text-white outline-none transition-all placeholder:text-[var(--text-muted)] focus:border-[var(--green)]/50 focus:ring-1 focus:ring-[var(--green)]/20"
+ />
+ </div>
+
+ <div class="border-t border-[var(--border-color)] pt-4">
+ <p class="mb-3 text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)]">
+ Alertas (opcional)
+ </p>
+ <div>
+ <label
+ for="modal-discord"
+ class="mb-1.5 block text-xs font-medium text-[var(--text-secondary)]"
+ >
+ Webhook do Discord
+ </label>
+ <input
+ id="modal-discord"
+ bind:value={discordWebhookUrl}
+ type="url"
+ placeholder="https://discord.com/api/webhooks/..."
+ class="w-full rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] px-3.5 py-2.5 text-sm text-white outline-none transition-all placeholder:text-[var(--text-muted)] focus:border-[var(--green)]/50 focus:ring-1 focus:ring-[var(--green)]/20"
+ />
+ </div>
+ <div class="mt-3">
+ <label
+ for="modal-email"
+ class="mb-1.5 block text-xs font-medium text-[var(--text-secondary)]"
+ >
+ E-mail de Alerta
+ </label>
+ <input
+ id="modal-email"
+ bind:value={alertEmail}
+ type="email"
+ placeholder="admin@exemplo.com"
+ class="w-full rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] px-3.5 py-2.5 text-sm text-white outline-none transition-all placeholder:text-[var(--text-muted)] focus:border-[var(--green)]/50 focus:ring-1 focus:ring-[var(--green)]/20"
+ />
+ </div>
+ <div class="mt-3">
+ <label
+ for="modal-keyword"
+ class="mb-1.5 block text-xs font-medium text-[var(--text-secondary)]"
+ >
+ Verificar Palavra-chave na Página (opcional)
+ </label>
+ <input
+ id="modal-keyword"
+ bind:value={keywordToFind}
+ type="text"
+ placeholder="Bem-vindo, Login, OK..."
+ class="w-full rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] px-3.5 py-2.5 text-sm text-white outline-none transition-all placeholder:text-[var(--text-muted)] focus:border-[var(--green)]/50 focus:ring-1 focus:ring-[var(--green)]/20"
+ />
+ <p class="mt-1 text-[9px] text-[var(--text-muted)]">
+ Se informado, o monitor vai ler o HTML e marcar como DOWN se não encontrar essa palavra.
+ </p>
+ </div>
+ </div>
+
+ {#if error}
+ <p class="rounded-lg bg-[var(--red)]/5 px-3 py-2 text-xs text-[var(--red)]">{error}</p>
+ {/if}
+
+ <div class="flex gap-3 pt-1">
+ <button
+ type="button"
+ onclick={onclose}
+ class="flex-1 rounded-xl border border-[var(--border-color)] px-4 py-2.5 text-sm font-medium text-[var(--text-muted)] transition-colors hover:border-[var(--text-muted)]/30 hover:text-white"
+ >
+ Cancelar
+ </button>
+ <button
+ type="submit"
+ disabled={saving}
+ class="flex-1 rounded-xl border border-[var(--green)] bg-[var(--green)]/10 px-4 py-2.5 text-sm font-medium text-[var(--green)] transition-all hover:bg-[var(--green)]/20 disabled:cursor-not-allowed disabled:opacity-40"
+ >
+ {saving ? 'Salvando…' : 'Adicionar'}
+ </button>
+ </div>
+ </form>
+ </div>
+ </div>
+{/if}
+
+<style>
+ @keyframes modalIn {
+ from {
+ opacity: 0;
+ transform: scale(0.95) translateY(-8px);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1) translateY(0);
+ }
+ }
+</style>
diff --git a/frontend/src/lib/components/ServiceCard.svelte b/frontend/src/lib/components/ServiceCard.svelte
new file mode 100644
index 0000000..29305f2
--- /dev/null
+++ b/frontend/src/lib/components/ServiceCard.svelte
@@ -0,0 +1,90 @@
+<script lang="ts">
+ import UptimeTimeline from './UptimeTimeline.svelte';
+ import type { Service, Heartbeat } from '$lib/types';
+
+ let {
+ service,
+ heartbeats = [],
+ ondeleted
+ }: {
+ service: Service;
+ heartbeats: Heartbeat[];
+ ondeleted: (id: number) => void;
+ } = $props();
+
+ let isUp = $derived(service?.last_heartbeat?.is_up ?? true);
+ let lastMs = $derived(service?.last_heartbeat?.response_time_ms ?? null);
+ let statusColor = $derived(isUp ? 'var(--green)' : 'var(--red)');
+ let bgClass = $derived(isUp ? 'bg-emerald-500/10 text-emerald-500' : 'bg-rose-500/10 text-rose-500');
+ let dotGlow = $derived(isUp ? '0 0 6px var(--green)' : '0 0 6px var(--red)');
+
+ let uptime30d = $derived.by(() => {
+ const now = Date.now();
+ const cutoff30d = now - 30 * 24 * 60 * 60 * 1000;
+ const recent = heartbeats.filter((h) => new Date(h.tested_at).getTime() >= cutoff30d);
+ if (recent.length === 0) return null;
+ return (recent.filter((h) => h.is_up).length / recent.length) * 100;
+ });
+
+ let uptimeColor = $derived(uptime30d !== null && uptime30d < 99 ? 'var(--red)' : 'var(--green)');
+</script>
+
+{#if service}
+<div
+ class="group relative overflow-hidden rounded-2xl border border-[var(--border-color)] bg-[var(--bg-card)] p-5 transition-all duration-300 hover:border-[var(--border-color)]/60 hover:shadow-2xl hover:shadow-black/30 hover:-translate-y-0.5"
+>
+ <div
+ class="pointer-events-none absolute -inset-px rounded-2xl opacity-0 transition-opacity duration-500 group-hover:opacity-100"
+ style="background: radial-gradient(600px circle at 50% 50%, {statusColor}06 0%, transparent 60%)"
+ ></div>
+
+ <div class="relative z-10">
+ <div class="mb-3 flex items-start justify-between gap-2">
+ <div class="min-w-0 flex-1">
+ <h3 class="truncate text-sm font-semibold text-white">{service.name}</h3>
+ <p class="mt-0.5 truncate font-mono text-xs text-[var(--text-muted)]">{service.url}</p>
+ </div>
+ <button
+ onclick={() => ondeleted(service.id)}
+ class="shrink-0 rounded-lg p-1.5 text-[var(--text-muted)] opacity-0 transition-all duration-200 hover:bg-rose-500/10 hover:text-rose-500 group-hover:opacity-100"
+ title="Remover serviço"
+ >
+ <svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
+ </svg>
+ </button>
+ </div>
+
+ <div class="mb-4 flex items-center gap-3">
+ <span class="inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 text-xs font-semibold tracking-wide {bgClass}">
+ <span class="h-1.5 w-1.5 rounded-full" style="background-color: {statusColor}; box-shadow: {dotGlow}"></span>
+ {isUp ? 'UP' : 'DOWN'}
+ </span>
+
+ {#if lastMs !== null}
+ <span class="font-mono text-xs tabular-nums text-[var(--text-muted)]">
+ {lastMs}<span class="text-[var(--text-secondary)]">ms</span>
+ </span>
+ {/if}
+
+ {#if uptime30d !== null}
+ <span
+ class="ml-auto font-mono text-xs tabular-nums"
+ style="color: {uptimeColor}"
+ >
+ {uptime30d.toFixed(2)}%
+ </span>
+ {/if}
+ </div>
+
+ <UptimeTimeline {heartbeats} />
+
+ <a
+ href="/status/{service.id}"
+ class="mt-3 block text-center text-[10px] font-medium uppercase tracking-widest text-[var(--text-muted)] transition-colors hover:text-[var(--text-secondary)]"
+ >
+ Detalhes →
+ </a>
+ </div>
+</div>
+{/if}
diff --git a/frontend/src/lib/components/UptimeTimeline.svelte b/frontend/src/lib/components/UptimeTimeline.svelte
new file mode 100644
index 0000000..6cb47df
--- /dev/null
+++ b/frontend/src/lib/components/UptimeTimeline.svelte
@@ -0,0 +1,41 @@
+<script lang="ts">
+ import type { Heartbeat } from '$lib/types';
+
+ let {
+ heartbeats = []
+ }: {
+ heartbeats: Heartbeat[];
+ } = $props();
+
+ let blocks = $derived.by(() => {
+ const all = heartbeats.slice(-30);
+ const filled = all.map((h) => h.is_up);
+ while (filled.length < 30) {
+ filled.unshift(null);
+ }
+ return filled;
+ });
+
+ function blockColor(v: boolean | null): string {
+ if (v === true) return 'var(--green)';
+ if (v === false) return 'var(--red)';
+ return 'var(--border-color)';
+ }
+</script>
+
+<div class="space-y-1.5">
+ <div class="flex gap-[3px]">
+ {#each blocks as block, i}
+ <div
+ class="h-4 flex-1 rounded-sm transition-all duration-300"
+ style="background-color: {blockColor(block)}; opacity: {block !== null ? 0.8 : 1}"
+ title={block === true ? 'UP' : block === false ? 'DOWN' : 'Sem dados'}
+ ></div>
+ {/each}
+ </div>
+ <div class="flex justify-between text-[10px] font-medium text-[var(--text-muted)]">
+ <span>{heartbeats.length > 0 ? '-30 min' : ''}</span>
+ <span class="tabular-nums">{heartbeats.length} checks</span>
+ <span>agora</span>
+ </div>
+</div>
diff --git a/frontend/src/lib/realtime.ts b/frontend/src/lib/realtime.ts
new file mode 100644
index 0000000..fb9c75c
--- /dev/null
+++ b/frontend/src/lib/realtime.ts
@@ -0,0 +1,42 @@
+import type { Heartbeat } from '$lib/types';
+
+type Listener = (hb: Heartbeat) => void;
+
+const listeners = new Set<Listener>();
+let es: EventSource | null = null;
+
+function connect() {
+ if (es) return;
+
+ es = new EventSource('/api/stream');
+
+ es.addEventListener('connected', () => {
+ console.log('[sse] conectado');
+ });
+
+ es.addEventListener('heartbeat', (event: MessageEvent) => {
+ try {
+ const hb: Heartbeat = JSON.parse(event.data);
+ listeners.forEach((fn) => fn(hb));
+ } catch {
+ // ignore parse errors
+ }
+ });
+
+ es.onerror = () => {
+ console.warn('[sse] erro — reconectando...');
+ };
+}
+
+export function subscribeHeartbeats(fn: Listener) {
+ listeners.add(fn);
+ connect();
+
+ return () => {
+ listeners.delete(fn);
+ if (listeners.size === 0 && es) {
+ es.close();
+ es = null;
+ }
+ };
+}
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
new file mode 100644
index 0000000..f80c933
--- /dev/null
+++ b/frontend/src/lib/types.ts
@@ -0,0 +1,44 @@
+export interface Service {
+ id: number;
+ name: string;
+ url: string;
+ group_name: string;
+ interval_seconds: number;
+ is_active: boolean;
+ discord_webhook_url?: string;
+ alert_email?: string;
+ keyword_to_find?: string;
+ created_at: string;
+ last_heartbeat?: Heartbeat | null;
+}
+
+export interface Maintenance {
+ id: number;
+ title: string;
+ start_time: string;
+ end_time: string;
+ is_active: boolean;
+}
+
+export interface Heartbeat {
+ id: number;
+ service_id: number;
+ status_code: number;
+ response_time_ms: number;
+ is_up: boolean;
+ error_message?: string;
+ tested_at: string;
+}
+
+export interface ServiceStats {
+ service_id: number;
+ uptime_24h: number;
+ uptime_7d: number;
+ uptime_30d: number;
+ avg_response_ms_24h: number;
+ avg_response_ms_7d: number;
+ avg_response_ms_30d: number;
+ total_checks_24h: number;
+ total_checks_7d: number;
+ total_checks_30d: number;
+}
diff --git a/frontend/src/routes/(admin)/admin/+page.server.js b/frontend/src/routes/(admin)/admin/+page.server.js
new file mode 100644
index 0000000..bdf498b
--- /dev/null
+++ b/frontend/src/routes/(admin)/admin/+page.server.js
@@ -0,0 +1,27 @@
+const API = 'http://localhost:8080/api';
+
+export async function load({ locals }) {
+ const token = locals.token;
+
+ const headers = token ? { Authorization: `Bearer ${token}` } : {};
+
+ const res = await fetch(`${API}/services`, { headers });
+ if (!res.ok) {
+ return { services: [], error: 'Falha ao carregar serviços', token: token ?? '' };
+ }
+ const services = await res.json();
+
+ const servicesWithStats = await Promise.all(
+ services.map(async (svc) => {
+ try {
+ const statsRes = await fetch(`${API}/services/${svc.id}/stats`, { headers });
+ if (statsRes.ok) {
+ svc.stats = await statsRes.json();
+ }
+ } catch { /* ignore */ }
+ return svc;
+ })
+ );
+
+ return { services: servicesWithStats, error: null, token: token ?? '' };
+}
diff --git a/frontend/src/routes/(admin)/admin/+page.svelte b/frontend/src/routes/(admin)/admin/+page.svelte
new file mode 100644
index 0000000..24b56db
--- /dev/null
+++ b/frontend/src/routes/(admin)/admin/+page.svelte
@@ -0,0 +1,432 @@
+<script lang="ts">
+ const API = 'http://localhost:8080/api';
+
+ let { data } = $props();
+
+ let initialized = $state(false);
+ let token = $state('');
+ let services = $state<any[]>([]);
+ let showForm = $state(false);
+
+ $effect(() => {
+ if (!initialized && data) {
+ token = data.token;
+ services = data.services ?? [];
+ initialized = true;
+ }
+ });
+ let editingSvc: any = $state(null);
+ let saving = $state(false);
+ let formError = $state('');
+ let testing = $state<Record<number, boolean>>({});
+ let toggling = $state<Record<number, boolean>>({});
+
+ function apiHeaders() {
+ return { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` };
+ }
+
+ async function handleUnauthorized(res: Response) {
+ if (res.status === 401) {
+ window.location.href = '/login';
+ return true;
+ }
+ return false;
+ }
+
+ function openCreate() {
+ editingSvc = null;
+ formError = '';
+ showForm = true;
+ }
+
+ function openEdit(svc: any) {
+ editingSvc = { ...svc };
+ formError = '';
+ showForm = true;
+ }
+
+ function closeForm() {
+ showForm = false;
+ editingSvc = null;
+ formError = '';
+ }
+
+ async function reFetch() {
+ try {
+ const res = await fetch(`${API}/services`, { headers: { Authorization: `Bearer ${token}` } });
+ if (await handleUnauthorized(res)) return;
+ if (res.ok) {
+ const list = await res.json();
+ const withStats = await Promise.all(
+ list.map(async (svc: any) => {
+ try {
+ const sr = await fetch(`${API}/services/${svc.id}/stats`, {
+ headers: { Authorization: `Bearer ${token}` }
+ });
+ if (sr.ok) svc.stats = await sr.json();
+ } catch {}
+ return svc;
+ })
+ );
+ services = withStats;
+ }
+ } catch {}
+ }
+
+ async function handleSubmit(e: Event) {
+ e.preventDefault();
+ saving = true;
+ formError = '';
+
+ const form = e.target as HTMLFormElement;
+ const fd = new FormData(form);
+
+ const body: Record<string, any> = {
+ name: fd.get('name'),
+ url: fd.get('url'),
+ group_name: (fd.get('group_name') as string) || 'Geral',
+ interval_seconds: parseInt((fd.get('interval_seconds') as string) || '60', 10),
+ };
+ const keyword = fd.get('keyword_to_find') as string;
+ if (keyword) body.keyword_to_find = keyword;
+ const discord = fd.get('discord_webhook_url') as string;
+ if (discord) body.discord_webhook_url = discord;
+ const email = fd.get('alert_email') as string;
+ if (email) body.alert_email = email;
+
+ try {
+ const id = fd.get('id');
+ const method = id ? 'PUT' : 'POST';
+ const url = id ? `${API}/services/${id}` : `${API}/services`;
+ const res = await fetch(url, {
+ method,
+ headers: apiHeaders(),
+ body: JSON.stringify(body),
+ });
+ if (await handleUnauthorized(res)) return;
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ throw new Error(err.error || 'Erro ao salvar');
+ }
+ await reFetch();
+ closeForm();
+ } catch (e: any) {
+ formError = e.message;
+ } finally {
+ saving = false;
+ }
+ }
+
+ async function handleDelete(id: number) {
+ try {
+ const res = await fetch(`${API}/services/${id}`, {
+ method: 'DELETE',
+ headers: { Authorization: `Bearer ${token}` }
+ });
+ if (await handleUnauthorized(res)) return;
+ if (res.ok) await reFetch();
+ } catch {}
+ }
+
+ async function handleToggle(id: number) {
+ toggling = { ...toggling, [id]: true };
+ try {
+ const res = await fetch(`${API}/services/${id}/toggle`, {
+ method: 'PATCH',
+ headers: { Authorization: `Bearer ${token}` }
+ });
+ if (await handleUnauthorized(res)) return;
+ if (res.ok) {
+ const updated = await res.json();
+ services = services.map((s) => (s.id === id ? { ...s, ...updated } : s));
+ }
+ } catch { /* ignore */ }
+ toggling = { ...toggling, [id]: false };
+ }
+
+ async function handleTest(id: number) {
+ testing = { ...testing, [id]: true };
+ try {
+ const res = await fetch(`${API}/services/${id}/test`, {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${token}` }
+ });
+ if (await handleUnauthorized(res)) return;
+ if (res.ok) {
+ const hb = await res.json();
+ services = services.map((s) =>
+ s.id === id ? { ...s, last_heartbeat: hb } : s
+ );
+ }
+ } catch { /* ignore */ }
+ testing = { ...testing, [id]: false };
+ }
+</script>
+
+<svelte:head>
+ <title>Admin — YAUM</title>
+</svelte:head>
+
+<div class="mb-8 flex items-center justify-between">
+ <div>
+ <h1 class="text-2xl font-semibold tracking-tight text-white">Painel de Controle</h1>
+ <p class="mt-1 text-sm text-[var(--text-muted)]">Gerenciamento de serviços monitorados</p>
+ </div>
+ <a
+ href="/"
+ class="text-xs text-[var(--text-muted)] underline transition-colors hover:text-white"
+ >
+ ← Dashboard
+ </a>
+</div>
+
+<div class="mb-8">
+ <button
+ onclick={openCreate}
+ class="flex items-center gap-2 rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] px-4 py-2.5 text-sm font-medium text-[var(--text-primary)] transition-all hover:border-[var(--green)]/50 hover:text-[var(--green)] hover:shadow-[0_0_20px_rgba(34,240,106,0.08)]"
+ >
+ <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
+ </svg>
+ Novo Serviço
+ </button>
+</div>
+
+{#if data.error}
+ <div class="rounded-xl border border-[var(--red)]/20 bg-[var(--red)]/5 p-6 text-center">
+ <p class="text-sm text-[var(--red)]">{data.error}</p>
+ </div>
+{:else if services.length === 0}
+ <div class="rounded-xl border border-dashed border-[var(--border-color)] p-12 text-center">
+ <p class="text-sm text-[var(--text-muted)]">Nenhum serviço cadastrado</p>
+ </div>
+{:else}
+ {#each [...new Set(services.map((s) => s.group_name || 'Geral'))].sort() as group}
+ <div class="mb-6">
+ <h2 class="mb-3 text-xs font-semibold uppercase tracking-wider text-[var(--text-secondary)]">{group}</h2>
+ <div class="overflow-hidden rounded-xl border border-[var(--border-color)]">
+ <table class="w-full text-left text-sm">
+ <thead>
+ <tr class="border-b border-[var(--border-color)] bg-[var(--bg-secondary)]/30">
+ <th class="px-4 py-3 text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)]">ID</th>
+ <th class="px-4 py-3 text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)]">Nome</th>
+ <th class="hidden px-4 py-3 text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)] md:table-cell">URL</th>
+ <th class="px-4 py-3 text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)]">Status</th>
+ <th class="px-4 py-3 text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)]">SLA 30d</th>
+ <th class="px-4 py-3 text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)]">Ações</th>
+ </tr>
+ </thead>
+ <tbody>
+ {#each services.filter((s) => (s.group_name || 'Geral') === group) as svc (svc.id)}
+ <tr class="border-b border-[var(--border-color)] transition-colors hover:bg-[var(--bg-secondary)]/20">
+ <td class="px-4 py-3 font-mono text-xs text-[var(--text-muted)]">{svc.id}</td>
+ <td class="px-4 py-3 font-medium text-white">{svc.name}</td>
+ <td class="hidden max-w-[200px] truncate px-4 py-3 font-mono text-xs text-[var(--text-muted)] md:table-cell">{svc.url}</td>
+ <td class="px-4 py-3">
+ <span
+ class="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-[10px] font-semibold"
+ style="background-color: {svc.last_heartbeat?.is_up ?? true ? 'rgba(34,240,106,0.1)' : 'rgba(255,64,96,0.1)'}; color: {svc.last_heartbeat?.is_up ?? true ? 'var(--green)' : 'var(--red)'}"
+ >
+ {svc.last_heartbeat?.is_up ?? true ? 'UP' : 'DOWN'}
+ </span>
+ </td>
+ <td class="px-4 py-3 font-mono text-xs tabular-nums">
+ {svc.stats ? svc.stats.uptime_30d.toFixed(2) + '%' : '—'}
+ </td>
+ <td class="px-4 py-3">
+ <div class="flex flex-wrap gap-1.5">
+ <button
+ onclick={() => openEdit(svc)}
+ class="rounded-lg px-2.5 py-1 text-[10px] font-medium text-[var(--text-secondary)] transition-colors hover:bg-[var(--border-color)] hover:text-white"
+ >
+ Editar
+ </button>
+ <button
+ onclick={() => handleToggle(svc.id)}
+ disabled={toggling[svc.id] ?? false}
+ class="rounded-lg px-2.5 py-1 text-[10px] font-medium transition-colors"
+ style="color: {svc.is_active ?? true ? 'var(--green)' : 'var(--red)'}; {(svc.is_active ?? true) ? 'border:1px solid rgba(34,240,106,0.3)' : 'border:1px solid rgba(255,64,96,0.3)'}; {toggling[svc.id] ? 'opacity:0.5' : ''}"
+ >
+ {svc.is_active ?? true ? 'Ativo' : 'Pausado'}
+ </button>
+ <button
+ onclick={() => handleTest(svc.id)}
+ disabled={testing[svc.id] ?? false}
+ class="rounded-lg px-2.5 py-1 text-[10px] font-medium text-[var(--text-secondary)] transition-colors hover:bg-[var(--border-color)] hover:text-white"
+ style={testing[svc.id] ? 'opacity:0.5' : ''}
+ >
+ {testing[svc.id] ? '...' : 'Testar'}
+ </button>
+ <button
+ onclick={() => handleDelete(svc.id)}
+ class="rounded-lg px-2.5 py-1 text-[10px] font-medium text-[var(--red)] transition-colors hover:bg-[var(--red)]/10"
+ >
+ Excluir
+ </button>
+ </div>
+ </td>
+ </tr>
+ {/each}
+ </tbody>
+ </table>
+ </div>
+ </div>
+ {/each}
+{/if}
+
+<!-- Modal create / edit -->
+{#if showForm}
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
+ <div
+ role="button"
+ tabindex="-1"
+ aria-label="Fechar"
+ class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
+ onclick={closeForm}
+ >
+ <div
+ class="w-full max-w-md animate-[modalIn_0.2s_ease-out] rounded-2xl border border-[var(--border-color)] bg-[var(--bg-secondary)] p-6 shadow-2xl"
+ onclick={(e) => e.stopPropagation()}
+ >
+ <div class="mb-5 flex items-center justify-between">
+ <h2 class="text-sm font-semibold uppercase tracking-wider text-[var(--text-secondary)]">
+ {editingSvc ? 'Editar Serviço' : 'Novo Serviço'}
+ </h2>
+ <button
+ onclick={closeForm}
+ class="rounded-lg p-1.5 text-[var(--text-muted)] transition-colors hover:bg-[var(--border-color)] hover:text-white"
+ aria-label="Fechar"
+ >
+ <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
+ </svg>
+ </button>
+ </div>
+
+ <form onsubmit={handleSubmit} class="space-y-4">
+ {#if editingSvc}
+ <input type="hidden" name="id" value={editingSvc.id} />
+ {/if}
+
+ <div>
+ <label for="admin-name" class="mb-1.5 block text-xs font-medium text-[var(--text-secondary)]">Nome do Serviço</label>
+ <input
+ id="admin-name"
+ name="name"
+ type="text"
+ value={editingSvc?.name ?? ''}
+ required
+ placeholder="Meu Site"
+ class="w-full rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] px-3.5 py-2.5 text-sm text-white outline-none transition-all placeholder:text-[var(--text-muted)] focus:border-[var(--green)]/50 focus:ring-1 focus:ring-[var(--green)]/20"
+ />
+ </div>
+
+ <div>
+ <label for="admin-url" class="mb-1.5 block text-xs font-medium text-[var(--text-secondary)]">URL</label>
+ <input
+ id="admin-url"
+ name="url"
+ type="url"
+ value={editingSvc?.url ?? ''}
+ required
+ placeholder="https://exemplo.com"
+ class="w-full rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] px-3.5 py-2.5 text-sm text-white outline-none transition-all placeholder:text-[var(--text-muted)] focus:border-[var(--green)]/50 focus:ring-1 focus:ring-[var(--green)]/20"
+ />
+</div>
+
+<div>
+ <label for="admin-group" class="mb-1.5 block text-xs font-medium text-[var(--text-secondary)]">Grupo / Categoria</label>
+ <input
+ id="admin-group"
+ name="group_name"
+ type="text"
+ value={editingSvc?.group_name ?? 'Geral'}
+ placeholder="APIs, Websites, Servidores..."
+ class="w-full rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] px-3.5 py-2.5 text-sm text-white outline-none transition-all placeholder:text-[var(--text-muted)] focus:border-[var(--green)]/50 focus:ring-1 focus:ring-[var(--green)]/20"
+ />
+</div>
+
+<div>
+ <label for="admin-interval" class="mb-1.5 block text-xs font-medium text-[var(--text-secondary)]">Intervalo (segundos)</label>
+ <input
+ id="admin-interval"
+ name="interval_seconds"
+ type="number"
+ value={editingSvc?.interval_seconds ?? 60}
+ min="10"
+ max="3600"
+ class="w-full rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] px-3.5 py-2.5 text-sm text-white outline-none transition-all placeholder:text-[var(--text-muted)] focus:border-[var(--green)]/50 focus:ring-1 focus:ring-[var(--green)]/20"
+ />
+ </div>
+
+ <div class="border-t border-[var(--border-color)] pt-4">
+ <p class="mb-3 text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)]">Opcionais</p>
+
+ <div>
+ <label for="admin-keyword" class="mb-1.5 block text-xs font-medium text-[var(--text-secondary)]">Palavra-chave</label>
+ <input
+ id="admin-keyword"
+ name="keyword_to_find"
+ type="text"
+ value={editingSvc?.keyword_to_find ?? ''}
+ placeholder="Bem-vindo, Login..."
+ class="w-full rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] px-3.5 py-2.5 text-sm text-white outline-none transition-all placeholder:text-[var(--text-muted)] focus:border-[var(--green)]/50 focus:ring-1 focus:ring-[var(--green)]/20"
+ />
+ </div>
+
+ <div class="mt-3">
+ <label for="admin-webhook" class="mb-1.5 block text-xs font-medium text-[var(--text-secondary)]">Webhook Discord</label>
+ <input
+ id="admin-webhook"
+ name="discord_webhook_url"
+ type="url"
+ value={editingSvc?.discord_webhook_url ?? ''}
+ placeholder="https://discord.com/api/webhooks/..."
+ class="w-full rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] px-3.5 py-2.5 text-sm text-white outline-none transition-all placeholder:text-[var(--text-muted)] focus:border-[var(--green)]/50 focus:ring-1 focus:ring-[var(--green)]/20"
+ />
+ </div>
+
+ <div class="mt-3">
+ <label for="admin-email" class="mb-1.5 block text-xs font-medium text-[var(--text-secondary)]">E-mail de Alerta</label>
+ <input
+ id="admin-email"
+ name="alert_email"
+ type="email"
+ value={editingSvc?.alert_email ?? ''}
+ placeholder="admin@exemplo.com"
+ class="w-full rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] px-3.5 py-2.5 text-sm text-white outline-none transition-all placeholder:text-[var(--text-muted)] focus:border-[var(--green)]/50 focus:ring-1 focus:ring-[var(--green)]/20"
+ />
+ </div>
+ </div>
+
+ {#if formError}
+ <p class="rounded-lg bg-[var(--red)]/5 px-3 py-2 text-xs text-[var(--red)]">{formError}</p>
+ {/if}
+
+ <div class="flex gap-3 pt-1">
+ <button
+ type="button"
+ onclick={closeForm}
+ class="flex-1 rounded-xl border border-[var(--border-color)] px-4 py-2.5 text-sm font-medium text-[var(--text-muted)] transition-colors hover:border-[var(--text-muted)]/30 hover:text-white"
+ >
+ Cancelar
+ </button>
+ <button
+ type="submit"
+ disabled={saving}
+ class="flex-1 rounded-xl border border-[var(--green)] bg-[var(--green)]/10 px-4 py-2.5 text-sm font-medium text-[var(--green)] transition-all hover:bg-[var(--green)]/20 disabled:cursor-not-allowed disabled:opacity-40"
+ >
+ {saving ? 'Salvando…' : editingSvc ? 'Salvar' : 'Adicionar'}
+ </button>
+ </div>
+ </form>
+ </div>
+ </div>
+{/if}
+
+<style>
+ @keyframes modalIn {
+ from { opacity: 0; transform: scale(0.95) translateY(-8px); }
+ to { opacity: 1; transform: scale(1) translateY(0); }
+ }
+</style>
diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte
new file mode 100644
index 0000000..9ed74ce
--- /dev/null
+++ b/frontend/src/routes/+layout.svelte
@@ -0,0 +1,50 @@
+<script lang="ts">
+ import { onMount } from 'svelte';
+ import type { Maintenance } from '$lib/types';
+ import '../app.css';
+
+ let { children } = $props();
+ let maintenance = $state<Maintenance | null>(null);
+
+ onMount(async () => {
+ try {
+ const res = await fetch('http://localhost:8080/api/active-maintenance');
+ if (res.ok) {
+ const data = await res.json();
+ maintenance = data.maintenance ?? null;
+ }
+ } catch { /* ignore */ }
+ });
+</script>
+
+<div class="min-h-screen">
+ {#if maintenance}
+ <div class="border-b border-[#facc15]/30 bg-[#facc15]/5 px-6 py-3">
+ <div class="mx-auto flex max-w-6xl items-center justify-center gap-2">
+ <svg class="h-4 w-4 shrink-0 text-[#facc15]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
+ </svg>
+ <p class="text-xs font-medium text-[#facc15]">{maintenance.title}</p>
+ </div>
+ </div>
+ {/if}
+
+ <header class="border-b border-[var(--border-color)] px-6 py-4">
+ <div class="mx-auto flex max-w-6xl items-center justify-between">
+ <div class="flex items-center gap-3">
+ <a href="/" class="text-lg font-bold tracking-tight text-white transition-colors hover:text-[var(--green)]">
+ <span class="text-[var(--green)]">●</span> YAUM
+ </a>
+ <span class="hidden text-xs text-[var(--text-muted)] sm:inline">Yet Another Uptime Monitor</span>
+ </div>
+ <nav class="flex items-center gap-4">
+ <a href="/" class="text-xs text-[var(--text-muted)] transition-colors hover:text-white">Dashboard</a>
+ <a href="/admin" class="text-xs text-[var(--text-muted)] transition-colors hover:text-white">Admin</a>
+ </nav>
+ </div>
+ </header>
+
+ <main class="mx-auto max-w-6xl px-4 py-8">
+ {@render children()}
+ </main>
+</div>
diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte
new file mode 100644
index 0000000..511460a
--- /dev/null
+++ b/frontend/src/routes/+page.svelte
@@ -0,0 +1,164 @@
+<script lang="ts">
+ import { onMount } from 'svelte';
+ import { deleteService } from '$lib/api';
+ import ServiceCard from '$lib/components/ServiceCard.svelte';
+ import AddServiceModal from '$lib/components/AddServiceModal.svelte';
+ import ActivityLog from '$lib/components/ActivityLog.svelte';
+ import { subscribeHeartbeats } from '$lib/realtime';
+ import type { Service, Heartbeat } from '$lib/types';
+
+ let { data } = $props<{
+ data: { services: (Service & { heartbeats: Heartbeat[] })[]; error: string };
+ }>();
+
+ let initialized = $state(false);
+ let services = $state<(Service & { heartbeats: Heartbeat[] })[]>([]);
+ let error = $state('');
+ let showModal = $state(false);
+
+ $effect(() => {
+ if (!initialized && data) {
+ services = [...data.services];
+ error = data.error;
+ initialized = true;
+ }
+ });
+
+ interface LogEntry {
+ kind: 'check' | 'created' | 'deleted' | 'transition';
+ serviceName: string;
+ serviceUrl: string;
+ timestamp: Date;
+ hb?: Heartbeat;
+ wasUp?: boolean;
+ }
+
+ let logEntries = $state<LogEntry[]>([]);
+
+ function pushEntry(entry: LogEntry) {
+ logEntries = [...logEntries, entry];
+ if (logEntries.length > 200) {
+ logEntries = logEntries.slice(-150);
+ }
+ }
+
+ function handleCreated(svc: Service) {
+ services = [{ ...svc, heartbeats: [] }, ...services];
+ pushEntry({
+ kind: 'created',
+ serviceName: svc.name,
+ serviceUrl: svc.url,
+ timestamp: new Date()
+ });
+ }
+
+ async function handleDeleted(id: number) {
+ const svc = services.find((s) => s.id === id);
+ try {
+ await deleteService(id);
+ services = services.filter((s) => s.id !== id);
+ if (svc) {
+ pushEntry({
+ kind: 'deleted',
+ serviceName: svc.name,
+ serviceUrl: svc.url,
+ timestamp: new Date()
+ });
+ }
+ } catch {
+ // silent
+ }
+ }
+
+ onMount(() => {
+ const unsub = subscribeHeartbeats((hb) => {
+ let wasUp: boolean | undefined;
+ services = services.map((s) => {
+ if (s.id !== hb.service_id) return s;
+ if (s.last_heartbeat) {
+ wasUp = s.last_heartbeat.is_up;
+ }
+ return {
+ ...s,
+ last_heartbeat: hb,
+ heartbeats: [...s.heartbeats, hb].slice(-30)
+ };
+ });
+
+ const svc = services.find((s) => s.id === hb.service_id);
+ if (!svc) return;
+
+ const kind = wasUp !== undefined && wasUp !== hb.is_up ? 'transition' : 'check';
+ pushEntry({
+ kind,
+ serviceName: svc.name,
+ serviceUrl: svc.url,
+ timestamp: new Date(),
+ hb,
+ wasUp
+ });
+ });
+ return unsub;
+ });
+</script>
+
+<svelte:head>
+ <title>YAUM — Yet Another Uptime Monitor</title>
+</svelte:head>
+
+<div class="mb-8">
+ <h1 class="text-2xl font-semibold tracking-tight text-white">Dashboard</h1>
+ <p class="mt-1 text-sm text-[var(--text-muted)]">Status dos serviços monitorados</p>
+</div>
+
+{#if error}
+ <div class="rounded-2xl border border-[var(--red)]/20 bg-[var(--red)]/5 p-8 text-center">
+ <svg class="mx-auto mb-3 h-8 w-8 text-[var(--red)]/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
+ </svg>
+ <p class="text-sm text-[var(--red)]">{error}</p>
+ <button
+ onclick={() => location.reload()}
+ class="mt-4 text-xs font-medium text-[var(--text-muted)] underline underline-offset-2 transition-colors hover:text-white"
+ >
+ Tentar novamente
+ </button>
+ </div>
+{:else if services.length === 0}
+ <div class="rounded-2xl border border-dashed border-[var(--border-color)] p-16 text-center">
+ <div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-[var(--bg-card)]">
+ <svg class="h-6 w-6 text-[var(--text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z" />
+ </svg>
+ </div>
+ <p class="text-sm font-medium text-[var(--text-secondary)]">Nenhum serviço sendo monitorado</p>
+ <p class="mt-1 text-xs text-[var(--text-muted)]">Adicione o primeiro serviço para começar</p>
+ <button
+ onclick={() => (showModal = true)}
+ class="mt-6 rounded-xl border border-[var(--green)]/30 bg-[var(--green)]/5 px-5 py-2 text-sm font-medium text-[var(--green)] transition-all hover:bg-[var(--green)]/10"
+ >
+ + Adicionar Serviço
+ </button>
+ </div>
+{:else}
+ {#each [...new Set(services.map((s) => s.group_name || 'Geral'))].sort() as group}
+ <div class="mb-8">
+ <h2 class="mb-4 text-xs font-semibold uppercase tracking-wider text-[var(--text-secondary)]">{group}</h2>
+ <div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
+ {#each services.filter((s) => (s.group_name || 'Geral') === group) as svc (svc.id)}
+ <ServiceCard service={svc} heartbeats={svc.heartbeats} ondeleted={handleDeleted} />
+ {/each}
+ </div>
+ </div>
+ {/each}
+
+ <div class="mt-8">
+ <ActivityLog entries={logEntries} />
+ </div>
+{/if}
+
+<AddServiceModal
+ show={showModal}
+ onclose={() => (showModal = false)}
+ oncreated={handleCreated}
+/>
diff --git a/frontend/src/routes/+page.ts b/frontend/src/routes/+page.ts
new file mode 100644
index 0000000..ba70169
--- /dev/null
+++ b/frontend/src/routes/+page.ts
@@ -0,0 +1,33 @@
+import type { Service, Heartbeat } from '$lib/types';
+
+export interface EnhancedService extends Service {
+ heartbeats: Heartbeat[];
+}
+
+export async function load({ fetch }) {
+ let services: EnhancedService[] = [];
+ let error = '';
+
+ try {
+ const res = await fetch('/api/services');
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const list: Service[] = await res.json();
+
+ services = await Promise.all(
+ list.map(async (svc) => {
+ try {
+ const hres = await fetch(`/api/services/${svc.id}/history`);
+ if (!hres.ok) throw new Error(`HTTP ${hres.status}`);
+ const heartbeats: Heartbeat[] = await hres.json();
+ return { ...svc, heartbeats };
+ } catch {
+ return { ...svc, heartbeats: [] };
+ }
+ })
+ );
+ } catch (e) {
+ error = 'Não foi possível conectar ao servidor';
+ }
+
+ return { services, error };
+}
diff --git a/frontend/src/routes/login/+page.server.js b/frontend/src/routes/login/+page.server.js
new file mode 100644
index 0000000..4272ef7
--- /dev/null
+++ b/frontend/src/routes/login/+page.server.js
@@ -0,0 +1,51 @@
+import { redirect } from '@sveltejs/kit';
+
+const API = 'http://localhost:8080/api';
+
+export async function load({ cookies }) {
+ const token = cookies.get('auth_token');
+ if (token) {
+ const res = await fetch(`${API}/auth/verify`, {
+ headers: { Authorization: `Bearer ${token}` }
+ });
+ if (res.ok) {
+ throw redirect(302, '/admin');
+ }
+ }
+ return {};
+}
+
+export const actions = {
+ default: async ({ request, cookies }) => {
+ const data = await request.formData();
+ const username = data.get('username');
+ const password = data.get('password');
+
+ if (!username || !password) {
+ return { error: 'Preencha todos os campos' };
+ }
+
+ const res = await fetch(`${API}/auth/login`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ username, password })
+ });
+
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ return { error: err.error || 'Credenciais inválidas' };
+ }
+
+ const { token } = await res.json();
+
+ cookies.set('auth_token', token, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === 'production',
+ sameSite: 'lax',
+ path: '/',
+ maxAge: 7 * 24 * 60 * 60
+ });
+
+ throw redirect(302, '/admin');
+ }
+};
diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte
new file mode 100644
index 0000000..a472c89
--- /dev/null
+++ b/frontend/src/routes/login/+page.svelte
@@ -0,0 +1,61 @@
+<script lang="ts">
+ let { form }: { form?: { error?: string } } = $props();
+</script>
+
+<svelte:head>
+ <title>Login — YAUM</title>
+</svelte:head>
+
+<div class="flex min-h-screen items-start justify-center pt-36">
+ <div class="w-full max-w-sm">
+ <div class="mb-8 text-center">
+ <h1 class="text-xl font-semibold tracking-tight text-white">YAUM</h1>
+ <p class="mt-1 text-sm text-[var(--text-muted)]">Yet Another Uptime Monitor</p>
+ </div>
+
+ <div class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg-card)] p-6">
+ <form method="POST" class="space-y-4">
+ <div>
+ <label for="username" class="mb-1.5 block text-xs font-medium text-[var(--text-secondary)]">Usuário</label>
+ <input
+ id="username"
+ name="username"
+ type="text"
+ required
+ autocomplete="username"
+ placeholder="admin"
+ class="w-full rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)] px-3.5 py-2.5 text-sm text-white outline-none transition-all placeholder:text-[var(--text-muted)] focus:border-[var(--green)]/50 focus:ring-1 focus:ring-[var(--green)]/20"
+ />
+ </div>
+
+ <div>
+ <label for="password" class="mb-1.5 block text-xs font-medium text-[var(--text-secondary)]">Senha</label>
+ <input
+ id="password"
+ name="password"
+ type="password"
+ required
+ autocomplete="current-password"
+ placeholder="••••••••"
+ class="w-full rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)] px-3.5 py-2.5 text-sm text-white outline-none transition-all placeholder:text-[var(--text-muted)] focus:border-[var(--green)]/50 focus:ring-1 focus:ring-[var(--green)]/20"
+ />
+ </div>
+
+ {#if form?.error}
+ <p class="rounded-lg bg-[var(--red)]/5 px-3 py-2 text-xs text-[var(--red)]">{form.error}</p>
+ {/if}
+
+ <button
+ type="submit"
+ class="w-full rounded-xl border border-[var(--green)]/50 bg-[var(--green)]/10 px-4 py-2.5 text-sm font-medium text-[var(--green)] transition-all hover:bg-[var(--green)]/20"
+ >
+ Entrar
+ </button>
+ </form>
+ </div>
+
+ <p class="mt-6 text-center text-xs text-[var(--text-muted)]">
+ <a href="/" class="underline transition-colors hover:text-white">← Voltar ao Dashboard</a>
+ </p>
+ </div>
+</div>
diff --git a/frontend/src/routes/services/[id]/+page.svelte b/frontend/src/routes/services/[id]/+page.svelte
new file mode 100644
index 0000000..36ea105
--- /dev/null
+++ b/frontend/src/routes/services/[id]/+page.svelte
@@ -0,0 +1,261 @@
+<script lang="ts">
+ import { onMount } from 'svelte';
+ import { page } from '$app/stores';
+ import { getHistory, getServiceStats, listServices } from '$lib/api';
+ import { subscribeHeartbeats } from '$lib/realtime';
+ import type { Service, Heartbeat, ServiceStats } from '$lib/types';
+
+ let svc: Service | undefined = $state();
+ let history: Heartbeat[] = $state([]);
+ let stats: ServiceStats | null = $state(null);
+ let loading = $state(true);
+ let expandedError: number | null = $state(null);
+
+ onMount(async () => {
+ try {
+ const all = await listServices();
+ svc = all.find((s) => s.id === Number($page.params.id));
+ history = await getHistory(Number($page.params.id));
+ getServiceStats(Number($page.params.id)).then((s) => (stats = s)).catch(() => {});
+ } catch {
+ // handle
+ } finally {
+ loading = false;
+ }
+ });
+
+ $effect(() => {
+ if (!svc && $page.params.id) {
+ getHistory(Number($page.params.id))
+ .then((h) => (history = h))
+ .catch(() => {});
+ }
+ });
+
+ onMount(() => {
+ const unsub = subscribeHeartbeats((hb) => {
+ if (hb.service_id !== Number($page.params.id)) return;
+ history = [...history, hb].slice(-50);
+ });
+ return unsub;
+ });
+
+ let chartPad = { t: 8, b: 20, l: 40, r: 12 };
+ let chartW = $derived(Math.max(history.length * 32, 300));
+ let chartH = 200;
+ let plotW = $derived(chartW - chartPad.l - chartPad.r);
+ let plotH = $derived(chartH - chartPad.t - chartPad.b);
+ let chartMax = $derived(Math.max(...history.map((h) => h.response_time_ms), 100));
+ let chartMin = $derived(0);
+
+ let dots = $derived(
+ history.map((h, i) => {
+ const x = history.length === 1
+ ? chartPad.l + plotW / 2
+ : chartPad.l + (i / (history.length - 1)) * plotW;
+ const y = chartPad.t + plotH - ((h.response_time_ms - chartMin) / (chartMax - chartMin || 1)) * plotH;
+ return { x, y, ...h };
+ })
+ );
+
+ let yTicks = $derived.by(() => {
+ const n = 4;
+ return Array.from({ length: n + 1 }, (_, i) => {
+ const val = chartMin + ((chartMax - chartMin) / n) * i;
+ const y = chartPad.t + plotH - ((val - chartMin) / (chartMax - chartMin || 1)) * plotH;
+ return { val: Math.round(val), y };
+ });
+ });
+</script>
+
+<svelte:head>
+ <title>{svc?.name ?? 'Detalhes'} — YAUM</title>
+</svelte:head>
+
+{#if loading}
+ <div class="flex items-center justify-center py-20">
+ <div
+ class="h-8 w-8 animate-spin rounded-full border-2 border-[var(--border-color)] border-t-[var(--green)]"
+ ></div>
+ </div>
+{:else if !svc}
+ <div class="rounded-xl border border-[var(--border-color)] p-12 text-center">
+ <p class="text-sm text-[var(--text-muted)]">Serviço não encontrado</p>
+ </div>
+{:else}
+ <div class="mb-6">
+ <a
+ href="/"
+ class="text-xs text-[var(--text-muted)] underline transition-colors hover:text-white"
+ >
+ ← Voltar
+ </a>
+ </div>
+
+ <div class="mb-8">
+ <h1 class="text-2xl font-semibold text-white">{svc.name}</h1>
+ <p class="mt-1 font-mono text-sm text-[var(--text-secondary)]">{svc.url}</p>
+ </div>
+
+ <div class="grid gap-6 lg:grid-cols-3">
+ <!-- Stats card -->
+ {#if stats}
+ <div class="rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] p-5">
+ <h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-[var(--text-secondary)]">
+ Uptime
+ </h2>
+ <div class="space-y-3">
+ {#each [
+ { label: '24h', uptime: stats.uptime_24h, checks: stats.total_checks_24h, avg: stats.avg_response_ms_24h },
+ { label: '7 dias', uptime: stats.uptime_7d, checks: stats.total_checks_7d, avg: stats.avg_response_ms_7d },
+ { label: '30 dias', uptime: stats.uptime_30d, checks: stats.total_checks_30d, avg: stats.avg_response_ms_30d }
+ ] as item}
+ <div class="rounded-lg border border-[var(--border-color)] bg-[var(--bg-secondary)]/30 p-3">
+ <div class="mb-1 flex items-center justify-between">
+ <span class="text-[10px] font-medium uppercase tracking-wider text-[var(--text-muted)]">
+ {item.label}
+ </span>
+ <span class="font-mono text-xs tabular-nums text-[var(--text-secondary)]">
+ {item.checks} checks
+ </span>
+ </div>
+ <div class="flex items-baseline gap-3">
+ <span
+ class="text-lg font-bold tabular-nums"
+ style="color: {item.uptime >= 99 ? 'var(--green)' : item.uptime >= 95 ? '#facc15' : 'var(--red)'}"
+ >
+ {item.uptime.toFixed(2)}%
+ </span>
+ <span class="font-mono text-xs text-[var(--text-muted)]">
+ {item.avg.toFixed(0)}ms méd.
+ </span>
+ </div>
+ </div>
+ {/each}
+ </div>
+ </div>
+ {/if}
+
+ <!-- Tempo de Resposta (ms) -->
+ <div
+ class="rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] p-5 lg:col-span-2"
+ >
+ <h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-[var(--text-secondary)]">
+ Tempo de Resposta (ms)
+ </h2>
+ <div class="relative h-48">
+ {#if history.length === 0}
+ <div class="flex h-full items-center justify-center text-xs text-[var(--text-muted)]">
+ Sem dados ainda
+ </div>
+ {:else if history.length === 1}
+ <div class="flex h-full flex-col items-center justify-center gap-1">
+ <span
+ class="text-3xl font-bold tabular-nums"
+ style="color: {history[0].is_up ? 'var(--green)' : 'var(--red)'}"
+ >
+ {history[0].response_time_ms}
+ <span class="text-base font-normal text-[var(--text-secondary)]">ms</span>
+ </span>
+ <span class="text-xs text-[var(--text-muted)]">
+ {history[0].is_up ? 'Online' : 'Offline'} —
+ {history[0].status_code || 'timeout'}
+ </span>
+ </div>
+ {:else}
+ <svg class="h-full w-full" viewBox="0 0 {chartW} {chartH}" preserveAspectRatio="none">
+ {#each yTicks as tick}
+ <line
+ x1={chartPad.l}
+ y1={tick.y}
+ x2={chartW - chartPad.r}
+ y2={tick.y}
+ stroke="var(--border-color)"
+ stroke-width="1"
+ stroke-dasharray="3,3"
+ />
+ <text
+ x={chartPad.l - 6}
+ y={tick.y + 3}
+ text-anchor="end"
+ fill="var(--text-muted)"
+ font-size="9"
+ >{tick.val}</text
+ >
+ {/each}
+
+ <polyline
+ points={dots.map((d) => `${d.x},${d.y}`).join(' ')}
+ fill="none"
+ stroke="var(--green)"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ />
+
+ {#each dots as d}
+ <circle cx={d.x} cy={d.y} r="3.5" fill={d.is_up ? 'var(--green)' : 'var(--red)'} />
+ {/each}
+ </svg>
+ {/if}
+ </div>
+ <div class="mt-2 flex justify-between text-[10px] text-[var(--text-muted)]">
+ <span>{history[0]?.tested_at ? new Date(history[0].tested_at).toLocaleTimeString() : ''}</span>
+ <span
+ >{history[history.length - 1]?.tested_at
+ ? new Date(history[history.length - 1].tested_at).toLocaleTimeString()
+ : ''}</span
+ >
+ </div>
+ </div>
+
+ <!-- Timeline compacta -->
+ <div class="rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] p-5">
+ <h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-[var(--text-secondary)]">
+ Últimas Verificações
+ </h2>
+ <div class="flex flex-col gap-1.5">
+ {#each history.slice().reverse() as h}
+ <div>
+ <div class="flex items-center justify-between gap-2">
+ <div class="flex items-center gap-2">
+ <span
+ class="h-2 w-2 shrink-0 rounded-full"
+ class:bg-[var(--green)]={h.is_up}
+ class:bg-[var(--red)]={!h.is_up}
+ ></span>
+ <span class="font-mono text-xs">
+ {#if h.status_code > 0}
+ {h.status_code}
+ {:else}
+ TIMEOUT
+ {/if}
+ </span>
+ </div>
+ <div class="flex items-center gap-2">
+ <span class="font-mono text-xs text-[var(--text-muted)]">{h.response_time_ms}ms</span>
+ {#if h.error_message}
+ <button
+ onclick={() => (expandedError = expandedError === h.id ? null : h.id)}
+ class="text-[10px] text-[var(--red)] underline underline-offset-2 transition-colors hover:opacity-70"
+ >
+ {expandedError === h.id ? '▲ erro' : '▼ erro'}
+ </button>
+ {/if}
+ </div>
+ </div>
+ {#if expandedError === h.id && h.error_message}
+ <div
+ class="mt-1.5 overflow-auto rounded-md border border-[var(--red)]/20 bg-[var(--bg-secondary)]/50 p-2"
+ >
+ <pre class="break-all font-mono text-[11px] leading-relaxed text-[var(--text-muted)]"
+ >{h.error_message}</pre
+ >
+ </div>
+ {/if}
+ </div>
+ {/each}
+ </div>
+ </div>
+ </div>
+{/if}
diff --git a/frontend/src/routes/status/[id]/+page.svelte b/frontend/src/routes/status/[id]/+page.svelte
new file mode 100644
index 0000000..afc59a2
--- /dev/null
+++ b/frontend/src/routes/status/[id]/+page.svelte
@@ -0,0 +1,278 @@
+<script lang="ts">
+ import type { PageData } from './$types';
+
+ let { data }: { data: PageData } = $props();
+
+ let svc = $derived(data.service);
+ let history = $derived(data.history ?? []);
+ let stats = $derived(data.stats);
+
+ let isUp = $derived(svc?.last_heartbeat?.is_up ?? null);
+ let expandedError: number | null = $state(null);
+
+ let chartPad = { t: 8, b: 20, l: 40, r: 12 };
+ let chartW = $derived(Math.max(history.length * 32, 300));
+ let chartH = 200;
+ let plotW = $derived(chartW - chartPad.l - chartPad.r);
+ let plotH = $derived(chartH - chartPad.t - chartPad.b);
+ let chartMax = $derived(Math.max(...history.map((h: any) => h.response_time_ms), 100));
+
+ let dots = $derived(
+ history.map((h: any, i: number) => {
+ const x = history.length === 1
+ ? chartPad.l + plotW / 2
+ : chartPad.l + (i / (history.length - 1)) * plotW;
+ const y = chartPad.t + plotH - ((h.response_time_ms - 0) / (chartMax || 1)) * plotH;
+ return { x, y, ...h };
+ })
+ );
+
+ let yTicks = $derived.by(() => {
+ if (chartMax <= 0) return [];
+ const n = 4;
+ return Array.from({ length: n + 1 }, (_, i) => {
+ const val = (chartMax / n) * i;
+ const y = chartPad.t + plotH - (val / chartMax) * plotH;
+ return { val: Math.round(val), y };
+ });
+ });
+</script>
+
+<svelte:head>
+ <title>{svc?.name ?? 'Status'} — YAUM</title>
+</svelte:head>
+
+<div class="mx-auto max-w-4xl">
+ {#if !svc}
+ <div class="rounded-xl border border-[var(--border-color)] p-12 text-center">
+ <div class="mx-auto mb-4 h-16 w-16 rounded-2xl bg-[var(--bg-secondary)] p-4">
+ <svg class="h-full w-full text-[var(--text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
+ </svg>
+ </div>
+ <p class="text-sm font-medium text-[var(--text-secondary)]">Serviço não encontrado</p>
+ <p class="mt-1 text-xs text-[var(--text-muted)]">
+ <a href="/" class="underline transition-colors hover:text-white">← Voltar ao início</a>
+ </p>
+ </div>
+ {:else}
+ <!-- Back link -->
+ <div class="mb-6">
+ <a href="/" class="text-xs text-[var(--text-muted)] underline transition-colors hover:text-white">
+ ← Voltar ao Dashboard
+ </a>
+ </div>
+
+ <!-- Hero status -->
+ <div
+ class="mb-8 overflow-hidden rounded-2xl border p-8 text-center transition-all"
+ style="border-color: {isUp === true ? 'rgba(34,240,106,0.3)' : isUp === false ? 'rgba(255,64,96,0.3)' : 'var(--border-color)'}; background-color: {isUp === true ? 'rgba(34,240,106,0.04)' : isUp === false ? 'rgba(255,64,96,0.04)' : 'var(--bg-card)'}"
+ >
+ {#if isUp === null}
+ <div class="mb-2 h-4 w-4 animate-pulse rounded-full bg-[var(--text-muted)] mx-auto"></div>
+ <p class="text-sm font-semibold uppercase tracking-widest text-[var(--text-muted)]">Aguardando verificação</p>
+ {:else}
+ <div
+ class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full"
+ style="background-color: {isUp ? 'rgba(34,240,106,0.15)' : 'rgba(255,64,96,0.15)'}"
+ >
+ {#if isUp}
+ <svg class="h-8 w-8" style="color: var(--green)" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
+ </svg>
+ {:else}
+ <svg class="h-8 w-8" style="color: var(--red)" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
+ </svg>
+ {/if}
+ </div>
+ <h1
+ class="text-3xl font-bold tracking-tight"
+ style="color: {isUp ? 'var(--green)' : 'var(--red)'}"
+ >
+ {isUp ? 'Operando Normalmente' : 'Fora do Ar'}
+ </h1>
+ {/if}
+
+ <div class="mt-4">
+ <h2 class="text-xl font-semibold text-white">{svc.name}</h2>
+ <p class="mt-1 font-mono text-sm text-[var(--text-secondary)]">{svc.url}</p>
+ </div>
+
+ {#if stats}
+ <div class="mt-6 inline-flex items-center gap-6 rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)]/30 px-6 py-3">
+ <div class="text-center">
+ <p class="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">SLA 30 dias</p>
+ <p
+ class="text-xl font-bold tabular-nums"
+ style="color: {stats.uptime_30d >= 99 ? 'var(--green)' : stats.uptime_30d >= 95 ? '#facc15' : 'var(--red)'}"
+ >
+ {stats.uptime_30d.toFixed(2)}%
+ </p>
+ </div>
+ <div class="h-8 w-px bg-[var(--border-color)]"></div>
+ <div class="text-center">
+ <p class="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Latência Média</p>
+ <p class="text-xl font-bold tabular-nums text-white">
+ {stats.avg_response_ms_30d.toFixed(0)}<span class="text-sm font-normal text-[var(--text-secondary)]">ms</span>
+ </p>
+ </div>
+ <div class="h-8 w-px bg-[var(--border-color)]"></div>
+ <div class="text-center">
+ <p class="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Total de Checks</p>
+ <p class="text-xl font-bold tabular-nums text-white">{stats.total_checks_30d}</p>
+ </div>
+ </div>
+ {/if}
+ </div>
+
+ <div class="grid gap-6 lg:grid-cols-5">
+ <!-- SLA Breakdown -->
+ {#if stats}
+ <div class="rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] p-5 lg:col-span-2">
+ <h2 class="mb-4 text-xs font-semibold uppercase tracking-wider text-[var(--text-secondary)]">
+ Uptime
+ </h2>
+ <div class="space-y-2.5">
+ {#each [
+ { label: 'Últimas 24h', uptime: stats.uptime_24h, checks: stats.total_checks_24h, avg: stats.avg_response_ms_24h },
+ { label: 'Últimos 7 dias', uptime: stats.uptime_7d, checks: stats.total_checks_7d, avg: stats.avg_response_ms_7d },
+ { label: 'Últimos 30 dias', uptime: stats.uptime_30d, checks: stats.total_checks_30d, avg: stats.avg_response_ms_30d }
+ ] as item}
+ <div class="rounded-lg border border-[var(--border-color)] bg-[var(--bg-secondary)]/20 p-3">
+ <div class="mb-1 flex items-center justify-between">
+ <span class="text-[10px] font-medium uppercase tracking-wider text-[var(--text-muted)]">{item.label}</span>
+ <span class="font-mono text-[10px] text-[var(--text-muted)]">{item.checks} checks</span>
+ </div>
+ <div class="flex items-baseline gap-3">
+ <span
+ class="text-base font-bold tabular-nums"
+ style="color: {item.uptime >= 99 ? 'var(--green)' : item.uptime >= 95 ? '#facc15' : 'var(--red)'}"
+ >
+ {item.uptime.toFixed(2)}%
+ </span>
+ <span class="font-mono text-[10px] text-[var(--text-muted)]">{item.avg.toFixed(0)}ms méd.</span>
+ </div>
+ </div>
+ {/each}
+ </div>
+ </div>
+ {/if}
+
+ <!-- Latency chart -->
+ <div
+ class="rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] p-5"
+ class:lg:col-span-3={!!stats}
+ class:lg:col-span-5={!stats}
+ >
+ <h2 class="mb-4 text-xs font-semibold uppercase tracking-wider text-[var(--text-secondary)]">
+ Tempo de Resposta (ms)
+ </h2>
+ <div class="relative h-48">
+ {#if history.length === 0}
+ <div class="flex h-full items-center justify-center text-xs text-[var(--text-muted)]">
+ Sem dados ainda
+ </div>
+ {:else if history.length === 1}
+ <div class="flex h-full flex-col items-center justify-center gap-1">
+ <span
+ class="text-3xl font-bold tabular-nums"
+ style="color: {history[0].is_up ? 'var(--green)' : 'var(--red)'}"
+ >
+ {history[0].response_time_ms}
+ <span class="text-base font-normal text-[var(--text-secondary)]">ms</span>
+ </span>
+ <span class="text-xs text-[var(--text-muted)]">
+ {history[0].is_up ? 'Online' : 'Offline'} — {history[0].status_code || 'timeout'}
+ </span>
+ </div>
+ {:else}
+ <svg class="h-full w-full" viewBox="0 0 {chartW} {chartH}" preserveAspectRatio="none">
+ {#each yTicks as tick}
+ <line
+ x1={chartPad.l} y1={tick.y} x2={chartW - chartPad.r} y2={tick.y}
+ stroke="var(--border-color)" stroke-width="1" stroke-dasharray="3,3"
+ />
+ <text x={chartPad.l - 6} y={tick.y + 3} text-anchor="end" fill="var(--text-muted)" font-size="9">{tick.val}</text>
+ {/each}
+ <polyline
+ points={dots.map((d: any) => `${d.x},${d.y}`).join(' ')}
+ fill="none" stroke="var(--green)" stroke-width="2"
+ stroke-linecap="round" stroke-linejoin="round"
+ />
+ {#each dots as d}
+ <circle cx={d.x} cy={d.y} r="3.5" fill={d.is_up ? 'var(--green)' : 'var(--red)'} />
+ {/each}
+ </svg>
+ {/if}
+ </div>
+ <div class="mt-2 flex justify-between text-[10px] text-[var(--text-muted)]">
+ <span>{history[0]?.tested_at ? new Date(history[0].tested_at).toLocaleTimeString() : ''}</span>
+ <span>{history[history.length - 1]?.tested_at ? new Date(history[history.length - 1].tested_at).toLocaleTimeString() : ''}</span>
+ </div>
+ </div>
+ </div>
+
+ <!-- History table -->
+ <div class="mt-6 rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] p-5">
+ <h2 class="mb-4 text-xs font-semibold uppercase tracking-wider text-[var(--text-secondary)]">
+ Histórico de Verificações
+ </h2>
+ {#if history.length === 0}
+ <p class="py-8 text-center text-xs text-[var(--text-muted)]">Nenhuma verificação registrada</p>
+ {:else}
+ <div class="overflow-x-auto">
+ <table class="w-full text-left text-xs">
+ <thead>
+ <tr class="border-b border-[var(--border-color)]">
+ <th class="pb-2 pr-4 text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)]">Horário</th>
+ <th class="pb-2 pr-4 text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)]">Status</th>
+ <th class="pb-2 pr-4 text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)]">Código</th>
+ <th class="pb-2 pr-4 text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)]">Latência</th>
+ <th class="pb-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)]">Erro</th>
+ </tr>
+ </thead>
+ <tbody>
+ {#each [...history].reverse() as h}
+ <tr class="border-b border-[var(--border-color)]/50 transition-colors hover:bg-[var(--bg-secondary)]/20">
+ <td class="py-2 pr-4 font-mono text-[var(--text-muted)]">
+ {new Date(h.tested_at).toLocaleString('pt-BR')}
+ </td>
+ <td class="py-2 pr-4">
+ <span
+ class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-semibold"
+ style="background-color: {h.is_up ? 'rgba(34,240,106,0.1)' : 'rgba(255,64,96,0.1)'}; color: {h.is_up ? 'var(--green)' : 'var(--red)'}"
+ >
+ {h.is_up ? 'UP' : 'DOWN'}
+ </span>
+ </td>
+ <td class="py-2 pr-4 font-mono text-[var(--text-secondary)]">
+ {h.status_code > 0 ? h.status_code : '—'}
+ </td>
+ <td class="py-2 pr-4 font-mono text-[var(--text-secondary)]">
+ {h.response_time_ms}ms
+ </td>
+ <td class="py-2 font-mono text-[var(--text-muted)]">
+ {#if h.error_message}
+ <button
+ onclick={() => (expandedError = expandedError === h.id ? null : h.id)}
+ class="text-[10px] text-[var(--red)] underline underline-offset-2 transition-colors hover:opacity-70"
+ >
+ {expandedError === h.id ? '▲ erro' : '▼ erro'}
+ </button>
+ {#if expandedError === h.id}
+ <pre class="mt-1 break-all text-[11px] leading-relaxed">{(h as any).error_message}</pre>
+ {/if}
+ {:else}
+ —
+ {/if}
+ </td>
+ </tr>
+ {/each}
+ </tbody>
+ </table>
+ </div>
+ {/if}
+ </div>
+ {/if}
+</div>
diff --git a/frontend/src/routes/status/[id]/+page.ts b/frontend/src/routes/status/[id]/+page.ts
new file mode 100644
index 0000000..4351aae
--- /dev/null
+++ b/frontend/src/routes/status/[id]/+page.ts
@@ -0,0 +1,22 @@
+import type { PageLoad } from './$types';
+
+export const load: PageLoad = async ({ params, fetch }) => {
+ const id = params.id;
+
+ const [servicesRes, historyRes, statsRes] = await Promise.all([
+ fetch(`http://localhost:8080/api/services`).catch(() => null),
+ fetch(`http://localhost:8080/api/services/${id}/history`).catch(() => null),
+ fetch(`http://localhost:8080/api/services/${id}/stats`).catch(() => null)
+ ]);
+
+ const services = servicesRes?.ok ? await servicesRes.json() : [];
+ const svc = services.find((s: any) => s.id === Number(id));
+ const history = historyRes?.ok ? await historyRes.json() : [];
+ const stats = statsRes?.ok ? await statsRes.json() : null;
+
+ return {
+ service: svc ?? null,
+ history,
+ stats
+ };
+};