diff options
Diffstat (limited to 'frontend/src/routes/(admin)/admin')
| -rw-r--r-- | frontend/src/routes/(admin)/admin/+page.server.js | 27 | ||||
| -rw-r--r-- | frontend/src/routes/(admin)/admin/+page.svelte | 432 |
2 files changed, 459 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> |