diff options
Diffstat (limited to 'frontend/src/routes')
| -rw-r--r-- | frontend/src/routes/(admin)/admin/+page.server.js | 27 | ||||
| -rw-r--r-- | frontend/src/routes/(admin)/admin/+page.svelte | 432 | ||||
| -rw-r--r-- | frontend/src/routes/+layout.svelte | 50 | ||||
| -rw-r--r-- | frontend/src/routes/+page.svelte | 164 | ||||
| -rw-r--r-- | frontend/src/routes/+page.ts | 33 | ||||
| -rw-r--r-- | frontend/src/routes/login/+page.server.js | 51 | ||||
| -rw-r--r-- | frontend/src/routes/login/+page.svelte | 61 | ||||
| -rw-r--r-- | frontend/src/routes/services/[id]/+page.svelte | 261 | ||||
| -rw-r--r-- | frontend/src/routes/status/[id]/+page.svelte | 278 | ||||
| -rw-r--r-- | frontend/src/routes/status/[id]/+page.ts | 22 |
10 files changed, 1379 insertions, 0 deletions
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 + }; +}; |