8e08c73f60
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
168 lines
3.9 KiB
Go
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
|
|
}
|