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
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user