diff options
Diffstat (limited to 'frontend/src/lib')
| -rw-r--r-- | frontend/src/lib/api.ts | 39 | ||||
| -rw-r--r-- | frontend/src/lib/components/ActivityLog.svelte | 122 | ||||
| -rw-r--r-- | frontend/src/lib/components/AddServiceModal.svelte | 224 | ||||
| -rw-r--r-- | frontend/src/lib/components/ServiceCard.svelte | 90 | ||||
| -rw-r--r-- | frontend/src/lib/components/UptimeTimeline.svelte | 41 | ||||
| -rw-r--r-- | frontend/src/lib/realtime.ts | 42 | ||||
| -rw-r--r-- | frontend/src/lib/types.ts | 44 |
7 files changed, 602 insertions, 0 deletions
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; +} |