Files
move-data-nas/internal/api/handlers_jobs.go
T
darroyo 8e08c73f60 feat: complete SyncServer implementation
Full-stack Go monolith with embedded React frontend for orchestrating
rsync-over-SSH file synchronization with Wake-on-LAN support.

Features:
- JWT auth (HS256) with bcrypt password hashing
- CRUD for machines (with WoL config) and sync_pairs
- Ed25519 SSH key generation and known_hosts management
- WoL magic packet sender + TCP-connect waiter with backoff
- Sync engine: rsync subprocess, per-pair job queue, progress parsing
- Homebrew cron parser for scheduled syncs
- SSE stream for live job status (queued/waking_up/running/success/failed)
- React+TS+Vite+Tailwind SPA embedded via embed.FS
- Debian packaging with systemd unit, postinst/prerm/postrm

Tech stack:
- Go 1.22+ (CGO_ENABLED=0, pure SQLite via modernc.org/sqlite)
- chi router for HTTP API
- TypeScript + React 18 + Tailwind CSS frontend
- Cross-compiled to Linux amd64 for Proxmox LXC deployment

Tests: wol (MAC parsing, magic packet), syncengine/queue, scheduler/cron
2026-07-07 15:03:22 -04:00

168 lines
3.9 KiB
Go

package api
import (
"database/sql"
"net/http"
"os"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/syncserver/internal/models"
"github.com/syncserver/internal/syncengine"
)
type JobHandler struct {
db *sql.DB
engine *syncengine.Engine
}
func NewJobHandler(db *sql.DB, engine *syncengine.Engine) *JobHandler {
return &JobHandler{db: db, engine: engine}
}
func (h *JobHandler) List(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
if limit <= 0 || limit > 100 {
limit = 50
}
repo := models.NewJobRepository(h.db)
jobs, err := repo.GetAll(limit, offset)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to fetch jobs")
return
}
out := make([]JobResponse, len(jobs))
for i, j := range jobs {
out[i] = jobToResp(j)
}
writeJSON(w, out)
}
func (h *JobHandler) Get(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
repo := models.NewJobRepository(h.db)
j, err := repo.GetByID(id)
if err == sql.ErrNoRows {
writeError(w, http.StatusNotFound, "job not found")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to fetch job")
return
}
writeJSON(w, jobToResp(*j))
}
func (h *JobHandler) Cancel(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
repo := models.NewJobRepository(h.db)
j, err := repo.GetByID(id)
if err == sql.ErrNoRows {
writeError(w, http.StatusNotFound, "job not found")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to fetch job")
return
}
if j.Status != "queued" && j.Status != "waking_up" && j.Status != "running" {
writeError(w, http.StatusBadRequest, "job is not cancellable")
return
}
if h.engine != nil {
h.engine.Cancel(id, j.SyncPairID)
}
repo.UpdateStatus(id, "cancelled")
writeJSON(w, map[string]string{"status": "cancelled"})
}
func (h *JobHandler) TriggerRun(w http.ResponseWriter, r *http.Request) {
pairID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
if h.engine == nil {
writeError(w, http.StatusInternalServerError, "engine not available")
return
}
jobID, err := h.engine.CreateJob(pairID, "manual")
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create job")
return
}
go func() {
h.engine.Run(r.Context(), jobID, pairID)
}()
jobRepo := models.NewJobRepository(h.db)
j, _ := jobRepo.GetByID(jobID)
writeJSON(w, jobToResp(*j), http.StatusCreated)
}
func (h *JobHandler) StreamLog(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
flusher, ok := w.(http.Flusher)
if !ok {
writeError(w, http.StatusInternalServerError, "streaming not supported")
return
}
w.Header().Set("Content-Type", "text/plain")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
flusher.Flush()
jobRepo := models.NewJobRepository(h.db)
j, err := jobRepo.GetByID(id)
if err == nil && j.LogFile != nil {
data, _ := os.ReadFile(*j.LogFile)
w.Write(data)
flusher.Flush()
}
}
func jobToResp(j models.Job) JobResponse {
resp := JobResponse{
ID: j.ID,
SyncPairID: j.SyncPairID,
TriggerType: j.TriggerType,
Status: j.Status,
LogFile: j.LogFile,
}
if j.StartedAt != nil {
s := j.StartedAt.Format(time.RFC3339)
resp.StartedAt = &s
}
if j.FinishedAt != nil {
s := j.FinishedAt.Format(time.RFC3339)
resp.FinishedAt = &s
}
return resp
}