diff options
| -rw-r--r-- | .gitignore | 4 | ||||
| -rw-r--r-- | Dockerfile | 61 | ||||
| -rw-r--r-- | README.md | 241 | ||||
| -rw-r--r-- | backend/internal/api/router.go | 83 | ||||
| -rw-r--r-- | backend/internal/static/static.go | 43 | ||||
| -rw-r--r-- | frontend/package-lock.json | 11 | ||||
| -rw-r--r-- | frontend/package.json | 1 | ||||
| -rw-r--r-- | frontend/src/hooks.server.js | 5 | ||||
| -rw-r--r-- | frontend/src/routes/(admin)/admin/+page.server.js | 8 | ||||
| -rw-r--r-- | frontend/src/routes/(admin)/admin/+page.svelte | 49 | ||||
| -rw-r--r-- | frontend/src/routes/+layout.svelte | 2 | ||||
| -rw-r--r-- | frontend/src/routes/login/+page.server.js | 7 | ||||
| -rw-r--r-- | frontend/src/routes/login/+page.svelte | 61 | ||||
| -rw-r--r-- | frontend/src/routes/status/[id]/+page.ts | 8 | ||||
| -rw-r--r-- | frontend/svelte.config.js | 6 | ||||
| -rw-r--r-- | scripts/build-prod.ps1 | 27 | ||||
| -rw-r--r-- | scripts/build-prod.sh | 29 |
17 files changed, 616 insertions, 30 deletions
@@ -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" |