From d186a989b6808ff39fcd43a9f0b478aebc4aa346 Mon Sep 17 00:00:00 2001
From: zwlucas <lucas.fariamo08@gmail.com>
Date: Fri, 29 May 2026 17:21:48 -0300
Subject: 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>
---
 frontend/src/routes/(admin)/admin/+page.svelte | 309 +++++++++++++++++++++++--
 1 file changed, 286 insertions(+), 23 deletions(-)

(limited to 'frontend/src/routes/(admin)/admin/+page.svelte')

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); }
-- 
cgit v1.2.3

