aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--backend/internal/alerts/alerts.go2
-rw-r--r--backend/internal/api/handler.go91
-rw-r--r--backend/internal/api/router.go5
-rw-r--r--backend/internal/monitor/worker.go59
-rw-r--r--backend/internal/store/memory.go81
-rw-r--r--backend/internal/store/postgres.go75
-rw-r--r--backend/internal/store/store.go7
-rw-r--r--frontend/src/lib/api.ts43
-rw-r--r--frontend/src/lib/types.ts8
-rw-r--r--frontend/src/routes/(admin)/admin/+page.svelte309
-rw-r--r--frontend/src/routes/+page.svelte10
-rw-r--r--frontend/src/routes/+page.ts44
-rw-r--r--frontend/src/routes/services/[id]/+page.svelte287
-rw-r--r--frontend/src/routes/status/[id]/+page.svelte2
-rw-r--r--frontend/src/routes/status/[id]/+page.ts3
15 files changed, 907 insertions, 119 deletions
diff --git a/backend/internal/alerts/alerts.go b/backend/internal/alerts/alerts.go
index 44d4a50..ca5e807 100644
--- a/backend/internal/alerts/alerts.go
+++ b/backend/internal/alerts/alerts.go
@@ -37,7 +37,7 @@ func SendDiscord(webhookURL string, svc model.Service, hb model.Heartbeat, wasUp
fields = append(fields, map[string]any{"name": "Erro", "value": hb.ErrorMessage, "inline": false})
}
if hb.IsUp {
- desc = "o servico voltou"
+ desc = "O servico voltou"
}
payload := map[string]any{
diff --git a/backend/internal/api/handler.go b/backend/internal/api/handler.go
index f304351..5f934da 100644
--- a/backend/internal/api/handler.go
+++ b/backend/internal/api/handler.go
@@ -142,12 +142,40 @@ func (h *Handler) GetHistory(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id invalido"})
return
}
- history, err := h.store.GetHistory(id, 50)
+
+ page, _ := strconv.Atoi(r.URL.Query().Get("page"))
+ if page < 1 {
+ page = 1
+ }
+ perPage, _ := strconv.Atoi(r.URL.Query().Get("per_page"))
+ if perPage < 1 || perPage > 100 {
+ perPage = 50
+ }
+
+ offset := (page - 1) * perPage
+ history, err := h.store.GetHistory(id, perPage, offset)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
- writeJSON(w, http.StatusOK, history)
+
+ total, err := h.store.CountHeartbeats(id)
+ if err != nil {
+ total = len(history)
+ }
+
+ totalPages := (total + perPage - 1) / perPage
+ if totalPages < 1 {
+ totalPages = 1
+ }
+
+ writeJSON(w, http.StatusOK, map[string]any{
+ "data": history,
+ "page": page,
+ "per_page": perPage,
+ "total": total,
+ "total_pages": totalPages,
+ })
}
func (h *Handler) GetActiveMaintenance(w http.ResponseWriter, r *http.Request) {
@@ -163,6 +191,65 @@ func (h *Handler) GetActiveMaintenance(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"maintenance": m})
}
+func (h *Handler) ListMaintenances(w http.ResponseWriter, r *http.Request) {
+ maintenances, err := h.store.ListMaintenances()
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ writeJSON(w, http.StatusOK, maintenances)
+}
+
+func (h *Handler) CreateMaintenance(w http.ResponseWriter, r *http.Request) {
+ var m model.Maintenance
+ if err := json.NewDecoder(r.Body).Decode(&m); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "json invalido: " + err.Error()})
+ return
+ }
+ if m.Title == "" {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "titulo obrigatorio"})
+ return
+ }
+ created, err := h.store.AddMaintenance(m)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ writeJSON(w, http.StatusCreated, created)
+}
+
+func (h *Handler) UpdateMaintenance(w http.ResponseWriter, r *http.Request) {
+ id, err := strconv.Atoi(r.PathValue("id"))
+ if err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id invalido"})
+ return
+ }
+ var m model.Maintenance
+ if err := json.NewDecoder(r.Body).Decode(&m); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "json invalido: " + err.Error()})
+ return
+ }
+ updated, err := h.store.UpdateMaintenance(id, m)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ writeJSON(w, http.StatusOK, updated)
+}
+
+func (h *Handler) DeleteMaintenance(w http.ResponseWriter, r *http.Request) {
+ id, err := strconv.Atoi(r.PathValue("id"))
+ if err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id invalido"})
+ return
+ }
+ if err := h.store.DeleteMaintenance(id); err != nil {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
func (h *Handler) BadgeSVG(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go
index 9a18cc5..8188b25 100644
--- a/backend/internal/api/router.go
+++ b/backend/internal/api/router.go
@@ -23,6 +23,11 @@ func NewRouter(h *Handler, stream *StreamHandler, auth *AuthHandler) http.Handle
mux.HandleFunc("GET /api/active-maintenance", h.GetActiveMaintenance)
+ mux.HandleFunc("GET /api/maintenances", h.ListMaintenances)
+ mux.HandleFunc("POST /api/maintenances", h.CreateMaintenance)
+ mux.HandleFunc("PUT /api/maintenances/{id}", h.UpdateMaintenance)
+ mux.HandleFunc("DELETE /api/maintenances/{id}", h.DeleteMaintenance)
+
mux.Handle("GET /api/stream", stream)
return corsMiddleware(auth.Middleware(mux))
diff --git a/backend/internal/monitor/worker.go b/backend/internal/monitor/worker.go
index 0cf980a..70855a2 100644
--- a/backend/internal/monitor/worker.go
+++ b/backend/internal/monitor/worker.go
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"log"
+ "sync"
"time"
"yaum/internal/alerts"
@@ -25,6 +26,8 @@ type Worker struct {
broadcaster *sse.Broadcaster
smtpCfg *alerts.SMTPConfig
quit chan struct{}
+ mu sync.Mutex
+ lastChecked map[int]time.Time
}
func NewWorker(s store.Store, interval, timeout time.Duration, b *sse.Broadcaster) *Worker {
@@ -35,6 +38,7 @@ func NewWorker(s store.Store, interval, timeout time.Duration, b *sse.Broadcaste
results: make(chan checkResult, 100),
broadcaster: b,
quit: make(chan struct{}),
+ lastChecked: make(map[int]time.Time),
}
}
@@ -44,10 +48,14 @@ func (w *Worker) WithSMTP(cfg *alerts.SMTPConfig) *Worker {
}
func (w *Worker) Start(ctx context.Context) {
- ticker := time.NewTicker(w.interval)
+ granularity := w.interval
+ if granularity > 10*time.Second {
+ granularity = 10 * time.Second
+ }
+ ticker := time.NewTicker(granularity)
defer ticker.Stop()
- log.Printf("[worker] iniciado | intervalo=%v timeout=%v", w.interval, w.timeout)
+ log.Printf("[worker] iniciado | global_interval=%v granularity=%v timeout=%v", w.interval, granularity, w.timeout)
go w.resultConsumer()
@@ -64,6 +72,40 @@ func (w *Worker) Start(ctx context.Context) {
}
}
+func (w *Worker) runChecks(ctx context.Context) {
+ services, err := w.store.ListActiveServices()
+ if err != nil {
+ log.Printf("[worker] erro ao listar servicos ativos: %v", err)
+ return
+ }
+
+ now := time.Now()
+ var due []model.Service
+
+ w.mu.Lock()
+ for _, svc := range services {
+ svcInterval := time.Duration(svc.IntervalSeconds) * time.Second
+ if svcInterval <= 0 {
+ svcInterval = w.interval
+ }
+ last, ok := w.lastChecked[svc.ID]
+ if !ok || now.Sub(last) >= svcInterval {
+ w.lastChecked[svc.ID] = now
+ due = append(due, svc)
+ }
+ }
+ w.mu.Unlock()
+
+ if len(due) == 0 {
+ return
+ }
+ log.Printf("[worker] verificando %d servico(s) de %d ativo(s)", len(due), len(services))
+
+ for _, svc := range due {
+ go w.checkService(ctx, svc)
+ }
+}
+
func (w *Worker) resultConsumer() {
for {
select {
@@ -120,19 +162,6 @@ func (w *Worker) fireAlerts(svc model.Service, hb model.Heartbeat, wasUp bool) {
}
}
-func (w *Worker) runChecks(ctx context.Context) {
- services, err := w.store.ListActiveServices()
- if err != nil {
- log.Printf("[worker] erro ao listar servicos ativos: %v", err)
- return
- }
- log.Printf("[worker] verificando %d servico(s)", len(services))
-
- for _, svc := range services {
- go w.checkService(ctx, svc)
- }
-}
-
func (w *Worker) checkService(ctx context.Context, svc model.Service) {
checkCtx, cancel := context.WithTimeout(ctx, w.timeout)
defer cancel()
diff --git a/backend/internal/store/memory.go b/backend/internal/store/memory.go
index a77e877..1ee2e05 100644
--- a/backend/internal/store/memory.go
+++ b/backend/internal/store/memory.go
@@ -49,6 +49,17 @@ func (s *MemoryStore) SeedMockData() {
s.heartbeats[svc.ID] = make([]*model.Heartbeat, 0)
s.seedHistory(svc.ID)
}
+
+ s.maintenances = []model.Maintenance{
+ {
+ ID: s.nextMtnID,
+ Title: "Manutenção Programada — Servidores",
+ StartTime: now.Add(-1 * time.Hour),
+ EndTime: now.Add(1 * time.Hour),
+ IsActive: true,
+ },
+ }
+ s.nextMtnID++
}
func (s *MemoryStore) seedHistory(serviceID int) {
@@ -187,21 +198,35 @@ func (s *MemoryStore) AddHeartbeat(hb model.Heartbeat) error {
return nil
}
-func (s *MemoryStore) GetHistory(serviceID int, limit int) ([]model.Heartbeat, error) {
+func (s *MemoryStore) GetHistory(serviceID int, limit int, offset int) ([]model.Heartbeat, error) {
s.mu.RLock()
defer s.mu.RUnlock()
hbs := s.heartbeats[serviceID]
- if len(hbs) > limit {
- hbs = hbs[len(hbs)-limit:]
- }
- result := make([]model.Heartbeat, len(hbs))
+ reverse := make([]*model.Heartbeat, len(hbs))
for i, hb := range hbs {
+ reverse[len(hbs)-1-i] = hb
+ }
+ if offset >= len(reverse) {
+ return []model.Heartbeat{}, nil
+ }
+ if offset+limit > len(reverse) {
+ limit = len(reverse) - offset
+ }
+ page := reverse[offset : offset+limit]
+ result := make([]model.Heartbeat, len(page))
+ for i, hb := range page {
result[i] = *hb
}
return result, nil
}
+func (s *MemoryStore) CountHeartbeats(serviceID int) (int, error) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return len(s.heartbeats[serviceID]), nil
+}
+
func (s *MemoryStore) GetLastHeartbeat(serviceID int) (*model.Heartbeat, error) {
s.mu.RLock()
defer s.mu.RUnlock()
@@ -225,6 +250,52 @@ func (s *MemoryStore) GetActiveMaintenance(now time.Time) (*model.Maintenance, e
return nil, nil
}
+func (s *MemoryStore) ListMaintenances() ([]model.Maintenance, error) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ result := make([]model.Maintenance, len(s.maintenances))
+ copy(result, s.maintenances)
+ return result, nil
+}
+
+func (s *MemoryStore) AddMaintenance(m model.Maintenance) (*model.Maintenance, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ m.ID = s.nextMtnID
+ s.nextMtnID++
+ s.maintenances = append(s.maintenances, m)
+ return &m, nil
+}
+
+func (s *MemoryStore) UpdateMaintenance(id int, m model.Maintenance) (*model.Maintenance, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ for i, existing := range s.maintenances {
+ if existing.ID == id {
+ m.ID = id
+ s.maintenances[i] = m
+ return &m, nil
+ }
+ }
+ return nil, errors.New("manutencao nao encontrada")
+}
+
+func (s *MemoryStore) DeleteMaintenance(id int) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ for i, m := range s.maintenances {
+ if m.ID == id {
+ s.maintenances = append(s.maintenances[:i], s.maintenances[i+1:]...)
+ return nil
+ }
+ }
+ return errors.New("manutencao nao encontrada")
+}
+
func (s *MemoryStore) GetServiceStats(serviceID int) (*model.ServiceStats, error) {
s.mu.RLock()
defer s.mu.RUnlock()
diff --git a/backend/internal/store/postgres.go b/backend/internal/store/postgres.go
index a8d072a..57fa258 100644
--- a/backend/internal/store/postgres.go
+++ b/backend/internal/store/postgres.go
@@ -213,15 +213,16 @@ func (s *PostgresStore) AddHeartbeat(hb model.Heartbeat) error {
return nil
}
-func (s *PostgresStore) GetHistory(serviceID int, limit int) ([]model.Heartbeat, error) {
+func (s *PostgresStore) GetHistory(serviceID int, limit int, offset int) ([]model.Heartbeat, error) {
query := `
SELECT id, service_id, status_code, response_time_ms, is_up, error_message, tested_at
FROM heartbeats
WHERE service_id = $1
ORDER BY tested_at DESC
LIMIT $2
+ OFFSET $3
`
- rows, err := s.pool.Query(context.Background(), query, serviceID, limit)
+ rows, err := s.pool.Query(context.Background(), query, serviceID, limit, offset)
if err != nil {
return nil, fmt.Errorf("get history: %w", err)
}
@@ -234,6 +235,18 @@ func (s *PostgresStore) GetHistory(serviceID int, limit int) ([]model.Heartbeat,
})
}
+func (s *PostgresStore) CountHeartbeats(serviceID int) (int, error) {
+ var count int
+ err := s.pool.QueryRow(context.Background(),
+ `SELECT COUNT(*) FROM heartbeats WHERE service_id = $1`,
+ serviceID,
+ ).Scan(&count)
+ if err != nil {
+ return 0, fmt.Errorf("count heartbeats: %w", err)
+ }
+ return count, nil
+}
+
func (s *PostgresStore) GetLastHeartbeat(serviceID int) (*model.Heartbeat, error) {
query := `
SELECT id, service_id, status_code, response_time_ms, is_up, error_message, tested_at
@@ -322,3 +335,61 @@ func (s *PostgresStore) GetActiveMaintenance(now time.Time) (*model.Maintenance,
}
return &m, nil
}
+
+func (s *PostgresStore) ListMaintenances() ([]model.Maintenance, error) {
+ query := `SELECT id, title, start_time, end_time, is_active FROM maintenances ORDER BY start_time DESC`
+ rows, err := s.pool.Query(context.Background(), query)
+ if err != nil {
+ return nil, fmt.Errorf("list maintenances: %w", err)
+ }
+ defer rows.Close()
+ return pgx.CollectRows(rows, func(row pgx.CollectableRow) (model.Maintenance, error) {
+ var m model.Maintenance
+ err := row.Scan(&m.ID, &m.Title, &m.StartTime, &m.EndTime, &m.IsActive)
+ return m, err
+ })
+}
+
+func (s *PostgresStore) AddMaintenance(m model.Maintenance) (*model.Maintenance, error) {
+ query := `
+ INSERT INTO maintenances (title, start_time, end_time, is_active)
+ VALUES ($1, $2, $3, $4)
+ RETURNING id
+ `
+ err := s.pool.QueryRow(context.Background(), query, m.Title, m.StartTime, m.EndTime, m.IsActive).
+ Scan(&m.ID)
+ if err != nil {
+ return nil, fmt.Errorf("add maintenance: %w", err)
+ }
+ return &m, nil
+}
+
+func (s *PostgresStore) UpdateMaintenance(id int, m model.Maintenance) (*model.Maintenance, error) {
+ query := `
+ UPDATE maintenances
+ SET title = $1, start_time = $2, end_time = $3, is_active = $4
+ WHERE id = $5
+ RETURNING id, title, start_time, end_time, is_active
+ `
+ var updated model.Maintenance
+ err := s.pool.QueryRow(context.Background(), query, m.Title, m.StartTime, m.EndTime, m.IsActive, id).
+ Scan(&updated.ID, &updated.Title, &updated.StartTime, &updated.EndTime, &updated.IsActive)
+ if err != nil {
+ if err == pgx.ErrNoRows {
+ return nil, fmt.Errorf("manutencao nao encontrada")
+ }
+ return nil, fmt.Errorf("update maintenance: %w", err)
+ }
+ return &updated, nil
+}
+
+func (s *PostgresStore) DeleteMaintenance(id int) error {
+ tag, err := s.pool.Exec(context.Background(), "DELETE FROM maintenances WHERE id = $1", id)
+ if err != nil {
+ return fmt.Errorf("delete maintenance: %w", err)
+ }
+ if tag.RowsAffected() == 0 {
+ return fmt.Errorf("manutencao nao encontrada")
+ }
+ return nil
+}
diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go
index e5e05f6..2c34b1c 100644
--- a/backend/internal/store/store.go
+++ b/backend/internal/store/store.go
@@ -15,9 +15,14 @@ type Store interface {
ToggleService(int) (*model.Service, error)
DeleteService(int) error
AddHeartbeat(model.Heartbeat) error
- GetHistory(int, int) ([]model.Heartbeat, error)
+ GetHistory(int, int, int) ([]model.Heartbeat, error)
+ CountHeartbeats(int) (int, error)
GetLastHeartbeat(int) (*model.Heartbeat, error)
GetServiceStats(int) (*model.ServiceStats, error)
GetActiveMaintenance(now time.Time) (*model.Maintenance, error)
+ ListMaintenances() ([]model.Maintenance, error)
+ AddMaintenance(model.Maintenance) (*model.Maintenance, error)
+ UpdateMaintenance(int, model.Maintenance) (*model.Maintenance, error)
+ DeleteMaintenance(int) error
}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 0be9148..239c598 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -1,4 +1,4 @@
-import type { Service, Heartbeat, ServiceStats } from './types';
+import type { Service, Heartbeat, ServiceStats, PaginatedResponse } from './types';
const BASE = '/api';
@@ -26,8 +26,8 @@ export async function deleteService(id: number): Promise<void> {
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`);
+export async function getHistory(id: number, page = 1, perPage = 50): Promise<PaginatedResponse<Heartbeat>> {
+ const res = await fetch(`${BASE}/services/${id}/history?page=${page}&per_page=${perPage}`);
if (!res.ok) throw new Error('falha ao obter histórico');
return res.json();
}
@@ -37,3 +37,40 @@ export async function getServiceStats(id: number): Promise<ServiceStats> {
if (!res.ok) throw new Error('falha ao obter estatísticas');
return res.json();
}
+
+export async function listMaintenances(): Promise<Maintenance[]> {
+ const res = await fetch(`${BASE}/maintenances`);
+ if (!res.ok) throw new Error('falha ao listar manutenções');
+ return res.json();
+}
+
+export async function createMaintenance(data: { title: string; start_time: string; end_time: string }): Promise<Maintenance> {
+ const res = await fetch(`${BASE}/maintenances`, {
+ 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 manutenção');
+ }
+ return res.json();
+}
+
+export async function updateMaintenance(id: number, data: { title: string; start_time: string; end_time: string; is_active: boolean }): Promise<Maintenance> {
+ const res = await fetch(`${BASE}/maintenances/${id}`, {
+ method: 'PUT',
+ 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 atualizar manutenção');
+ }
+ return res.json();
+}
+
+export async function deleteMaintenance(id: number): Promise<void> {
+ const res = await fetch(`${BASE}/maintenances/${id}`, { method: 'DELETE' });
+ if (!res.ok) throw new Error('falha ao remover manutenção');
+}
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
index f80c933..594ab05 100644
--- a/frontend/src/lib/types.ts
+++ b/frontend/src/lib/types.ts
@@ -30,6 +30,14 @@ export interface Heartbeat {
tested_at: string;
}
+export interface PaginatedResponse<T> {
+ data: T[];
+ page: number;
+ per_page: number;
+ total: number;
+ total_pages: number;
+}
+
export interface ServiceStats {
service_id: number;
uptime_24h: number;
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); }
diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte
index 511460a..172e6b8 100644
--- a/frontend/src/routes/+page.svelte
+++ b/frontend/src/routes/+page.svelte
@@ -7,8 +7,10 @@
import { subscribeHeartbeats } from '$lib/realtime';
import type { Service, Heartbeat } from '$lib/types';
+ import type { InitialLogEntry } from './+page.ts';
+
let { data } = $props<{
- data: { services: (Service & { heartbeats: Heartbeat[] })[]; error: string };
+ data: { services: (Service & { heartbeats: Heartbeat[] })[]; error: string; initialLog: InitialLogEntry[] };
}>();
let initialized = $state(false);
@@ -35,6 +37,12 @@
let logEntries = $state<LogEntry[]>([]);
+ $effect(() => {
+ if (data?.initialLog && logEntries.length === 0) {
+ logEntries = data.initialLog.map((e) => ({ ...e, timestamp: new Date(e.timestamp) }));
+ }
+ });
+
function pushEntry(entry: LogEntry) {
logEntries = [...logEntries, entry];
if (logEntries.length > 200) {
diff --git a/frontend/src/routes/+page.ts b/frontend/src/routes/+page.ts
index ba70169..4f8b3f9 100644
--- a/frontend/src/routes/+page.ts
+++ b/frontend/src/routes/+page.ts
@@ -4,9 +4,19 @@ export interface EnhancedService extends Service {
heartbeats: Heartbeat[];
}
+export interface InitialLogEntry {
+ kind: 'check' | 'created' | 'deleted' | 'transition';
+ serviceName: string;
+ serviceUrl: string;
+ timestamp: Date;
+ hb?: Heartbeat;
+ wasUp?: boolean;
+}
+
export async function load({ fetch }) {
let services: EnhancedService[] = [];
let error = '';
+ let initialLog: InitialLogEntry[] = [];
try {
const res = await fetch('/api/services');
@@ -18,16 +28,46 @@ export async function load({ fetch }) {
try {
const hres = await fetch(`/api/services/${svc.id}/history`);
if (!hres.ok) throw new Error(`HTTP ${hres.status}`);
- const heartbeats: Heartbeat[] = await hres.json();
+ const json = await hres.json();
+ const heartbeats: Heartbeat[] = Array.isArray(json) ? json : (json.data ?? []);
return { ...svc, heartbeats };
} catch {
return { ...svc, heartbeats: [] };
}
})
);
+
+ initialLog = [];
+ for (const svc of services) {
+ const hbs = svc.heartbeats;
+ if (hbs.length === 0) continue;
+ const last = hbs[hbs.length - 1];
+ initialLog.push({
+ kind: 'check',
+ serviceName: svc.name,
+ serviceUrl: svc.url,
+ timestamp: new Date(last.tested_at),
+ hb: last
+ });
+ if (hbs.length >= 2) {
+ const prev = hbs[hbs.length - 2];
+ if (prev.is_up !== last.is_up) {
+ initialLog.push({
+ kind: 'transition',
+ serviceName: svc.name,
+ serviceUrl: svc.url,
+ timestamp: new Date(last.tested_at),
+ hb: last,
+ wasUp: prev.is_up
+ });
+ }
+ }
+ }
+ initialLog.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
+ initialLog = initialLog.slice(0, 50);
} catch (e) {
error = 'Não foi possível conectar ao servidor';
}
- return { services, error };
+ return { services, error, initialLog };
}
diff --git a/frontend/src/routes/services/[id]/+page.svelte b/frontend/src/routes/services/[id]/+page.svelte
index 36ea105..a7b8877 100644
--- a/frontend/src/routes/services/[id]/+page.svelte
+++ b/frontend/src/routes/services/[id]/+page.svelte
@@ -6,17 +6,41 @@
import type { Service, Heartbeat, ServiceStats } from '$lib/types';
let svc: Service | undefined = $state();
- let history: Heartbeat[] = $state([]);
+ let chartHistory: Heartbeat[] = $state([]);
let stats: ServiceStats | null = $state(null);
let loading = $state(true);
+
+ let tableData: Heartbeat[] = $state([]);
+ let currentPage = $state(1);
+ let totalPages = $state(1);
+ let totalItems = $state(0);
+
let expandedError: number | null = $state(null);
+ const PER_PAGE = 50;
+
+ async function loadTablePage(pageNum: number) {
+ const res = await getHistory(Number($page.params.id), pageNum, PER_PAGE);
+ tableData = res.data;
+ currentPage = res.page;
+ totalPages = res.total_pages;
+ totalItems = res.total;
+ }
+
onMount(async () => {
try {
const all = await listServices();
svc = all.find((s) => s.id === Number($page.params.id));
- history = await getHistory(Number($page.params.id));
+
+ const chartRes = await fetch(`/api/services/${$page.params.id}/history?page=1&per_page=50`);
+ if (chartRes.ok) {
+ const json = await chartRes.json();
+ chartHistory = json.data ?? json;
+ }
+
getServiceStats(Number($page.params.id)).then((s) => (stats = s)).catch(() => {});
+
+ await loadTablePage(1);
} catch {
// handle
} finally {
@@ -24,47 +48,117 @@
}
});
- $effect(() => {
- if (!svc && $page.params.id) {
- getHistory(Number($page.params.id))
- .then((h) => (history = h))
- .catch(() => {});
- }
- });
-
onMount(() => {
const unsub = subscribeHeartbeats((hb) => {
if (hb.service_id !== Number($page.params.id)) return;
- history = [...history, hb].slice(-50);
+ chartHistory = [...chartHistory, hb].slice(-50);
+ if (currentPage === 1) {
+ tableData = [hb, ...tableData].slice(0, PER_PAGE);
+ totalItems++;
+ totalPages = Math.max(1, Math.ceil(totalItems / PER_PAGE));
+ }
});
return unsub;
});
- let chartPad = { t: 8, b: 20, l: 40, r: 12 };
- let chartW = $derived(Math.max(history.length * 32, 300));
+ let containerWidth = $state(0);
+ let pad = { t: 8, b: 24, l: 44, r: 12 };
let chartH = 200;
- let plotW = $derived(chartW - chartPad.l - chartPad.r);
- let plotH = $derived(chartH - chartPad.t - chartPad.b);
- let chartMax = $derived(Math.max(...history.map((h) => h.response_time_ms), 100));
- let chartMin = $derived(0);
+ let plotW = $derived(Math.max(containerWidth - pad.l - pad.r, 0));
+ let plotH = $derived(chartH - pad.t - pad.b);
+
+ function niceNum(range: number, round: boolean): number {
+ const exp = Math.floor(Math.log10(range));
+ const frac = range / Math.pow(10, exp);
+ let nice: number;
+ if (round) {
+ if (frac <= 1.5) nice = 1;
+ else if (frac <= 3) nice = 2;
+ else if (frac <= 7) nice = 5;
+ else nice = 10;
+ } else {
+ if (frac <= 1) nice = 1;
+ else if (frac <= 2) nice = 2;
+ else if (frac <= 5) nice = 5;
+ else nice = 10;
+ }
+ return nice * Math.pow(10, exp);
+ }
+
+ let yAxis = $derived.by(() => {
+ const vals = chartHistory.map((h) => h.response_time_ms);
+ if (vals.length === 0) return { min: 0, max: 100, ticks: [{ val: 0, y: 0 }, { val: 100, y: 200 }] };
+ const rawMin = Math.min(...vals);
+ const rawMax = Math.max(...vals);
+ if (rawMax === rawMin) return { min: 0, max: Math.max(rawMax * 2, 100), ticks: [] };
+
+ const range = rawMax - rawMin;
+ const pad = Math.max(range * 0.15, 10);
+ let lo = Math.max(0, rawMin - pad);
+ let hi = rawMax + pad;
+
+ const tickStep = niceNum((hi - lo) / 4, true);
+ lo = Math.floor(lo / tickStep) * tickStep;
+ hi = Math.ceil(hi / tickStep) * tickStep;
+
+ const ticks: { val: number; y: number }[] = [];
+ for (let v = lo; v <= hi + tickStep * 0.001; v += tickStep) {
+ const y = pad.t + plotH - ((v - lo) / (hi - lo || 1)) * plotH;
+ ticks.push({ val: Math.round(v), y });
+ }
+ return { min: lo, max: hi, ticks };
+ });
+
+ let segments = $derived.by(() => {
+ const n = chartHistory.length;
+ if (n < 2) return [];
+ const { min, max } = yAxis;
+ return chartHistory.slice(0, -1).map((h, i) => {
+ const next = chartHistory[i + 1];
+ const x1 = pad.l + (i / (n - 1)) * plotW;
+ const x2 = pad.l + ((i + 1) / (n - 1)) * plotW;
+ const y1 = pad.t + plotH - ((h.response_time_ms - min) / (max - min || 1)) * plotH;
+ const y2 = pad.t + plotH - ((next.response_time_ms - min) / (max - min || 1)) * plotH;
+ return {
+ x1, y1, x2, y2,
+ color: h.is_up && next.is_up ? 'var(--green)' : 'var(--red)'
+ };
+ });
+ });
let dots = $derived(
- history.map((h, i) => {
- const x = history.length === 1
- ? chartPad.l + plotW / 2
- : chartPad.l + (i / (history.length - 1)) * plotW;
- const y = chartPad.t + plotH - ((h.response_time_ms - chartMin) / (chartMax - chartMin || 1)) * plotH;
+ chartHistory.map((h, i) => {
+ const { min, max } = yAxis;
+ const x = pad.l + (i / (Math.max(chartHistory.length - 1, 1))) * plotW;
+ const y = pad.t + plotH - ((h.response_time_ms - min) / (max - min || 1)) * plotH;
return { x, y, ...h };
})
);
- let yTicks = $derived.by(() => {
- const n = 4;
- return Array.from({ length: n + 1 }, (_, i) => {
- const val = chartMin + ((chartMax - chartMin) / n) * i;
- const y = chartPad.t + plotH - ((val - chartMin) / (chartMax - chartMin || 1)) * plotH;
- return { val: Math.round(val), y };
- });
+ let areaPath = $derived.by(() => {
+ if (chartHistory.length < 2) return '';
+ const { min, max } = yAxis;
+ const baseline = pad.t + plotH;
+ const top = dots.map((d) => `${d.x},${d.y}`).join(' L ');
+ const bottom = dots.map((d) => d.x).reverse().map((x) => `${x},${baseline}`).join(' L ');
+ return `M ${top} L ${bottom} Z`;
+ });
+
+ let pages = $derived.by(() => {
+ const p: (number | string)[] = [];
+ const total = totalPages;
+ if (total <= 7) {
+ for (let i = 1; i <= total; i++) p.push(i);
+ } else {
+ p.push(1);
+ if (currentPage > 3) p.push('...');
+ const start = Math.max(2, currentPage - 1);
+ const end = Math.min(total - 1, currentPage + 1);
+ for (let i = start; i <= end; i++) p.push(i);
+ if (currentPage < total - 2) p.push('...');
+ p.push(total);
+ }
+ return p;
});
</script>
@@ -143,39 +237,47 @@
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-[var(--text-secondary)]">
Tempo de Resposta (ms)
</h2>
- <div class="relative h-48">
- {#if history.length === 0}
+ <div bind:clientWidth={containerWidth} class="relative" style="height: 200px">
+ {#if chartHistory.length === 0}
<div class="flex h-full items-center justify-center text-xs text-[var(--text-muted)]">
Sem dados ainda
</div>
- {:else if history.length === 1}
+ {:else if chartHistory.length === 1}
<div class="flex h-full flex-col items-center justify-center gap-1">
<span
class="text-3xl font-bold tabular-nums"
- style="color: {history[0].is_up ? 'var(--green)' : 'var(--red)'}"
+ style="color: {chartHistory[0].is_up ? 'var(--green)' : 'var(--red)'}"
>
- {history[0].response_time_ms}
+ {chartHistory[0].response_time_ms}
<span class="text-base font-normal text-[var(--text-secondary)]">ms</span>
</span>
<span class="text-xs text-[var(--text-muted)]">
- {history[0].is_up ? 'Online' : 'Offline'} —
- {history[0].status_code || 'timeout'}
+ {chartHistory[0].is_up ? 'Online' : 'Offline'} —
+ {chartHistory[0].status_code || 'timeout'}
</span>
</div>
{:else}
- <svg class="h-full w-full" viewBox="0 0 {chartW} {chartH}" preserveAspectRatio="none">
- {#each yTicks as tick}
+ <svg width={containerWidth} height={chartH}>
+ <defs>
+ <linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1">
+ <stop offset="0%" stop-color="var(--green)" stop-opacity="0.15" />
+ <stop offset="100%" stop-color="var(--green)" stop-opacity="0.01" />
+ </linearGradient>
+ </defs>
+
+ <!-- grid horizontal -->
+ {#each yAxis.ticks as tick}
<line
- x1={chartPad.l}
+ x1={pad.l}
y1={tick.y}
- x2={chartW - chartPad.r}
+ x2={containerWidth - pad.r}
y2={tick.y}
stroke="var(--border-color)"
stroke-width="1"
stroke-dasharray="3,3"
/>
<text
- x={chartPad.l - 6}
+ x={pad.l - 6}
y={tick.y + 3}
text-anchor="end"
fill="var(--text-muted)"
@@ -184,38 +286,56 @@
>
{/each}
- <polyline
- points={dots.map((d) => `${d.x},${d.y}`).join(' ')}
- fill="none"
- stroke="var(--green)"
- stroke-width="2"
- stroke-linecap="round"
- stroke-linejoin="round"
- />
+ <!-- área preenchida -->
+ <path d={areaPath} fill="url(#areaGrad)" />
+ <!-- segmentos da linha -->
+ {#each segments as seg}
+ <line
+ x1={seg.x1}
+ y1={seg.y1}
+ x2={seg.x2}
+ y2={seg.y2}
+ stroke={seg.color}
+ stroke-width="2"
+ stroke-linecap="round"
+ />
+ {/each}
+
+ <!-- pontos -->
{#each dots as d}
- <circle cx={d.x} cy={d.y} r="3.5" fill={d.is_up ? 'var(--green)' : 'var(--red)'} />
+ <circle
+ cx={d.x}
+ cy={d.y}
+ r="3"
+ fill={d.is_up ? 'var(--green)' : 'var(--red)'}
+ stroke="var(--bg-card)"
+ stroke-width="1.5"
+ />
{/each}
</svg>
+
+ <!-- eixo X: tempo -->
+ <div class="flex justify-between" style="padding-left: {pad.l}px; padding-right: {pad.r}px;">
+ <span class="text-[10px] text-[var(--text-muted)]">
+ {new Date(chartHistory[0].tested_at).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}
+ </span>
+ <span class="text-[10px] text-[var(--text-muted)]">
+ {new Date(chartHistory[chartHistory.length - 1].tested_at).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}
+ </span>
+ </div>
{/if}
</div>
- <div class="mt-2 flex justify-between text-[10px] text-[var(--text-muted)]">
- <span>{history[0]?.tested_at ? new Date(history[0].tested_at).toLocaleTimeString() : ''}</span>
- <span
- >{history[history.length - 1]?.tested_at
- ? new Date(history[history.length - 1].tested_at).toLocaleTimeString()
- : ''}</span
- >
- </div>
</div>
- <!-- Timeline compacta -->
- <div class="rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] p-5">
+ <!-- Histórico paginado -->
+ <div class="rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] p-5 lg:col-span-3">
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-[var(--text-secondary)]">
- Últimas Verificações
+ Histórico de Verificações
</h2>
+
<div class="flex flex-col gap-1.5">
- {#each history.slice().reverse() as h}
+ {#each tableData as h}
<div>
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
@@ -256,6 +376,49 @@
</div>
{/each}
</div>
+
+ <!-- Paginação -->
+ {#if totalPages > 1}
+ <div class="mt-5 flex items-center justify-center gap-1.5">
+ <button
+ onclick={() => loadTablePage(currentPage - 1)}
+ disabled={currentPage <= 1}
+ class="rounded-lg px-2.5 py-1.5 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-30"
+ style="color: var(--text-muted); {currentPage <= 1 ? '' : 'hover:bg-[var(--border-color)] hover:text-white'}"
+ >
+ Anterior
+ </button>
+
+ {#each pages as p}
+ {#if p === '...'}
+ <span class="px-1 text-xs text-[var(--text-muted)]">…</span>
+ {:else}
+ <button
+ onclick={() => loadTablePage(p as number)}
+ class="min-w-[28px] rounded-lg px-2 py-1.5 text-xs font-medium transition-all"
+ style={p === currentPage
+ ? 'background-color: var(--green); color: #000; font-weight: 700;'
+ : 'color: var(--text-muted); hover:background-color: var(--border-color); hover:color: white;'}
+ >
+ {p}
+ </button>
+ {/if}
+ {/each}
+
+ <button
+ onclick={() => loadTablePage(currentPage + 1)}
+ disabled={currentPage >= totalPages}
+ class="rounded-lg px-2.5 py-1.5 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-30"
+ style="color: var(--text-muted); {currentPage >= totalPages ? '' : 'hover:bg-[var(--border-color)] hover:text-white'}"
+ >
+ Próximo
+ </button>
+ </div>
+
+ <p class="mt-2 text-center text-[10px] text-[var(--text-muted)]">
+ Página {currentPage} de {totalPages} — {totalItems} verificações no total
+ </p>
+ {/if}
</div>
</div>
{/if}
diff --git a/frontend/src/routes/status/[id]/+page.svelte b/frontend/src/routes/status/[id]/+page.svelte
index afc59a2..0701eeb 100644
--- a/frontend/src/routes/status/[id]/+page.svelte
+++ b/frontend/src/routes/status/[id]/+page.svelte
@@ -187,7 +187,7 @@
</span>
</div>
{:else}
- <svg class="h-full w-full" viewBox="0 0 {chartW} {chartH}" preserveAspectRatio="none">
+ <svg class="h-full w-full">
{#each yTicks as tick}
<line
x1={chartPad.l} y1={tick.y} x2={chartW - chartPad.r} y2={tick.y}
diff --git a/frontend/src/routes/status/[id]/+page.ts b/frontend/src/routes/status/[id]/+page.ts
index 4351aae..1b91c67 100644
--- a/frontend/src/routes/status/[id]/+page.ts
+++ b/frontend/src/routes/status/[id]/+page.ts
@@ -11,7 +11,8 @@ export const load: PageLoad = async ({ params, fetch }) => {
const services = servicesRes?.ok ? await servicesRes.json() : [];
const svc = services.find((s: any) => s.id === Number(id));
- const history = historyRes?.ok ? await historyRes.json() : [];
+ const historyJson = historyRes?.ok ? await historyRes.json() : [];
+ const history = Array.isArray(historyJson) ? historyJson : (historyJson.data ?? []);
const stats = statsRes?.ok ? await statsRes.json() : null;
return {