aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore4
-rw-r--r--Dockerfile61
-rw-r--r--README.md241
-rw-r--r--backend/internal/api/router.go83
-rw-r--r--backend/internal/static/static.go43
-rw-r--r--frontend/package-lock.json11
-rw-r--r--frontend/package.json1
-rw-r--r--frontend/src/hooks.server.js5
-rw-r--r--frontend/src/routes/(admin)/admin/+page.server.js8
-rw-r--r--frontend/src/routes/(admin)/admin/+page.svelte49
-rw-r--r--frontend/src/routes/+layout.svelte2
-rw-r--r--frontend/src/routes/login/+page.server.js7
-rw-r--r--frontend/src/routes/login/+page.svelte61
-rw-r--r--frontend/src/routes/status/[id]/+page.ts8
-rw-r--r--frontend/svelte.config.js6
-rw-r--r--scripts/build-prod.ps127
-rw-r--r--scripts/build-prod.sh29
17 files changed, 616 insertions, 30 deletions
diff --git a/.gitignore b/.gitignore
index 6f24604..5f2bf75 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,10 @@
# Backend
backend/server.exe
backend/server
+backend/yaum.exe
+backend/yaum
+backend/internal/static/build/
+!backend/internal/static/build/placeholder.txt
# Frontend
frontend/node_modules/
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..48979be
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,61 @@
+# ============================================================
+# YAUM — Multistage Docker Build
+# Produces an ~20 MB single-binary image with zero runtime
+# dependencies (Go + SvelteKit frontend embedded).
+# ============================================================
+
+# ─────────────────────────────────────────────────────────────
+# Stage 1: Build the SvelteKit frontend (static export)
+# ─────────────────────────────────────────────────────────────
+FROM node:20-alpine AS frontend-builder
+
+WORKDIR /build/frontend
+
+# Install dependencies (layer cached while package*.json stay the same)
+COPY frontend/package.json frontend/package-lock.json* ./
+RUN npm ci
+
+# Copy source and build
+COPY frontend/ ./
+RUN npm run build
+
+# Result: /build/frontend/build/ (200.html, _app/, …)
+
+# ─────────────────────────────────────────────────────────────
+# Stage 2: Compile the Go backend with the frontend embedded
+# ─────────────────────────────────────────────────────────────
+FROM golang:1.25-alpine AS backend-builder
+
+WORKDIR /build
+
+# Copy backend source
+COPY backend/ ./backend/
+
+# Copy the static export from Stage 1 into the Go embed directory
+COPY --from=frontend-builder /build/frontend/build/ ./backend/internal/static/build/
+
+# Build a fully static binary (no libc, no external deps)
+WORKDIR /build/backend
+RUN CGO_ENABLED=0 \
+ GOOS=linux \
+ GOARCH=amd64 \
+ go build \
+ -ldflags="-s -w" \
+ -o /app/yaum \
+ ./cmd/server/
+
+# ─────────────────────────────────────────────────────────────
+# Stage 3: Minimal runtime image
+# ─────────────────────────────────────────────────────────────
+FROM alpine:3.20
+
+# CA certificates so Go can verify TLS on monitored HTTPS URLs
+RUN apk add --no-cache ca-certificates tzdata
+
+COPY --from=backend-builder /app/yaum /app/yaum
+
+# Default port; can be overridden at runtime via config.json or env
+EXPOSE 8080
+
+WORKDIR /app
+CMD ["/app/yaum"]
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..166b1d3
--- /dev/null
+++ b/README.md
@@ -0,0 +1,241 @@
+
+<p align="center">
+ <img src="frontend/static/favicon.svg" width="80" alt="YAUM logo" />
+</p>
+
+<h1 align="center">YAUM — Yet Another Uptime Monitor</h1>
+
+<p align="center">
+ A private, ultra-performant, and minimalist uptime monitoring dashboard
+ for websites and HTTP services, built with <strong>Go</strong> and <strong>SvelteKit</strong>.
+</p>
+
+<p align="center">
+ <a href="#"><img src="https://img.shields.io/badge/Go-1.25-00ADD8?logo=go" alt="Go 1.25" /></a>
+ <a href="#"><img src="https://img.shields.io/badge/Svelte-5-FF3E00?logo=svelte" alt="Svelte 5" /></a>
+ <a href="#"><img src="https://img.shields.io/badge/SvelteKit-2-FF3E00?logo=svelte" alt="SvelteKit 2" /></a>
+ <a href="#"><img src="https://img.shields.io/badge/TailwindCSS-3-06B6D4?logo=tailwindcss" alt="TailwindCSS 3" /></a>
+ <a href="#"><img src="https://img.shields.io/badge/Docker-ready-2496ED?logo=docker" alt="Docker ready" /></a>
+ <a href="#"><img src="https://img.shields.io/badge/license-MIT-green" alt="MIT License" /></a>
+</p>
+
+---
+
+## 🚀 Key Features
+
+- **⏱️ High-concurrency monitoring engine** — Go goroutines fire per-service HTTP checks at configurable intervals (down to 10 seconds granularity).
+- **📊 Real-time public dashboard** — Server-Sent Events (SSE) push heartbeats and status transitions to the browser as they happen.
+- **🔐 Password-protected admin panel** — Single-admin JWT auth with bcrypt master password; manage services, maintenances, and server metrics.
+- **📈 Response-time charts** — SVG-based latency visualization with colored segments, gradient fill, and dynamic y‑axis scaling.
+- **🔔 Discord webhook alerts** — Instant notifications on status transitions.
+- **📋 Keyword checking** — Validate response bodies for expected content.
+- **📦 Service groups** — Organize services into named groups for cleaner dashboards.
+- **🛠️ Maintenance windows** — Schedule maintenance periods that display a global banner.
+- **📜 Activity log** — Real-time log of checks, transitions, creations, and deletions.
+- **📄 SVG status badges** — Lightweight embeddable badges for READMEs or external pages.
+- **📺 Kiosk / TV mode** — Full-screen dashboard for wall-mounted monitors with Screen Wake Lock API integration (prevents sleep).
+- **🔒 SSL/TLS inspection** — Automatic extraction of certificate issuer, expiry date, and days remaining; yellow alert when < 7 days.
+- **🖥️ Host server health metrics** — CPU, RAM, and Disk usage displayed directly in the admin panel (via `gopsutil`).
+- **🐳 Single-binary deployment** — Entire frontend embedded into the Go binary via `go:embed`; deploy with a single executable or Docker.
+- **↔️ History pagination** — Paginated check history (50 per page) with SSE updates on page 1.
+
+---
+
+## 🛠️ Tech Stack
+
+| Component | Technology |
+| ----------------------- | ------------------------------------------------------- |
+| **Backend Language** | Go 1.25 (`net/http`, `pgx/v5`, `golang-jwt`, `bcrypt`) |
+| **Frontend Framework** | SvelteKit 2 + Svelte 5 |
+| **Database** | PostgreSQL (pgx/v5) — in-memory fallback for dev |
+| **Styling** | TailwindCSS 3 + custom dark theme |
+| **Real-time transport** | Server-Sent Events (SSE) |
+| **Server metrics** | `shirou/gopsutil/v4` (CPU / RAM / Disk) |
+| **Deploy / Infra** | Docker multistage (`alpine:3.20`), single binary |
+| **Authentication** | JWT (7 days) + bcrypt master password |
+
+---
+
+## 🗂️ Project Architecture
+
+```
+yaum/
+├── backend/ # Go monolith (API + Worker + SSE)
+│ ├── cmd/server/main.go # Entry point
+│ ├── internal/
+│ │ ├── api/ # HTTP handlers & router
+│ │ │ ├── handler.go # All route handlers
+│ │ │ ├── router.go # ServeMux + SPA file server
+│ │ │ ├── auth.go # JWT login, verify, middleware
+│ │ │ └── stream.go # SSE broadcaster endpoint
+│ │ ├── monitor/ # Background checker
+│ │ │ ├── worker.go # Scheduling engine (10s tick granularity)
+│ │ │ └── checker.go # HTTP check + TLS inspection
+│ │ ├── store/ # Data layer
+│ │ │ ├── store.go # Interface
+│ │ │ ├── memory.go # In-memory implementation
+│ │ │ └── postgres.go # PostgreSQL implementation
+│ │ ├── model/types.go # Shared domain types
+│ │ ├── config/config.go # Config loader (JSON + .env)
+│ │ ├── alerts/ # Discord + Email notification
+│ │ ├── sse/ # SSE broadcaster
+│ │ ├── servermetrics/ # CPU / RAM / Disk collector
+│ │ └── static/ # Embeds the frontend build
+│ ├── migrations/ # PostgreSQL schema files
+│ ├── config.json # Server configuration
+│ └── .env.example # Environment template
+│
+├── frontend/ # SvelteKit SPA
+│ ├── src/
+│ │ ├── routes/
+│ │ │ ├── +page.svelte # Public dashboard
+│ │ │ ├── login/ # Login page
+│ │ │ ├── status/[id]/ # Public service detail
+│ │ │ └── (admin)/admin/ # Admin panel
+│ │ └── lib/
+│ │ ├── components/ # ServiceCard, ActivityLog, modals
+│ │ ├── realtime.ts # SSE client subscription
+│ │ └── api.ts # API client helpers
+│ └── svelte.config.js # adapter-static (SPA fallback)
+│
+├── scripts/ # Build helpers
+│ ├── build-prod.sh # Unix production build
+│ └── build-prod.ps1 # Windows production build
+│
+├── Dockerfile # Multistage docker build
+└── README.md # This file
+```
+
+### How it works
+
+1. **Worker** — A background goroutine iterates active services every 10 seconds. For each service whose `interval_seconds` has elapsed since the last check, it fires a goroutine that performs an HTTP GET (with optional keyword validation and TLS certificate extraction).
+2. **Persistence** — Each heartbeat is stored in PostgreSQL (or in-memory store for development) and the latest SSL info is saved per service.
+3. **Real-time streaming** — Every heartbeat is broadcast to all connected browser clients via SSE at `/api/stream`.
+4. **Router** — The Go `net/http` ServeMux (Go 1.22+ pattern matching) handles API routes (`/api/*`) and falls through to an embedded static file server for the SvelteKit frontend. Any path that doesn't match a file gets the SPA fallback (`200.html`), enabling client-side routing for `/admin`, `/status/[id]`, and `/login`.
+
+---
+
+## ⚙️ Getting Started — Local Development
+
+### Prerequisites
+
+- [Go](https://go.dev/dl/) 1.25+
+- [Node.js](https://nodejs.org/) 20+
+- [PostgreSQL](https://www.postgresql.org/download/) 16+ (optional — the app falls back to an in-memory store)
+
+### 1. Clone and configure
+
+```bash
+git clone https://git.devlucas.me/yaum.git
+cd yaum/backend
+cp .env.example .env
+```
+
+Edit `.env` with your credentials:
+
+```env
+ADMIN_USERNAME=admin
+ADMIN_PASSWORD=your-secure-password
+JWT_SECRET=your-random-64-char-secret
+```
+
+Optionally set up PostgreSQL in `config.json` (the app works without it using an in-memory store with seed data).
+
+### 2. Start the Go backend
+
+```bash
+cd backend
+go run ./cmd/server/
+```
+
+The API will be available at `http://localhost:8080`.
+
+### 3. Start the SvelteKit frontend (dev mode)
+
+```bash
+cd frontend
+npm install
+npm run dev
+```
+
+The Vite dev server runs at `http://localhost:5173` and proxies `/api/*` requests to the Go backend.
+
+Open `http://localhost:5173` in your browser. Navigate to `/login` and sign in with your admin credentials.
+
+---
+
+## 🐳 Production & Deployment
+
+YAUM uses a **multistage Docker build** that produces a ~20 MB image containing everything needed to run.
+
+### Build the image
+
+```bash
+docker build -t yaum .
+```
+
+### Run the container
+
+Mount your `config.json` and `.env` file, and expose port 8080:
+
+```bash
+docker run -d \
+ --name yaum \
+ -p 8080:8080 \
+ -v /host/path/config.json:/app/config.json \
+ -v /host/path/.env:/app/.env \
+ yaum
+```
+
+### Build the binary manually
+
+To produce the single binary without Docker:
+
+```bash
+# 1. Build the frontend
+cd frontend && npm ci && npm run build
+
+# 2. Copy the static export into the Go embed directory
+cp -r frontend/build backend/internal/static/build/
+
+# 3. Compile the Go binary
+cd backend
+CGO_ENABLED=0 go build -ldflags="-s -w" -o yaum ./cmd/server/
+```
+
+Run the resulting `backend/yaum` binary — it serves both the API and the frontend on the configured port (default `:8080`).
+
+### Platform-specific instructions
+
+| Platform | Script |
+| ---------- | ------------------------ |
+| Linux/macOS | `scripts/build-prod.sh` |
+| Windows | `scripts\build-prod.ps1` |
+
+---
+
+## 📄 License
+
+```
+MIT License
+
+Copyright (c) 2026
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+```
diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go
index d28da71..a88ffbd 100644
--- a/backend/internal/api/router.go
+++ b/backend/internal/api/router.go
@@ -1,8 +1,12 @@
package api
import (
+ "io/fs"
+ "log"
"net/http"
"strings"
+
+ "yaum/internal/static"
)
func NewRouter(h *Handler, stream *StreamHandler, auth *AuthHandler) http.Handler {
@@ -33,9 +37,88 @@ func NewRouter(h *Handler, stream *StreamHandler, auth *AuthHandler) http.Handle
mux.Handle("GET /api/stream", stream)
+ // Static file serving — register only if build FS is available
+ if buildFS := static.BuildFS(); buildFS != nil {
+ staticHandler := spaFileServer(buildFS)
+ mux.HandleFunc("/", staticHandler.ServeHTTP)
+ } else {
+ log.Println("[router] nenhum build frontend encontrado — servindo apenas API")
+ // Still register root to give a friendly message
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ if strings.HasPrefix(r.URL.Path, "/api") {
+ http.NotFound(w, r)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(`YAUM API — frontend build not available.
+Start the frontend dev server (npm run dev) or set YAUM_STATIC_DIR.`))
+ })
+ }
+
return corsMiddleware(auth.Middleware(mux))
}
+// spaFileServer returns an http.Handler that serves static files and falls
+// back to index.html (or 200.html) for SPA routes like /admin, /status/123.
+// Only GET/HEAD requests are handled; other methods return 405.
+func spaFileServer(fsys fs.FS) http.Handler {
+ fileServer := http.FileServer(http.FS(fsys))
+
+ exists := func(name string) bool {
+ f, err := fsys.Open(name)
+ if err != nil {
+ return false
+ }
+ f.Close()
+ return true
+ }
+
+ // Determine which SPA fallback file exists
+ fallbackFile := "200.html"
+ fallbackBytes, fallbackErr := fs.ReadFile(fsys, fallbackFile)
+ if fallbackErr != nil {
+ fallbackFile = "index.html"
+ fallbackBytes, fallbackErr = fs.ReadFile(fsys, fallbackFile)
+ }
+ if fallbackErr != nil {
+ log.Printf("[router] nenhum fallback SPA (200.html/index.html) encontrado")
+ }
+
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Only serve GET/HEAD for static files
+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ // API paths should already be handled by the mux, but just in case
+ if strings.HasPrefix(r.URL.Path, "/api") {
+ http.NotFound(w, r)
+ return
+ }
+
+ p := strings.TrimPrefix(r.URL.Path, "/")
+
+ // Root path or non-existent file → serve SPA fallback
+ if p == "" || !exists(p) {
+ if fallbackErr == nil {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(http.StatusOK)
+ w.Write(fallbackBytes)
+ return
+ }
+ http.NotFound(w, r)
+ return
+ }
+
+ fileServer.ServeHTTP(w, r)
+ })
+}
+
+
+
+
+
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
diff --git a/backend/internal/static/static.go b/backend/internal/static/static.go
new file mode 100644
index 0000000..53523a3
--- /dev/null
+++ b/backend/internal/static/static.go
@@ -0,0 +1,43 @@
+package static
+
+import (
+ "embed"
+ "io/fs"
+ "log"
+ "os"
+ "path/filepath"
+)
+
+//go:embed build
+var embeddedBuild embed.FS
+
+func BuildFS() fs.FS {
+ sub, err := fs.Sub(embeddedBuild, "build")
+ if err != nil {
+ log.Printf("[static] embed nao disponivel, tentando filesystem: %v", err)
+ // Fallback: look for build directory relative to the backend binary
+ dir := os.Getenv("YAUM_STATIC_DIR")
+ if dir == "" {
+ // Try common locations
+ candidates := []string{
+ "../frontend/build",
+ "frontend/build",
+ "./build",
+ }
+ for _, c := range candidates {
+ if info, e := os.Stat(c); e == nil && info.IsDir() {
+ dir = c
+ break
+ }
+ }
+ }
+ if dir != "" {
+ abs, _ := filepath.Abs(dir)
+ log.Printf("[static] servindo de %s", abs)
+ return os.DirFS(dir)
+ }
+ return nil
+ }
+ log.Println("[static] servindo de embed")
+ return sub
+}
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 09bad19..2198ee5 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -10,6 +10,7 @@
"hasInstallScript": true,
"devDependencies": {
"@sveltejs/adapter-auto": "^3.3.1",
+ "@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.61.1",
"@sveltejs/vite-plugin-svelte": "^4.0.4",
"autoprefixer": "^10.5.0",
@@ -937,6 +938,16 @@
"@sveltejs/kit": "^2.0.0"
}
},
+ "node_modules/@sveltejs/adapter-static": {
+ "version": "3.0.10",
+ "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz",
+ "integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@sveltejs/kit": "^2.0.0"
+ }
+ },
"node_modules/@sveltejs/kit": {
"version": "2.61.1",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.61.1.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 557023a..a47f219 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -11,6 +11,7 @@
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.3.1",
+ "@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.61.1",
"@sveltejs/vite-plugin-svelte": "^4.0.4",
"autoprefixer": "^10.5.0",
diff --git a/frontend/src/hooks.server.js b/frontend/src/hooks.server.js
index 39c89e9..0c53a3d 100644
--- a/frontend/src/hooks.server.js
+++ b/frontend/src/hooks.server.js
@@ -1,7 +1,8 @@
import { redirect } from '@sveltejs/kit';
-const API = 'http://localhost:8080/api';
+import { building } from '$app/environment';
+const API_BASE = building ? 'http://localhost:8080/api' : '/api';
const PUBLIC_PREFIXES = ['/_app', '/api'];
export async function handle({ event, resolve }) {
@@ -18,7 +19,7 @@ export async function handle({ event, resolve }) {
if (!token) {
throw redirect(302, '/login');
}
- const res = await fetch(`${API}/auth/verify`, {
+ const res = await fetch(`${API_BASE}/auth/verify`, {
headers: { Authorization: `Bearer ${token}` }
});
if (!res.ok) {
diff --git a/frontend/src/routes/(admin)/admin/+page.server.js b/frontend/src/routes/(admin)/admin/+page.server.js
index bdf498b..f0a6f44 100644
--- a/frontend/src/routes/(admin)/admin/+page.server.js
+++ b/frontend/src/routes/(admin)/admin/+page.server.js
@@ -1,11 +1,13 @@
-const API = 'http://localhost:8080/api';
+import { building } from '$app/environment';
+
+const API_BASE = building ? 'http://localhost:8080/api' : '/api';
export async function load({ locals }) {
const token = locals.token;
const headers = token ? { Authorization: `Bearer ${token}` } : {};
- const res = await fetch(`${API}/services`, { headers });
+ const res = await fetch(`${API_BASE}/services`, { headers });
if (!res.ok) {
return { services: [], error: 'Falha ao carregar serviços', token: token ?? '' };
}
@@ -14,7 +16,7 @@ export async function load({ locals }) {
const servicesWithStats = await Promise.all(
services.map(async (svc) => {
try {
- const statsRes = await fetch(`${API}/services/${svc.id}/stats`, { headers });
+ const statsRes = await fetch(`${API_BASE}/services/${svc.id}/stats`, { headers });
if (statsRes.ok) {
svc.stats = await statsRes.json();
}
diff --git a/frontend/src/routes/(admin)/admin/+page.svelte b/frontend/src/routes/(admin)/admin/+page.svelte
index 7fa361d..9a48ba0 100644
--- a/frontend/src/routes/(admin)/admin/+page.svelte
+++ b/frontend/src/routes/(admin)/admin/+page.svelte
@@ -1,5 +1,7 @@
<script lang="ts">
- const API = 'http://localhost:8080/api';
+ import { onMount } from 'svelte';
+
+ const API = '/api';
let { data } = $props();
@@ -8,15 +10,42 @@
let services = $state<any[]>([]);
let showForm = $state(false);
- $effect(() => {
- if (!initialized && data) {
- token = data.token;
- services = data.services ?? [];
- initialized = true;
- fetchMaintenances();
- fetchServerStats();
+ function getCookie(name: string) {
+ return document.cookie.split('; ').find(r => r.startsWith(name + '='))?.split('=')[1] ?? '';
+ }
+
+ async function bootstrap() {
+ if (initialized) return;
+ initialized = true;
+ token = data?.token || getCookie('auth_token') || '';
+ if (data?.services && data.services.length > 0) {
+ services = data.services;
+ } else {
+ await fetchServices();
}
- });
+ fetchMaintenances();
+ fetchServerStats();
+ }
+
+ async function fetchServices() {
+ try {
+ const res = await fetch(`${API}/services`, { headers: { Authorization: `Bearer ${token}` } });
+ if (await handleUnauthorized(res)) return;
+ if (res.ok) {
+ const svcs = await res.json();
+ const withStats = await Promise.all(
+ svcs.map(async (svc: any) => {
+ try {
+ const sr = await fetch(`${API}/services/${svc.id}/stats`, { headers: { Authorization: `Bearer ${token}` } });
+ if (sr.ok) svc.stats = await sr.json();
+ } catch { /* ignore */ }
+ return svc;
+ })
+ );
+ services = withStats;
+ }
+ } catch { /* ignore */ }
+ }
let editingSvc: any = $state(null);
let saving = $state(false);
let formError = $state('');
@@ -24,6 +53,8 @@
let toggling = $state<Record<number, boolean>>({});
// maintenance state
+ onMount(bootstrap);
+
let maintenances = $state<any[]>([]);
let showMtnForm = $state(false);
let editingMtn: any = $state(null);
diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte
index 9ed74ce..8dbfcda 100644
--- a/frontend/src/routes/+layout.svelte
+++ b/frontend/src/routes/+layout.svelte
@@ -8,7 +8,7 @@
onMount(async () => {
try {
- const res = await fetch('http://localhost:8080/api/active-maintenance');
+ const res = await fetch('/api/active-maintenance');
if (res.ok) {
const data = await res.json();
maintenance = data.maintenance ?? null;
diff --git a/frontend/src/routes/login/+page.server.js b/frontend/src/routes/login/+page.server.js
index 4272ef7..a0e9f7f 100644
--- a/frontend/src/routes/login/+page.server.js
+++ b/frontend/src/routes/login/+page.server.js
@@ -1,11 +1,12 @@
import { redirect } from '@sveltejs/kit';
+import { building } from '$app/environment';
-const API = 'http://localhost:8080/api';
+const API_BASE = building ? 'http://localhost:8080/api' : '/api';
export async function load({ cookies }) {
const token = cookies.get('auth_token');
if (token) {
- const res = await fetch(`${API}/auth/verify`, {
+ const res = await fetch(`${API_BASE}/auth/verify`, {
headers: { Authorization: `Bearer ${token}` }
});
if (res.ok) {
@@ -25,7 +26,7 @@ export const actions = {
return { error: 'Preencha todos os campos' };
}
- const res = await fetch(`${API}/auth/login`, {
+ const res = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte
index a472c89..2b12a75 100644
--- a/frontend/src/routes/login/+page.svelte
+++ b/frontend/src/routes/login/+page.svelte
@@ -1,5 +1,53 @@
<script lang="ts">
- let { form }: { form?: { error?: string } } = $props();
+ import { onMount } from 'svelte';
+ import { goto } from '$app/navigation';
+
+ let errorMsg = $state('');
+ let loading = $state(false);
+
+ async function handleSubmit(e: Event) {
+ e.preventDefault();
+ loading = true;
+ errorMsg = '';
+ const formData = new FormData(e.target as HTMLFormElement);
+ try {
+ const res = await fetch('/api/auth/login', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ username: formData.get('username'),
+ password: formData.get('password')
+ })
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ errorMsg = err.error || 'Credenciais inválidas';
+ return;
+ }
+ const { token } = await res.json();
+ document.cookie = `auth_token=${token}; path=/; max-age=${7 * 24 * 60 * 60}; SameSite=Lax`;
+ goto('/admin');
+ } catch {
+ errorMsg = 'Erro de conexão com o servidor';
+ } finally {
+ loading = false;
+ }
+ }
+
+ onMount(async () => {
+ const hasToken = document.cookie.split(';').some(c => c.trim().startsWith('auth_token='));
+ if (!hasToken) return;
+ try {
+ const res = await fetch('/api/auth/verify', {
+ headers: { Authorization: `Bearer ${getCookie('auth_token')}` }
+ });
+ if (res.ok) goto('/admin');
+ } catch { /* ignore */ }
+ });
+
+ function getCookie(name: string) {
+ return document.cookie.split('; ').find(r => r.startsWith(name + '='))?.split('=')[1] ?? '';
+ }
</script>
<svelte:head>
@@ -14,7 +62,7 @@
</div>
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg-card)] p-6">
- <form method="POST" class="space-y-4">
+ <form onsubmit={handleSubmit} class="space-y-4">
<div>
<label for="username" class="mb-1.5 block text-xs font-medium text-[var(--text-secondary)]">Usuário</label>
<input
@@ -41,15 +89,16 @@
/>
</div>
- {#if form?.error}
- <p class="rounded-lg bg-[var(--red)]/5 px-3 py-2 text-xs text-[var(--red)]">{form.error}</p>
+ {#if errorMsg}
+ <p class="rounded-lg bg-[var(--red)]/5 px-3 py-2 text-xs text-[var(--red)]">{errorMsg}</p>
{/if}
<button
type="submit"
- class="w-full rounded-xl border border-[var(--green)]/50 bg-[var(--green)]/10 px-4 py-2.5 text-sm font-medium text-[var(--green)] transition-all hover:bg-[var(--green)]/20"
+ disabled={loading}
+ class="w-full rounded-xl border border-[var(--green)]/50 bg-[var(--green)]/10 px-4 py-2.5 text-sm font-medium text-[var(--green)] transition-all hover:bg-[var(--green)]/20 disabled:opacity-50"
>
- Entrar
+ {loading ? 'Entrando…' : 'Entrar'}
</button>
</form>
</div>
diff --git a/frontend/src/routes/status/[id]/+page.ts b/frontend/src/routes/status/[id]/+page.ts
index 13a72b1..6e77c6e 100644
--- a/frontend/src/routes/status/[id]/+page.ts
+++ b/frontend/src/routes/status/[id]/+page.ts
@@ -4,10 +4,10 @@ export const load: PageLoad = async ({ params, fetch }) => {
const id = params.id;
const [servicesRes, historyRes, statsRes, sslRes] = await Promise.all([
- fetch(`http://localhost:8080/api/services`).catch(() => null),
- fetch(`http://localhost:8080/api/services/${id}/history`).catch(() => null),
- fetch(`http://localhost:8080/api/services/${id}/stats`).catch(() => null),
- fetch(`http://localhost:8080/api/services/${id}/ssl`).catch(() => null)
+ fetch(`/api/services`).catch(() => null),
+ fetch(`/api/services/${id}/history`).catch(() => null),
+ fetch(`/api/services/${id}/stats`).catch(() => null),
+ fetch(`/api/services/${id}/ssl`).catch(() => null)
]);
const services = servicesRes?.ok ? await servicesRes.json() : [];
diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js
index fcf06bd..0d48f92 100644
--- a/frontend/svelte.config.js
+++ b/frontend/svelte.config.js
@@ -1,11 +1,13 @@
-import adapter from '@sveltejs/adapter-auto';
+import adapterStatic from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
- adapter: adapter()
+ adapter: adapterStatic({
+ fallback: '200.html'
+ })
}
};
diff --git a/scripts/build-prod.ps1 b/scripts/build-prod.ps1
new file mode 100644
index 0000000..b4c7107
--- /dev/null
+++ b/scripts/build-prod.ps1
@@ -0,0 +1,27 @@
+#!/usr/bin/env pwsh
+$ErrorActionPreference = "Stop"
+$ROOT = Split-Path -Parent $PSScriptRoot
+
+Write-Host "=== Building YAUM single binary ===" -ForegroundColor Green
+Write-Host ""
+
+Write-Host "1. Building frontend..." -ForegroundColor Yellow
+Set-Location "$ROOT/frontend"
+npm ci
+npm run build
+
+Write-Host ""
+Write-Host "2. Copying frontend build to Go embed directory..." -ForegroundColor Yellow
+Remove-Item -Path "$ROOT/backend/internal/static/build" -Recurse -Force -ErrorAction SilentlyContinue
+Copy-Item -Path "$ROOT/frontend/build" -Destination "$ROOT/backend/internal/static/build" -Recurse -Force
+
+Write-Host ""
+Write-Host "3. Building Go binary..." -ForegroundColor Yellow
+Set-Location "$ROOT/backend"
+go build -o yaum.exe ./cmd/server/
+
+Write-Host ""
+Write-Host "=== Done! Binary at backend\yaum.exe ===" -ForegroundColor Green
+Write-Host ""
+Write-Host "Run with:" -ForegroundColor Cyan
+Write-Host " cd backend && .\yaum.exe" -ForegroundColor White
diff --git a/scripts/build-prod.sh b/scripts/build-prod.sh
new file mode 100644
index 0000000..d356034
--- /dev/null
+++ b/scripts/build-prod.sh
@@ -0,0 +1,29 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+ROOT="$DIR/.."
+
+echo "=== Building YAUM single binary ==="
+
+echo ""
+echo "1. Building frontend..."
+cd "$ROOT/frontend"
+npm ci
+npm run build
+
+echo ""
+echo "2. Copying frontend build to Go embed directory..."
+rm -rf "$ROOT/backend/internal/static/build"
+cp -r "$ROOT/frontend/build" "$ROOT/backend/internal/static/build"
+
+echo ""
+echo "3. Building Go binary..."
+cd "$ROOT/backend"
+go build -o yaum ./cmd/server/
+
+echo ""
+echo "=== Done! Binary at backend/yaum ==="
+echo ""
+echo "Run with:"
+echo " cd backend && ./yaum"