aboutsummaryrefslogtreecommitdiff
path: root/frontend/src/routes/(admin)/admin
diff options
context:
space:
mode:
authorzwlucas <lucas.fariamo08@gmail.com>2026-05-29 20:21:48 +0000
committerzwlucas <lucas.fariamo08@gmail.com>2026-05-29 20:21:48 +0000
commitd186a989b6808ff39fcd43a9f0b478aebc4aa346 (patch)
treeabf32684ea56e6066d8584e0378227ffc397ace2 /frontend/src/routes/(admin)/admin
parent6cc0bfd1d79074df790272b8091b1f0226d14283 (diff)
downloadyaum-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/routes/(admin)/admin')
-rw-r--r--frontend/src/routes/(admin)/admin/+page.svelte309
1 files changed, 286 insertions, 23 deletions
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); }