aboutsummaryrefslogtreecommitdiff
path: root/frontend/src/routes/(admin)
diff options
context:
space:
mode:
authorzwlucas <lucas.fariamo08@gmail.com>2026-05-29 18:57:37 +0000
committerzwlucas <lucas.fariamo08@gmail.com>2026-05-29 18:57:37 +0000
commit6cc0bfd1d79074df790272b8091b1f0226d14283 (patch)
treecab18b0f708cf3b0eaeeeb98e2cf81459365b33e /frontend/src/routes/(admin)
downloadyaum-6cc0bfd1d79074df790272b8091b1f0226d14283.tar.gz
yaum-6cc0bfd1d79074df790272b8091b1f0226d14283.zip
feat: upload project
Signed-off-by: zwlucas <lucas.fariamo08@gmail.com>
Diffstat (limited to 'frontend/src/routes/(admin)')
-rw-r--r--frontend/src/routes/(admin)/admin/+page.server.js27
-rw-r--r--frontend/src/routes/(admin)/admin/+page.svelte432
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>