diff options
| author | zwlucas <lucas.fariamo08@gmail.com> | 2026-05-29 20:21:48 +0000 |
|---|---|---|
| committer | zwlucas <lucas.fariamo08@gmail.com> | 2026-05-29 20:21:48 +0000 |
| commit | d186a989b6808ff39fcd43a9f0b478aebc4aa346 (patch) | |
| tree | abf32684ea56e6066d8584e0378227ffc397ace2 /frontend/src | |
| parent | 6cc0bfd1d79074df790272b8091b1f0226d14283 (diff) | |
| download | yaum-d186a989b6808ff39fcd43a9f0b478aebc4aa346.tar.gz yaum-d186a989b6808ff39fcd43a9f0b478aebc4aa346.zip | |
feat: enhance maintenance management and heartbeat history features
- Updated Store interface to include methods for managing maintenances.
- Modified API functions to support pagination for heartbeat history and added CRUD operations for maintenances.
- Enhanced types to include paginated responses and initial log entries.
- Implemented maintenance management UI in the admin panel with create, edit, and delete functionalities.
- Updated service detail page to display paginated heartbeat history and improved chart rendering.
- Refactored status page to accommodate new data structures and ensure consistent data handling.
Signed-off-by: Lucas Faria Mendes <lucas.fariamo08@gmail.com>
Diffstat (limited to 'frontend/src')
| -rw-r--r-- | frontend/src/lib/api.ts | 43 | ||||
| -rw-r--r-- | frontend/src/lib/types.ts | 8 | ||||
| -rw-r--r-- | frontend/src/routes/(admin)/admin/+page.svelte | 309 | ||||
| -rw-r--r-- | frontend/src/routes/+page.svelte | 10 | ||||
| -rw-r--r-- | frontend/src/routes/+page.ts | 44 | ||||
| -rw-r--r-- | frontend/src/routes/services/[id]/+page.svelte | 287 | ||||
| -rw-r--r-- | frontend/src/routes/status/[id]/+page.svelte | 2 | ||||
| -rw-r--r-- | frontend/src/routes/status/[id]/+page.ts | 3 |
8 files changed, 613 insertions, 93 deletions
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 0be9148..239c598 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,4 +1,4 @@ -import type { Service, Heartbeat, ServiceStats } from './types'; +import type { Service, Heartbeat, ServiceStats, PaginatedResponse } from './types'; const BASE = '/api'; @@ -26,8 +26,8 @@ export async function deleteService(id: number): Promise<void> { 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`); +export async function getHistory(id: number, page = 1, perPage = 50): Promise<PaginatedResponse<Heartbeat>> { + const res = await fetch(`${BASE}/services/${id}/history?page=${page}&per_page=${perPage}`); if (!res.ok) throw new Error('falha ao obter histórico'); return res.json(); } @@ -37,3 +37,40 @@ export async function getServiceStats(id: number): Promise<ServiceStats> { if (!res.ok) throw new Error('falha ao obter estatísticas'); return res.json(); } + +export async function listMaintenances(): Promise<Maintenance[]> { + const res = await fetch(`${BASE}/maintenances`); + if (!res.ok) throw new Error('falha ao listar manutenções'); + return res.json(); +} + +export async function createMaintenance(data: { title: string; start_time: string; end_time: string }): Promise<Maintenance> { + const res = await fetch(`${BASE}/maintenances`, { + 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 manutenção'); + } + return res.json(); +} + +export async function updateMaintenance(id: number, data: { title: string; start_time: string; end_time: string; is_active: boolean }): Promise<Maintenance> { + const res = await fetch(`${BASE}/maintenances/${id}`, { + method: 'PUT', + 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 atualizar manutenção'); + } + return res.json(); +} + +export async function deleteMaintenance(id: number): Promise<void> { + const res = await fetch(`${BASE}/maintenances/${id}`, { method: 'DELETE' }); + if (!res.ok) throw new Error('falha ao remover manutenção'); +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index f80c933..594ab05 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -30,6 +30,14 @@ export interface Heartbeat { tested_at: string; } +export interface PaginatedResponse<T> { + data: T[]; + page: number; + per_page: number; + total: number; + total_pages: number; +} + export interface ServiceStats { service_id: number; uptime_24h: number; diff --git a/frontend/src/routes/(admin)/admin/+page.svelte b/frontend/src/routes/(admin)/admin/+page.svelte index 24b56db..5be9154 100644 --- a/frontend/src/routes/(admin)/admin/+page.svelte +++ b/frontend/src/routes/(admin)/admin/+page.svelte @@ -13,6 +13,7 @@ token = data.token; services = data.services ?? []; initialized = true; + fetchMaintenances(); } }); let editingSvc: any = $state(null); @@ -21,10 +22,25 @@ let testing = $state<Record<number, boolean>>({}); let toggling = $state<Record<number, boolean>>({}); + // maintenance state + let maintenances = $state<any[]>([]); + let showMtnForm = $state(false); + let editingMtn: any = $state(null); + let mtnSaving = $state(false); + let mtnFormError = $state(''); + function apiHeaders() { return { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }; } + async function fetchMaintenances() { + try { + const res = await fetch(`${API}/maintenances`, { headers: { Authorization: `Bearer ${token}` } }); + if (await handleUnauthorized(res)) return; + if (res.ok) maintenances = await res.json(); + } catch {} + } + async function handleUnauthorized(res: Response) { if (res.status === 401) { window.location.href = '/login'; @@ -144,6 +160,75 @@ toggling = { ...toggling, [id]: false }; } + function openMtnCreate() { + editingMtn = null; + mtnFormError = ''; + showMtnForm = true; + } + + function openMtnEdit(m: any) { + editingMtn = { ...m }; + mtnFormError = ''; + showMtnForm = true; + } + + function closeMtnForm() { + showMtnForm = false; + editingMtn = null; + mtnFormError = ''; + } + + async function handleMtnSubmit(e: Event) { + e.preventDefault(); + mtnSaving = true; + mtnFormError = ''; + const form = e.target as HTMLFormElement; + const fd = new FormData(form); + const body: Record<string, any> = { + title: fd.get('title'), + start_time: fd.get('start_time'), + end_time: fd.get('end_time'), + is_active: fd.get('is_active') === 'on', + }; + try { + const id = fd.get('id'); + if (id) { + const res = await fetch(`${API}/maintenances/${id}`, { + method: 'PUT', + headers: apiHeaders(), + body: JSON.stringify(body), + }); + if (await handleUnauthorized(res)) return; + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao salvar'); + } else { + const res = await fetch(`${API}/maintenances`, { + method: 'POST', + headers: apiHeaders(), + body: JSON.stringify({ title: body.title, start_time: body.start_time, end_time: body.end_time }), + }); + if (await handleUnauthorized(res)) return; + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao salvar'); + } + await fetchMaintenances(); + closeMtnForm(); + } catch (e: any) { + mtnFormError = e.message; + } finally { + mtnSaving = false; + } + } + + async function handleMtnDelete(id: number) { + try { + const res = await fetch(`${API}/maintenances/${id}`, { + method: 'DELETE', + headers: apiHeaders() + }); + if (await handleUnauthorized(res)) return; + if (res.ok) await fetchMaintenances(); + } catch {} + } + async function handleTest(id: number) { testing = { ...testing, [id]: true }; try { @@ -274,7 +359,78 @@ {/each} {/if} -<!-- Modal create / edit --> +<div class="mb-8 mt-12 border-t border-[var(--border-color)] pt-8"> + <div class="mb-6 flex items-center justify-between"> + <div> + <h2 class="text-sm font-semibold uppercase tracking-wider text-[var(--text-secondary)]">Manutenções Programadas</h2> + <p class="mt-0.5 text-xs text-[var(--text-muted)]">Janelas de manutenção exibem um aviso no topo do site</p> + </div> + <button + onclick={openMtnCreate} + class="flex items-center gap-2 rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] px-4 py-2 text-sm font-medium text-[var(--text-primary)] transition-all hover:border-[var(--green)]/50 hover:text-[var(--green)]" + > + <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> + Nova Manutenção + </button> + </div> + + {#if maintenances.length === 0} + <div class="rounded-xl border border-dashed border-[var(--border-color)] p-8 text-center"> + <p class="text-xs text-[var(--text-muted)]">Nenhuma manutenção agendada</p> + </div> + {:else} + <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)]">Título</th> + <th class="px-4 py-3 text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)]">Início</th> + <th class="px-4 py-3 text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)]">Fim</th> + <th class="px-4 py-3 text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)]">Ativa</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 maintenances as mtn (mtn.id)} + <tr class="border-b border-[var(--border-color)] transition-colors hover:bg-[var(--bg-secondary)]/20"> + <td class="px-4 py-3 font-medium text-white">{mtn.title}</td> + <td class="px-4 py-3 font-mono text-xs text-[var(--text-muted)]">{new Date(mtn.start_time).toLocaleString('pt-BR')}</td> + <td class="px-4 py-3 font-mono text-xs text-[var(--text-muted)]">{new Date(mtn.end_time).toLocaleString('pt-BR')}</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: {mtn.is_active ? 'rgba(34,240,106,0.1)' : 'rgba(255,64,96,0.1)'}; color: {mtn.is_active ? 'var(--green)' : 'var(--red)'}" + > + {mtn.is_active ? 'Sim' : 'Não'} + </span> + </td> + <td class="px-4 py-3"> + <div class="flex gap-1.5"> + <button + onclick={() => openMtnEdit(mtn)} + 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={() => handleMtnDelete(mtn.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> + {/if} +</div> + +<!-- Modal create / edit service --> {#if showForm} <!-- svelte-ignore a11y_click_events_have_key_events --> <div @@ -323,30 +479,30 @@ <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> + <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-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> + <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" @@ -424,6 +580,113 @@ </div> {/if} +<!-- Modal create / edit maintenance --> +{#if showMtnForm} + <!-- 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={closeMtnForm} + > + <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)]"> + {editingMtn ? 'Editar Manutenção' : 'Nova Manutenção'} + </h2> + <button + onclick={closeMtnForm} + 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={handleMtnSubmit} class="space-y-4"> + {#if editingMtn} + <input type="hidden" name="id" value={editingMtn.id} /> + {/if} + + <div> + <label for="mtn-title" class="mb-1.5 block text-xs font-medium text-[var(--text-secondary)]">Título</label> + <input + id="mtn-title" + name="title" + type="text" + value={editingMtn?.title ?? ''} + required + placeholder="Manutenção nos 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="mtn-start" class="mb-1.5 block text-xs font-medium text-[var(--text-secondary)]">Início</label> + <input + id="mtn-start" + name="start_time" + type="datetime-local" + value={editingMtn ? editingMtn.start_time.slice(0, 16) : ''} + required + 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 focus:border-[var(--green)]/50 focus:ring-1 focus:ring-[var(--green)]/20" + /> + </div> + + <div> + <label for="mtn-end" class="mb-1.5 block text-xs font-medium text-[var(--text-secondary)]">Fim</label> + <input + id="mtn-end" + name="end_time" + type="datetime-local" + value={editingMtn ? editingMtn.end_time.slice(0, 16) : ''} + required + 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 focus:border-[var(--green)]/50 focus:ring-1 focus:ring-[var(--green)]/20" + /> + </div> + + <div> + <label class="flex items-center gap-3"> + <input + type="checkbox" + name="is_active" + checked={editingMtn?.is_active ?? true} + class="h-4 w-4 rounded border-[var(--border-color)] bg-[var(--bg-card)] text-[var(--green)] focus:ring-[var(--green)]/30" + /> + <span class="text-xs font-medium text-[var(--text-secondary)]">Ativa</span> + </label> + </div> + + {#if mtnFormError} + <p class="rounded-lg bg-[var(--red)]/5 px-3 py-2 text-xs text-[var(--red)]">{mtnFormError}</p> + {/if} + + <div class="flex gap-3 pt-1"> + <button + type="button" + onclick={closeMtnForm} + 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={mtnSaving} + 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" + > + {mtnSaving ? 'Salvando…' : editingMtn ? 'Salvar' : 'Adicionar'} + </button> + </div> + </form> + </div> + </div> +{/if} <style> @keyframes modalIn { from { opacity: 0; transform: scale(0.95) translateY(-8px); } diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 511460a..172e6b8 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -7,8 +7,10 @@ import { subscribeHeartbeats } from '$lib/realtime'; import type { Service, Heartbeat } from '$lib/types'; + import type { InitialLogEntry } from './+page.ts'; + let { data } = $props<{ - data: { services: (Service & { heartbeats: Heartbeat[] })[]; error: string }; + data: { services: (Service & { heartbeats: Heartbeat[] })[]; error: string; initialLog: InitialLogEntry[] }; }>(); let initialized = $state(false); @@ -35,6 +37,12 @@ let logEntries = $state<LogEntry[]>([]); + $effect(() => { + if (data?.initialLog && logEntries.length === 0) { + logEntries = data.initialLog.map((e) => ({ ...e, timestamp: new Date(e.timestamp) })); + } + }); + function pushEntry(entry: LogEntry) { logEntries = [...logEntries, entry]; if (logEntries.length > 200) { diff --git a/frontend/src/routes/+page.ts b/frontend/src/routes/+page.ts index ba70169..4f8b3f9 100644 --- a/frontend/src/routes/+page.ts +++ b/frontend/src/routes/+page.ts @@ -4,9 +4,19 @@ export interface EnhancedService extends Service { heartbeats: Heartbeat[]; } +export interface InitialLogEntry { + kind: 'check' | 'created' | 'deleted' | 'transition'; + serviceName: string; + serviceUrl: string; + timestamp: Date; + hb?: Heartbeat; + wasUp?: boolean; +} + export async function load({ fetch }) { let services: EnhancedService[] = []; let error = ''; + let initialLog: InitialLogEntry[] = []; try { const res = await fetch('/api/services'); @@ -18,16 +28,46 @@ export async function load({ fetch }) { 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(); + const json = await hres.json(); + const heartbeats: Heartbeat[] = Array.isArray(json) ? json : (json.data ?? []); return { ...svc, heartbeats }; } catch { return { ...svc, heartbeats: [] }; } }) ); + + initialLog = []; + for (const svc of services) { + const hbs = svc.heartbeats; + if (hbs.length === 0) continue; + const last = hbs[hbs.length - 1]; + initialLog.push({ + kind: 'check', + serviceName: svc.name, + serviceUrl: svc.url, + timestamp: new Date(last.tested_at), + hb: last + }); + if (hbs.length >= 2) { + const prev = hbs[hbs.length - 2]; + if (prev.is_up !== last.is_up) { + initialLog.push({ + kind: 'transition', + serviceName: svc.name, + serviceUrl: svc.url, + timestamp: new Date(last.tested_at), + hb: last, + wasUp: prev.is_up + }); + } + } + } + initialLog.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); + initialLog = initialLog.slice(0, 50); } catch (e) { error = 'Não foi possível conectar ao servidor'; } - return { services, error }; + return { services, error, initialLog }; } diff --git a/frontend/src/routes/services/[id]/+page.svelte b/frontend/src/routes/services/[id]/+page.svelte index 36ea105..a7b8877 100644 --- a/frontend/src/routes/services/[id]/+page.svelte +++ b/frontend/src/routes/services/[id]/+page.svelte @@ -6,17 +6,41 @@ import type { Service, Heartbeat, ServiceStats } from '$lib/types'; let svc: Service | undefined = $state(); - let history: Heartbeat[] = $state([]); + let chartHistory: Heartbeat[] = $state([]); let stats: ServiceStats | null = $state(null); let loading = $state(true); + + let tableData: Heartbeat[] = $state([]); + let currentPage = $state(1); + let totalPages = $state(1); + let totalItems = $state(0); + let expandedError: number | null = $state(null); + const PER_PAGE = 50; + + async function loadTablePage(pageNum: number) { + const res = await getHistory(Number($page.params.id), pageNum, PER_PAGE); + tableData = res.data; + currentPage = res.page; + totalPages = res.total_pages; + totalItems = res.total; + } + onMount(async () => { try { const all = await listServices(); svc = all.find((s) => s.id === Number($page.params.id)); - history = await getHistory(Number($page.params.id)); + + const chartRes = await fetch(`/api/services/${$page.params.id}/history?page=1&per_page=50`); + if (chartRes.ok) { + const json = await chartRes.json(); + chartHistory = json.data ?? json; + } + getServiceStats(Number($page.params.id)).then((s) => (stats = s)).catch(() => {}); + + await loadTablePage(1); } catch { // handle } finally { @@ -24,47 +48,117 @@ } }); - $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); + chartHistory = [...chartHistory, hb].slice(-50); + if (currentPage === 1) { + tableData = [hb, ...tableData].slice(0, PER_PAGE); + totalItems++; + totalPages = Math.max(1, Math.ceil(totalItems / PER_PAGE)); + } }); return unsub; }); - let chartPad = { t: 8, b: 20, l: 40, r: 12 }; - let chartW = $derived(Math.max(history.length * 32, 300)); + let containerWidth = $state(0); + let pad = { t: 8, b: 24, l: 44, r: 12 }; 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 plotW = $derived(Math.max(containerWidth - pad.l - pad.r, 0)); + let plotH = $derived(chartH - pad.t - pad.b); + + function niceNum(range: number, round: boolean): number { + const exp = Math.floor(Math.log10(range)); + const frac = range / Math.pow(10, exp); + let nice: number; + if (round) { + if (frac <= 1.5) nice = 1; + else if (frac <= 3) nice = 2; + else if (frac <= 7) nice = 5; + else nice = 10; + } else { + if (frac <= 1) nice = 1; + else if (frac <= 2) nice = 2; + else if (frac <= 5) nice = 5; + else nice = 10; + } + return nice * Math.pow(10, exp); + } + + let yAxis = $derived.by(() => { + const vals = chartHistory.map((h) => h.response_time_ms); + if (vals.length === 0) return { min: 0, max: 100, ticks: [{ val: 0, y: 0 }, { val: 100, y: 200 }] }; + const rawMin = Math.min(...vals); + const rawMax = Math.max(...vals); + if (rawMax === rawMin) return { min: 0, max: Math.max(rawMax * 2, 100), ticks: [] }; + + const range = rawMax - rawMin; + const pad = Math.max(range * 0.15, 10); + let lo = Math.max(0, rawMin - pad); + let hi = rawMax + pad; + + const tickStep = niceNum((hi - lo) / 4, true); + lo = Math.floor(lo / tickStep) * tickStep; + hi = Math.ceil(hi / tickStep) * tickStep; + + const ticks: { val: number; y: number }[] = []; + for (let v = lo; v <= hi + tickStep * 0.001; v += tickStep) { + const y = pad.t + plotH - ((v - lo) / (hi - lo || 1)) * plotH; + ticks.push({ val: Math.round(v), y }); + } + return { min: lo, max: hi, ticks }; + }); + + let segments = $derived.by(() => { + const n = chartHistory.length; + if (n < 2) return []; + const { min, max } = yAxis; + return chartHistory.slice(0, -1).map((h, i) => { + const next = chartHistory[i + 1]; + const x1 = pad.l + (i / (n - 1)) * plotW; + const x2 = pad.l + ((i + 1) / (n - 1)) * plotW; + const y1 = pad.t + plotH - ((h.response_time_ms - min) / (max - min || 1)) * plotH; + const y2 = pad.t + plotH - ((next.response_time_ms - min) / (max - min || 1)) * plotH; + return { + x1, y1, x2, y2, + color: h.is_up && next.is_up ? 'var(--green)' : 'var(--red)' + }; + }); + }); 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; + chartHistory.map((h, i) => { + const { min, max } = yAxis; + const x = pad.l + (i / (Math.max(chartHistory.length - 1, 1))) * plotW; + const y = pad.t + plotH - ((h.response_time_ms - min) / (max - min || 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 }; - }); + let areaPath = $derived.by(() => { + if (chartHistory.length < 2) return ''; + const { min, max } = yAxis; + const baseline = pad.t + plotH; + const top = dots.map((d) => `${d.x},${d.y}`).join(' L '); + const bottom = dots.map((d) => d.x).reverse().map((x) => `${x},${baseline}`).join(' L '); + return `M ${top} L ${bottom} Z`; + }); + + let pages = $derived.by(() => { + const p: (number | string)[] = []; + const total = totalPages; + if (total <= 7) { + for (let i = 1; i <= total; i++) p.push(i); + } else { + p.push(1); + if (currentPage > 3) p.push('...'); + const start = Math.max(2, currentPage - 1); + const end = Math.min(total - 1, currentPage + 1); + for (let i = start; i <= end; i++) p.push(i); + if (currentPage < total - 2) p.push('...'); + p.push(total); + } + return p; }); </script> @@ -143,39 +237,47 @@ <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 bind:clientWidth={containerWidth} class="relative" style="height: 200px"> + {#if chartHistory.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} + {:else if chartHistory.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)'}" + style="color: {chartHistory[0].is_up ? 'var(--green)' : 'var(--red)'}" > - {history[0].response_time_ms} + {chartHistory[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'} + {chartHistory[0].is_up ? 'Online' : 'Offline'} — + {chartHistory[0].status_code || 'timeout'} </span> </div> {:else} - <svg class="h-full w-full" viewBox="0 0 {chartW} {chartH}" preserveAspectRatio="none"> - {#each yTicks as tick} + <svg width={containerWidth} height={chartH}> + <defs> + <linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1"> + <stop offset="0%" stop-color="var(--green)" stop-opacity="0.15" /> + <stop offset="100%" stop-color="var(--green)" stop-opacity="0.01" /> + </linearGradient> + </defs> + + <!-- grid horizontal --> + {#each yAxis.ticks as tick} <line - x1={chartPad.l} + x1={pad.l} y1={tick.y} - x2={chartW - chartPad.r} + x2={containerWidth - pad.r} y2={tick.y} stroke="var(--border-color)" stroke-width="1" stroke-dasharray="3,3" /> <text - x={chartPad.l - 6} + x={pad.l - 6} y={tick.y + 3} text-anchor="end" fill="var(--text-muted)" @@ -184,38 +286,56 @@ > {/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" - /> + <!-- área preenchida --> + <path d={areaPath} fill="url(#areaGrad)" /> + <!-- segmentos da linha --> + {#each segments as seg} + <line + x1={seg.x1} + y1={seg.y1} + x2={seg.x2} + y2={seg.y2} + stroke={seg.color} + stroke-width="2" + stroke-linecap="round" + /> + {/each} + + <!-- pontos --> {#each dots as d} - <circle cx={d.x} cy={d.y} r="3.5" fill={d.is_up ? 'var(--green)' : 'var(--red)'} /> + <circle + cx={d.x} + cy={d.y} + r="3" + fill={d.is_up ? 'var(--green)' : 'var(--red)'} + stroke="var(--bg-card)" + stroke-width="1.5" + /> {/each} </svg> + + <!-- eixo X: tempo --> + <div class="flex justify-between" style="padding-left: {pad.l}px; padding-right: {pad.r}px;"> + <span class="text-[10px] text-[var(--text-muted)]"> + {new Date(chartHistory[0].tested_at).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })} + </span> + <span class="text-[10px] text-[var(--text-muted)]"> + {new Date(chartHistory[chartHistory.length - 1].tested_at).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })} + </span> + </div> {/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"> + <!-- Histórico paginado --> + <div class="rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] p-5 lg:col-span-3"> <h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-[var(--text-secondary)]"> - Últimas Verificações + Histórico de Verificações </h2> + <div class="flex flex-col gap-1.5"> - {#each history.slice().reverse() as h} + {#each tableData as h} <div> <div class="flex items-center justify-between gap-2"> <div class="flex items-center gap-2"> @@ -256,6 +376,49 @@ </div> {/each} </div> + + <!-- Paginação --> + {#if totalPages > 1} + <div class="mt-5 flex items-center justify-center gap-1.5"> + <button + onclick={() => loadTablePage(currentPage - 1)} + disabled={currentPage <= 1} + class="rounded-lg px-2.5 py-1.5 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-30" + style="color: var(--text-muted); {currentPage <= 1 ? '' : 'hover:bg-[var(--border-color)] hover:text-white'}" + > + Anterior + </button> + + {#each pages as p} + {#if p === '...'} + <span class="px-1 text-xs text-[var(--text-muted)]">…</span> + {:else} + <button + onclick={() => loadTablePage(p as number)} + class="min-w-[28px] rounded-lg px-2 py-1.5 text-xs font-medium transition-all" + style={p === currentPage + ? 'background-color: var(--green); color: #000; font-weight: 700;' + : 'color: var(--text-muted); hover:background-color: var(--border-color); hover:color: white;'} + > + {p} + </button> + {/if} + {/each} + + <button + onclick={() => loadTablePage(currentPage + 1)} + disabled={currentPage >= totalPages} + class="rounded-lg px-2.5 py-1.5 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-30" + style="color: var(--text-muted); {currentPage >= totalPages ? '' : 'hover:bg-[var(--border-color)] hover:text-white'}" + > + Próximo + </button> + </div> + + <p class="mt-2 text-center text-[10px] text-[var(--text-muted)]"> + Página {currentPage} de {totalPages} — {totalItems} verificações no total + </p> + {/if} </div> </div> {/if} diff --git a/frontend/src/routes/status/[id]/+page.svelte b/frontend/src/routes/status/[id]/+page.svelte index afc59a2..0701eeb 100644 --- a/frontend/src/routes/status/[id]/+page.svelte +++ b/frontend/src/routes/status/[id]/+page.svelte @@ -187,7 +187,7 @@ </span> </div> {:else} - <svg class="h-full w-full" viewBox="0 0 {chartW} {chartH}" preserveAspectRatio="none"> + <svg class="h-full w-full"> {#each yTicks as tick} <line x1={chartPad.l} y1={tick.y} x2={chartW - chartPad.r} y2={tick.y} diff --git a/frontend/src/routes/status/[id]/+page.ts b/frontend/src/routes/status/[id]/+page.ts index 4351aae..1b91c67 100644 --- a/frontend/src/routes/status/[id]/+page.ts +++ b/frontend/src/routes/status/[id]/+page.ts @@ -11,7 +11,8 @@ export const load: PageLoad = async ({ params, fetch }) => { 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 historyJson = historyRes?.ok ? await historyRes.json() : []; + const history = Array.isArray(historyJson) ? historyJson : (historyJson.data ?? []); const stats = statsRes?.ok ? await statsRes.json() : null; return { |