Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added
- Optional operator auth: `GANTRY_API_TOKEN` / `-api-token`, reverse-proxy identity headers, dashboard token prompt, SSE `access_token` query support
- Per-rule cron schedules (`schedule_cron` / `schedule_enabled`) with in-process scheduler, overlap skip, next/last run fields

### Changed
- Multi-arch Docker builds cross-compile Go on the host platform instead of QEMU-emulating arm64 (much faster image builds)
Expand Down
8 changes: 4 additions & 4 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,10 @@ Goal: make network-exposed and always-on deployments safer and more operationall
- [ ] Audit that secrets never appear in logs, SSE, or dry-run payloads

### M5.3 Scheduled sync
- [ ] Cron expression (or simple interval) per `SyncRule`
- [ ] Enable/disable schedule without deleting the rule
- [ ] In-process scheduler; skip overlapping runs for the same rule
- [ ] Surface next run time + last scheduled job in API/UI
- [x] Cron expression (or simple interval) per `SyncRule`
- [x] Enable/disable schedule without deleting the rule
- [x] In-process scheduler; skip overlapping runs for the same rule
- [x] Surface next run time + last scheduled job in API/UI

### M5.4 Ops polish
- [ ] Graceful job cancel from UI (API already has cancel path — wire end-to-end)
Expand Down

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion frontend/dist/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Gantry — S3 Sync Engine</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚙️</text></svg>" />
<script type="module" crossorigin src="/assets/index-CLcPRRIt.js"></script>
<script type="module" crossorigin src="/assets/index-BqX_e6Y2.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-l4dKELjr.css">
</head>
<body class="bg-surface-900 text-slate-100">
Expand Down
15 changes: 15 additions & 0 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ const emptyRule = {
delete_on_target: false,
concurrency_limit: 4,
bandwidth_limit_kbps: 0,
schedule_cron: '',
schedule_enabled: false,
}

