aboutsummaryrefslogtreecommitdiff
path: root/frontend/src/lib
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src/lib')
-rw-r--r--frontend/src/lib/api.ts39
-rw-r--r--frontend/src/lib/components/ActivityLog.svelte122
-rw-r--r--frontend/src/lib/components/AddServiceModal.svelte224
-rw-r--r--frontend/src/lib/components/ServiceCard.svelte90
-rw-r--r--frontend/src/lib/components/UptimeTimeline.svelte41
-rw-r--r--frontend/src/lib/realtime.ts42
-rw-r--r--frontend/src/lib/types.ts44
7 files changed, 602 insertions, 0 deletions
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
new file mode 100644
index 0000000..0be9148
--- /dev/null
+++ b/frontend/src/lib/api.ts
@@ -0,0 +1,39 @@
+import type { Service, Heartbeat, ServiceStats } from './types';
+
+const BASE = '/api';
+
+export async function listServices(): Promise<Service[]> {
+ const res = await fetch(`${BASE}/services`);
+ if (!res.ok) throw new Error('falha ao listar serviços');
+ return res.json();
+}
+
+export async function createService(data: { name: string; url: string }): Promise<Service> {
+ const res = await fetch(`${BASE}/services`, {
+ 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 serviço');
+ }
+ return res.json();
+}
+
+export async function deleteService(id: number): Promise<void> {
+ const res = await fetch(`${BASE}/services/${id}`, { method: 'DELETE' });
+ 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`);
+ if (!res.ok) throw new Error('falha ao obter histórico');
+ return res.json();
+}
+
+export async function getServiceStats(id: number): Promise<ServiceStats> {
+ const res = await fetch(`${BASE}/services/${id}/stats`);
+ if (!res.ok) throw new Error('falha ao obter estatísticas');
+ return res.json();
+}
diff --git a/frontend/src/lib/components/ActivityLog.svelte b/frontend/src/lib/components/ActivityLog.svelte
new file mode 100644
index 0000000..4d7ff79
--- /dev/null
+++ b/frontend/src/lib/components/ActivityLog.svelte
@@ -0,0 +1,122 @@
+<script lang="ts">
+ import type { Heartbeat } from '$lib/types';
+
+ interface LogEntry {
+ kind: 'check' | 'created' | 'deleted' | 'transition';
+ serviceName: string;
+ serviceUrl: string;
+ timestamp: Date;
+ hb?: Heartbeat;
+ wasUp?: boolean;
+ }
+
+ let {
+ entries = []
+ }: {
+ entries: LogEntry[];
+ } = $props();
+
+ let el: HTMLDivElement;
+ let autoScroll = $state(true);
+
+ function handleScroll() {
+ if (!el) return;
+ const dist = el.scrollHeight - el.scrollTop - el.clientHeight;
+ autoScroll = dist < 40;
+ }
+
+ $effect(() => {
+ if (entries.length && autoScroll && el) {
+ el.scrollTop = el.scrollHeight;
+ }
+ });
+
+ function timeAgo(date: Date): string {
+ const sec = Math.floor((Date.now() - date.getTime()) / 1000);
+ if (sec < 10) return 'agora';
+ if (sec < 60) return `${sec}s`;
+ const min = Math.floor(sec / 60);
+ if (min === 1) return '1min';
+ if (min < 60) return `${min}min`;
+ return date.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
+ }
+</script>
+
+<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg-card)]">
+ <div class="flex items-center justify-between border-b border-[var(--border-color)] px-5 py-3">
+ <h2 class="text-xs font-semibold uppercase tracking-wider text-[var(--text-secondary)]">
+ Atividade Recente
+ </h2>
+ <span class="text-[10px] tabular-nums text-[var(--text-muted)]">{entries.length} eventos</span>
+ </div>
+ <div
+ bind:this={el}
+ onscroll={handleScroll}
+ class="scrollbar-thin space-y-0.5 overflow-y-auto px-3 py-2"
+ style="max-height: 320px"
+ >
+ {#if entries.length === 0}
+ <p class="py-8 text-center text-xs text-[var(--text-muted)]">Nenhuma atividade ainda</p>
+ {:else}
+ {#each entries as entry, i (i)}
+ <div
+ class="flex items-start gap-3 rounded-xl px-3 py-2.5 transition-colors hover:bg-[var(--border-color)]/30"
+ >
+ <div class="mt-0.5 shrink-0">
+ {#if entry.kind === 'created'}
+ <span class="flex h-5 w-5 items-center justify-center rounded-md bg-[var(--green)]/10 text-[10px] text-[var(--green)]">
+ <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
+ </svg>
+ </span>
+ {:else if entry.kind === 'deleted'}
+ <span class="flex h-5 w-5 items-center justify-center rounded-md bg-[var(--red)]/10 text-[10px] text-[var(--red)]">
+ <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
+ </svg>
+ </span>
+ {:else if entry.kind === 'transition'}
+ <span
+ class="flex h-5 w-5 items-center justify-center rounded-md text-[10px]"
+ style="background-color: {entry.hb?.is_up ? 'rgba(34,240,106,0.1)' : 'rgba(255,64,96,0.1)'}; color: {entry.hb?.is_up ? 'var(--green)' : 'var(--red)'}"
+ >
+ {entry.hb?.is_up ? '↑' : '↓'}
+ </span>
+ {:else}
+ <span
+ class="flex h-5 w-5 items-center justify-center rounded-md text-[10px]"
+ style="background-color: {entry.hb?.is_up ? 'rgba(34,240,106,0.1)' : 'rgba(255,64,96,0.1)'}; color: {entry.hb?.is_up ? 'var(--green)' : 'var(--red)'}"
+ >
+ {entry.hb?.is_up ? '✓' : '✗'}
+ </span>
+ {/if}
+ </div>
+ <div class="min-w-0 flex-1">
+ <div class="flex items-baseline gap-2">
+ <span class="truncate text-xs font-medium text-white">{entry.serviceName}</span>
+ <span class="shrink-0 text-[10px] text-[var(--text-muted)]">{timeAgo(entry.timestamp)}</span>
+ </div>
+ <p class="truncate text-[10px] text-[var(--text-muted)]">
+ {#if entry.kind === 'created'}
+ Serviço adicionado
+ {:else if entry.kind === 'deleted'}
+ Serviço removido
+ {:else if entry.kind === 'transition'}
+ {entry.hb?.is_up ? 'Recuperado' : 'Falha'} — {entry.hb?.response_time_ms ?? '?'}ms
+ {:else}
+ {entry.hb?.is_up ? 'Online' : 'Offline'} — {entry.hb?.status_code} / {entry.hb?.response_time_ms}ms
+ {/if}
+ </p>
+ </div>
+ </div>
+ {/each}
+ {/if}
+ </div>
+</div>
+
+<style>
+ .scrollbar-thin {
+ scrollbar-width: thin;
+ scrollbar-color: var(--border-color) transparent;
+ }
+</style>
diff --git a/frontend/src/lib/components/AddServiceModal.svelte b/frontend/src/lib/components/AddServiceModal.svelte
new file mode 100644
index 0000000..de1ba13
--- /dev/null
+++ b/frontend/src/lib/components/AddServiceModal.svelte
@@ -0,0 +1,224 @@
+<script lang="ts">
+ import type { Service } from '$lib/types';
+
+ let {
+ show = false,
+ onclose,
+ oncreated
+ }: {
+ show: boolean;
+ onclose: () => void;
+ oncreated: (svc: Service) => void;
+ } = $props();
+
+ let name = $state('');
+ let url = $state('');
+ let discordWebhookUrl = $state('');
+ let alertEmail = $state('');
+ let keywordToFind = $state('');
+ let error = $state('');
+ let saving = $state(false);
+
+ async function handleSubmit(e: Event) {
+ e.preventDefault();
+ error = '';
+
+ if (!name.trim() || !url.trim()) {
+ error = 'Preencha nome e URL';
+ return;
+ }
+ try {
+ new URL(url);
+ } catch {
+ error = 'URL inválida — deve começar com http:// ou https://';
+ return;
+ }
+
+ saving = true;
+ try {
+ const res = await fetch('/api/services', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ name: name.trim(),
+ url: url.trim(),
+ discord_webhook_url: discordWebhookUrl.trim() || undefined,
+ alert_email: alertEmail.trim() || undefined,
+ keyword_to_find: keywordToFind.trim() || undefined
+ })
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ throw new Error(err.error || 'Erro ao criar serviço');
+ }
+ const svc: Service = await res.json();
+ oncreated(svc);
+ name = '';
+ url = '';
+ discordWebhookUrl = '';
+ alertEmail = '';
+ keywordToFind = '';
+ onclose();
+ } catch (e: any) {
+ error = e.message;
+ } finally {
+ saving = false;
+ }
+ }
+
+ function handleBackdrop(e: MouseEvent) {
+ if (e.target === e.currentTarget) onclose();
+ }
+
+ function handleKeydown(e: KeyboardEvent) {
+ if (e.key === 'Escape') onclose();
+ }
+</script>
+
+<svelte:window onkeydown={handleKeydown} />
+
+{#if show}
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
+ <div
+ role="button"
+ tabindex="-1"
+ aria-label="Fechar modal"
+ class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
+ onclick={handleBackdrop}
+ >
+ <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"
+ >
+ <div class="mb-5 flex items-center justify-between">
+ <h2 class="text-sm font-semibold uppercase tracking-wider text-[var(--text-secondary)]">
+ Novo Serviço
+ </h2>
+ <button
+ onclick={onclose}
+ 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">
+ <div>
+ <label for="modal-name" class="mb-1.5 block text-xs font-medium text-[var(--text-secondary)]">
+ Nome do Serviço
+ </label>
+ <input
+ id="modal-name"
+ bind:value={name}
+ type="text"
+ 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="modal-url" class="mb-1.5 block text-xs font-medium text-[var(--text-secondary)]">
+ URL
+ </label>
+ <input
+ id="modal-url"
+ bind:value={url}
+ type="url"
+ 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 class="border-t border-[var(--border-color)] pt-4">
+ <p class="mb-3 text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)]">
+ Alertas (opcional)
+ </p>
+ <div>
+ <label
+ for="modal-discord"
+ class="mb-1.5 block text-xs font-medium text-[var(--text-secondary)]"
+ >
+ Webhook do Discord
+ </label>
+ <input
+ id="modal-discord"
+ bind:value={discordWebhookUrl}
+ type="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="modal-email"
+ class="mb-1.5 block text-xs font-medium text-[var(--text-secondary)]"
+ >
+ E-mail de Alerta
+ </label>
+ <input
+ id="modal-email"
+ bind:value={alertEmail}
+ type="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 class="mt-3">
+ <label
+ for="modal-keyword"
+ class="mb-1.5 block text-xs font-medium text-[var(--text-secondary)]"
+ >
+ Verificar Palavra-chave na Página (opcional)
+ </label>
+ <input
+ id="modal-keyword"
+ bind:value={keywordToFind}
+ type="text"
+ placeholder="Bem-vindo, Login, OK..."
+ 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"
+ />
+ <p class="mt-1 text-[9px] text-[var(--text-muted)]">
+ Se informado, o monitor vai ler o HTML e marcar como DOWN se não encontrar essa palavra.
+ </p>
+ </div>
+ </div>
+
+ {#if error}
+ <p class="rounded-lg bg-[var(--red)]/5 px-3 py-2 text-xs text-[var(--red)]">{error}</p>
+ {/if}
+
+ <div class="flex gap-3 pt-1">
+ <button
+ type="button"
+ onclick={onclose}
+ 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…' : '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>
diff --git a/frontend/src/lib/components/ServiceCard.svelte b/frontend/src/lib/components/ServiceCard.svelte
new file mode 100644
index 0000000..29305f2
--- /dev/null
+++ b/frontend/src/lib/components/ServiceCard.svelte
@@ -0,0 +1,90 @@
+<script lang="ts">
+ import UptimeTimeline from './UptimeTimeline.svelte';
+ import type { Service, Heartbeat } from '$lib/types';
+
+ let {
+ service,
+ heartbeats = [],
+ ondeleted
+ }: {
+ service: Service;
+ heartbeats: Heartbeat[];
+ ondeleted: (id: number) => void;
+ } = $props();
+
+ let isUp = $derived(service?.last_heartbeat?.is_up ?? true);
+ let lastMs = $derived(service?.last_heartbeat?.response_time_ms ?? null);
+ let statusColor = $derived(isUp ? 'var(--green)' : 'var(--red)');
+ let bgClass = $derived(isUp ? 'bg-emerald-500/10 text-emerald-500' : 'bg-rose-500/10 text-rose-500');
+ let dotGlow = $derived(isUp ? '0 0 6px var(--green)' : '0 0 6px var(--red)');
+
+ let uptime30d = $derived.by(() => {
+ const now = Date.now();
+ const cutoff30d = now - 30 * 24 * 60 * 60 * 1000;
+ const recent = heartbeats.filter((h) => new Date(h.tested_at).getTime() >= cutoff30d);
+ if (recent.length === 0) return null;
+ return (recent.filter((h) => h.is_up).length / recent.length) * 100;
+ });
+
+ let uptimeColor = $derived(uptime30d !== null && uptime30d < 99 ? 'var(--red)' : 'var(--green)');
+</script>
+
+{#if service}
+<div
+ class="group relative overflow-hidden rounded-2xl border border-[var(--border-color)] bg-[var(--bg-card)] p-5 transition-all duration-300 hover:border-[var(--border-color)]/60 hover:shadow-2xl hover:shadow-black/30 hover:-translate-y-0.5"
+>
+ <div
+ class="pointer-events-none absolute -inset-px rounded-2xl opacity-0 transition-opacity duration-500 group-hover:opacity-100"
+ style="background: radial-gradient(600px circle at 50% 50%, {statusColor}06 0%, transparent 60%)"
+ ></div>
+
+ <div class="relative z-10">
+ <div class="mb-3 flex items-start justify-between gap-2">
+ <div class="min-w-0 flex-1">
+ <h3 class="truncate text-sm font-semibold text-white">{service.name}</h3>
+ <p class="mt-0.5 truncate font-mono text-xs text-[var(--text-muted)]">{service.url}</p>
+ </div>
+ <button
+ onclick={() => ondeleted(service.id)}
+ class="shrink-0 rounded-lg p-1.5 text-[var(--text-muted)] opacity-0 transition-all duration-200 hover:bg-rose-500/10 hover:text-rose-500 group-hover:opacity-100"
+ title="Remover serviço"
+ >
+ <svg class="h-3.5 w-3.5" 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>
+
+ <div class="mb-4 flex items-center gap-3">
+ <span class="inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 text-xs font-semibold tracking-wide {bgClass}">
+ <span class="h-1.5 w-1.5 rounded-full" style="background-color: {statusColor}; box-shadow: {dotGlow}"></span>
+ {isUp ? 'UP' : 'DOWN'}
+ </span>
+
+ {#if lastMs !== null}
+ <span class="font-mono text-xs tabular-nums text-[var(--text-muted)]">
+ {lastMs}<span class="text-[var(--text-secondary)]">ms</span>
+ </span>
+ {/if}
+
+ {#if uptime30d !== null}
+ <span
+ class="ml-auto font-mono text-xs tabular-nums"
+ style="color: {uptimeColor}"
+ >
+ {uptime30d.toFixed(2)}%
+ </span>
+ {/if}
+ </div>
+
+ <UptimeTimeline {heartbeats} />
+
+ <a
+ href="/status/{service.id}"
+ class="mt-3 block text-center text-[10px] font-medium uppercase tracking-widest text-[var(--text-muted)] transition-colors hover:text-[var(--text-secondary)]"
+ >
+ Detalhes →
+ </a>
+ </div>
+</div>
+{/if}
diff --git a/frontend/src/lib/components/UptimeTimeline.svelte b/frontend/src/lib/components/UptimeTimeline.svelte
new file mode 100644
index 0000000..6cb47df
--- /dev/null
+++ b/frontend/src/lib/components/UptimeTimeline.svelte
@@ -0,0 +1,41 @@
+<script lang="ts">
+ import type { Heartbeat } from '$lib/types';
+
+ let {
+ heartbeats = []
+ }: {
+ heartbeats: Heartbeat[];
+ } = $props();
+
+ let blocks = $derived.by(() => {
+ const all = heartbeats.slice(-30);
+ const filled = all.map((h) => h.is_up);
+ while (filled.length < 30) {
+ filled.unshift(null);
+ }
+ return filled;
+ });
+
+ function blockColor(v: boolean | null): string {
+ if (v === true) return 'var(--green)';
+ if (v === false) return 'var(--red)';
+ return 'var(--border-color)';
+ }
+</script>
+
+<div class="space-y-1.5">
+ <div class="flex gap-[3px]">
+ {#each blocks as block, i}
+ <div
+ class="h-4 flex-1 rounded-sm transition-all duration-300"
+ style="background-color: {blockColor(block)}; opacity: {block !== null ? 0.8 : 1}"
+ title={block === true ? 'UP' : block === false ? 'DOWN' : 'Sem dados'}
+ ></div>
+ {/each}
+ </div>
+ <div class="flex justify-between text-[10px] font-medium text-[var(--text-muted)]">
+ <span>{heartbeats.length > 0 ? '-30 min' : ''}</span>
+ <span class="tabular-nums">{heartbeats.length} checks</span>
+ <span>agora</span>
+ </div>
+</div>
diff --git a/frontend/src/lib/realtime.ts b/frontend/src/lib/realtime.ts
new file mode 100644
index 0000000..fb9c75c
--- /dev/null
+++ b/frontend/src/lib/realtime.ts
@@ -0,0 +1,42 @@
+import type { Heartbeat } from '$lib/types';
+
+type Listener = (hb: Heartbeat) => void;
+
+const listeners = new Set<Listener>();
+let es: EventSource | null = null;
+
+function connect() {
+ if (es) return;
+
+ es = new EventSource('/api/stream');
+
+ es.addEventListener('connected', () => {
+ console.log('[sse] conectado');
+ });
+
+ es.addEventListener('heartbeat', (event: MessageEvent) => {
+ try {
+ const hb: Heartbeat = JSON.parse(event.data);
+ listeners.forEach((fn) => fn(hb));
+ } catch {
+ // ignore parse errors
+ }
+ });
+
+ es.onerror = () => {
+ console.warn('[sse] erro — reconectando...');
+ };
+}
+
+export function subscribeHeartbeats(fn: Listener) {
+ listeners.add(fn);
+ connect();
+
+ return () => {
+ listeners.delete(fn);
+ if (listeners.size === 0 && es) {
+ es.close();
+ es = null;
+ }
+ };
+}
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
new file mode 100644
index 0000000..f80c933
--- /dev/null
+++ b/frontend/src/lib/types.ts
@@ -0,0 +1,44 @@
+export interface Service {
+ id: number;
+ name: string;
+ url: string;
+ group_name: string;
+ interval_seconds: number;
+ is_active: boolean;
+ discord_webhook_url?: string;
+ alert_email?: string;
+ keyword_to_find?: string;
+ created_at: string;
+ last_heartbeat?: Heartbeat | null;
+}
+
+export interface Maintenance {
+ id: number;
+ title: string;
+ start_time: string;
+ end_time: string;
+ is_active: boolean;
+}
+
+export interface Heartbeat {
+ id: number;
+ service_id: number;
+ status_code: number;
+ response_time_ms: number;
+ is_up: boolean;
+ error_message?: string;
+ tested_at: string;
+}
+
+export interface ServiceStats {
+ service_id: number;
+ uptime_24h: number;
+ uptime_7d: number;
+ uptime_30d: number;
+ avg_response_ms_24h: number;
+ avg_response_ms_7d: number;
+ avg_response_ms_30d: number;
+ total_checks_24h: number;
+ total_checks_7d: number;
+ total_checks_30d: number;
+}