export default function App() {
Expand Down Expand Up @@ -702,6 +704,16 @@ export default function App() {
<input type="checkbox" checked={ruleForm.delete_on_target} onChange={(e) => setRuleForm({ ...ruleForm, delete_on_target: e.target.checked })} />
Strict mirror (delete on target)
</label>
<div className="grid grid-cols-2 gap-3">
<label className="block text-xs text-slate-400 col-span-2">
Schedule cron (5-field, e.g. */15 * * * *)
<input className="field" placeholder="leave empty for manual only" value={ruleForm.schedule_cron} onChange={(e) => setRuleForm({ ...ruleForm, schedule_cron: e.target.value })} />
</label>
<label className="flex items-center gap-2 text-sm text-slate-300 col-span-2">
<input type="checkbox" checked={ruleForm.schedule_enabled} onChange={(e) => setRuleForm({ ...ruleForm, schedule_enabled: e.target.checked })} />
Enable schedule
</label>
</div>
<button type="submit" disabled={busy} className="w-full rounded-md bg-blue-600 hover:bg-blue-500 py-2 text-sm font-medium">
Save rule
</button>
Expand All @@ -721,6 +733,9 @@ export default function App() {
</div>
<div className="text-[11px] text-slate-500 mt-0.5">
{r.delete_on_target ? 'Mirror' : 'Safe sync'} · concurrency {r.concurrency_limit}
{r.schedule_enabled && r.schedule_cron
? ` · cron ${r.schedule_cron}${r.next_run_at ? ` · next ${new Date(r.next_run_at).toLocaleString()}` : ''}`
: ''}
</div>
</div>
<div className="flex flex-col gap-1">
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ require (
github.com/gin-gonic/gin v1.12.0
github.com/glebarez/sqlite v1.11.0
github.com/google/uuid v1.6.0
github.com/robfig/cron/v3 v3.0.1
golang.org/x/time v0.15.0
gorm.io/gorm v1.31.2
)
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRC
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
Expand Down
20 changes: 15 additions & 5 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/ArianAr/Gantry/internal/version"
"github.com/ArianAr/Gantry/pkg/api"
"github.com/ArianAr/Gantry/pkg/db"
"github.com/ArianAr/Gantry/pkg/schedule"
)

//go:embed all:frontend/dist
Expand Down Expand Up @@ -50,11 +51,18 @@ func main() {
Token: *apiToken,
TrustProxyHeaders: *trustProxy,
}
router, _ := api.NewRouter(api.Options{
DB: database,
StaticFS: staticRoot,
Auth: auth,

sched := schedule.New(database, nil) // engine set after router construction
router, apiSrv := api.NewRouter(api.Options{
DB: database,
StaticFS: staticRoot,
Auth: auth,
Scheduler: sched,
})
sched.Engine = apiSrv.Engine
rootCtx, rootCancel := context.WithCancel(context.Background())
defer rootCancel()
sched.Start(rootCtx)

srv := &http.Server{
Addr: *addr,
Expand All @@ -71,7 +79,7 @@ func main() {
} else if auth.TrustProxyHeaders {
authMode = "proxy-headers"
}
log.Printf("Gantry %s listening on %s (db=%s, auth=%s)", version.Version, *addr, *dbPath, authMode)
log.Printf("Gantry %s listening on %s (db=%s, auth=%s, scheduler=on)", version.Version, *addr, *dbPath, authMode)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("server: %v", err)
}
Expand All @@ -81,6 +89,8 @@ func main() {
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop

rootCancel()
sched.Stop()
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
log.Printf("shutting down...")
Expand Down
40 changes: 37 additions & 3 deletions pkg/api/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,18 @@ import (
"gorm.io/gorm"
)

// CronValidator validates and computes next cron fire times (optional).
type CronValidator interface {
ValidateCron(expr string) error
NextRun(expr string, from time.Time) (*time.Time, error)
}

// Server holds API dependencies.
type Server struct {
DB *db.DB
Engine *s3.Engine
Hub *Hub
DB *db.DB
Engine *s3.Engine
Hub *Hub
Scheduler CronValidator // optional; used for schedule_cron validation
}

// RegisterAPI mounts REST routes on the given engine group (typically /api).
Expand Down Expand Up @@ -168,6 +175,8 @@ type ruleRequest struct {
DeleteOnTarget bool `json:"delete_on_target"`
ConcurrencyLimit int `json:"concurrency_limit"`
BandwidthLimitKbps int `json:"bandwidth_limit_kbps"`
ScheduleCron string `json:"schedule_cron"`
ScheduleEnabled bool `json:"schedule_enabled"`
}

func (s *Server) listRules(c *gin.Context) {
Expand Down Expand Up @@ -201,6 +210,8 @@ func (s *Server) createOrUpdateRule(c *gin.Context) {
DeleteOnTarget: req.DeleteOnTarget,
ConcurrencyLimit: req.ConcurrencyLimit,
BandwidthLimitKbps: req.BandwidthLimitKbps,
ScheduleCron: req.ScheduleCron,
ScheduleEnabled: req.ScheduleEnabled,
}
if req.ModifiedAfter != nil && *req.ModifiedAfter != "" {
t, err := time.Parse(time.RFC3339, *req.ModifiedAfter)
Expand All @@ -211,6 +222,29 @@ func (s *Server) createOrUpdateRule(c *gin.Context) {
utc := t.UTC()
rule.ModifiedAfter = &utc
}
if s.Scheduler != nil {
if err := s.Scheduler.ValidateCron(rule.ScheduleCron); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid schedule_cron: " + err.Error()})
return
}
if rule.ScheduleEnabled && rule.ScheduleCron != "" {
next, err := s.Scheduler.NextRun(rule.ScheduleCron, time.Now().UTC())
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid schedule_cron: " + err.Error()})
return
}
rule.NextRunAt = next
} else {
rule.NextRunAt = nil
rule.ScheduleEnabled = false
}
}
// Preserve LastScheduledAt on update
if rule.ID != "" {
if existing, err := s.DB.GetRule(rule.ID); err == nil && existing != nil {
rule.LastScheduledAt = existing.LastScheduledAt
}
}
if err := s.DB.CreateOrUpdateRule(rule); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
Expand Down
11 changes: 6 additions & 5 deletions pkg/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ import (

// Options configures the HTTP server.
type Options struct {
DB *db.DB
StaticFS fs.FS // frontend dist (may be nil in tests)
Mode string
Auth AuthConfig
DB *db.DB
StaticFS fs.FS // frontend dist (may be nil in tests)
Mode string
Auth AuthConfig
Scheduler CronValidator // optional schedule validation
}

// NewRouter builds the Gin engine with API + optional SPA static hosting.
Expand All @@ -27,7 +28,7 @@ func NewRouter(opts Options) (*gin.Engine, *Server) {

hub := NewHub()
engine := s3.NewEngine(opts.DB, hub)
srv := &Server{DB: opts.DB, Engine: engine, Hub: hub}
srv := &Server{DB: opts.DB, Engine: engine, Hub: hub, Scheduler: opts.Scheduler}

r := gin.New()
r.Use(gin.Recovery(), gin.Logger(), opts.Auth.Middleware())
Expand Down
7 changes: 6 additions & 1 deletion pkg/db/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,12 @@ type SyncRule struct {
DeleteOnTarget bool `gorm:"default:false" json:"delete_on_target"`
ConcurrencyLimit int `gorm:"default:4" json:"concurrency_limit"`
BandwidthLimitKbps int `gorm:"default:0" json:"bandwidth_limit_kbps"`
CreatedAt time.Time `json:"created_at"`
// ScheduleCron is a standard 5-field cron expression (min hour dom mon dow). Empty = no schedule.
ScheduleCron string `json:"schedule_cron"`
ScheduleEnabled bool `gorm:"default:false" json:"schedule_enabled"`
LastScheduledAt *time.Time `json:"last_scheduled_at,omitempty"`
NextRunAt *time.Time `json:"next_run_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}

// TableName returns the sync_rules table name.
Expand Down
Loading
Loading