Cron tab: crontabs, cron.d, user crontabs, run-parts dirs and systemd timers in one list; next/last run, run now, enable/disable, in-place editing, cron log and output file

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-17 16:21:35 -07:00
co-authored by Claude Fable 5.1
parent bc891b1042
commit 41c2f05535
12 changed files with 1415 additions and 9 deletions
+126 -2
View File
@@ -19,6 +19,7 @@ import (
"hamlab/internal/auth"
"hamlab/internal/config"
"hamlab/internal/cron"
"hamlab/internal/metrics"
"hamlab/internal/services"
"hamlab/internal/store"
@@ -35,6 +36,7 @@ type Server struct {
metrics *metrics.Collector
store *store.Store
svc *services.Manager
cron *cron.Manager
version string
started time.Time
user string
@@ -43,9 +45,9 @@ type Server struct {
mux *http.ServeMux
}
func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *metrics.Collector, st *store.Store, svc *services.Manager, ui fs.FS, version string) *Server {
func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *metrics.Collector, st *store.Store, svc *services.Manager, cr *cron.Manager, ui fs.FS, version string) *Server {
host, _ := os.Hostname()
s := &Server{cfg: cfg, sessions: sessions, mgr: mgr, metrics: col, store: st, svc: svc, ui: ui, version: version,
s := &Server{cfg: cfg, sessions: sessions, mgr: mgr, metrics: col, store: st, svc: svc, cron: cr, ui: ui, version: version,
started: time.Now(), host: host, user: os.Getenv("USER")}
if u, err := user.Current(); err == nil && u.Username != "" {
s.user = u.Username
@@ -76,6 +78,14 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
m.HandleFunc("GET /api/services/{unit}/logs", s.auth(s.serviceLogs))
m.HandleFunc("GET /api/services/{unit}/logs/tail", s.auth(s.serviceTail))
m.HandleFunc("POST /api/services/{unit}/{action}", s.auth(s.serviceAction))
m.HandleFunc("GET /api/cron", s.auth(s.cronOverview))
m.HandleFunc("GET /api/cron/source", s.auth(s.cronSource))
m.HandleFunc("PUT /api/cron/source", s.auth(s.cronWriteSource))
m.HandleFunc("GET /api/cron/jobs/{id}/logs", s.auth(s.cronJobLogs))
m.HandleFunc("GET /api/cron/jobs/{id}/output", s.auth(s.cronJobOutput))
m.HandleFunc("POST /api/cron/jobs/{id}/{action}", s.auth(s.cronJobAction))
m.HandleFunc("GET /api/cron/runs/{id}", s.auth(s.cronRun))
m.HandleFunc("POST /api/cron/timers/{unit}/{action}", s.auth(s.cronTimerAction))
m.HandleFunc("GET /api/prefs/dashboard", s.auth(s.getDashPrefs))
m.HandleFunc("PUT /api/prefs/dashboard", s.auth(s.putDashPrefs))
m.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) { jsonErr(w, 404, "not found") })
@@ -311,6 +321,120 @@ func (s *Server) serviceAction(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(204)
}
// ---- cron (crontabs + systemd timers) ----
func (s *Server) cronOverview(w http.ResponseWriter, r *http.Request) {
jsonOK(w, 200, s.cron.Overview(r.Context()))
}
func (s *Server) cronSource(w http.ResponseWriter, r *http.Request) {
text, editable, err := s.cron.Source(r.Context(), r.URL.Query().Get("name"))
if err != nil {
jsonErr(w, 404, err.Error())
return
}
jsonOK(w, 200, map[string]any{"text": text, "editable": editable})
}
func (s *Server) cronWriteSource(w http.ResponseWriter, r *http.Request) {
var body struct {
Text string `json:"text"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
jsonErr(w, 400, "bad request")
return
}
name := r.URL.Query().Get("name")
if err := s.cron.WriteSource(r.Context(), name, body.Text); err != nil {
jsonErr(w, 400, err.Error())
return
}
log.Printf("cron: wrote %s (from %s)", name, clientIP(r))
w.WriteHeader(204)
}
func (s *Server) cronJob(w http.ResponseWriter, r *http.Request) (cron.Job, bool) {
j, ok := s.cron.FindJob(r.Context(), r.PathValue("id"))
if !ok {
jsonErr(w, 404, "no such job (crontab may have changed; reload)")
}
return j, ok
}
func (s *Server) cronJobLogs(w http.ResponseWriter, r *http.Request) {
j, ok := s.cronJob(w, r)
if !ok {
return
}
entries, err := s.cron.JobLogs(r.Context(), j, logCount(r))
if err != nil {
jsonErr(w, 500, err.Error())
return
}
jsonOK(w, 200, entries)
}
func (s *Server) cronJobOutput(w http.ResponseWriter, r *http.Request) {
j, ok := s.cronJob(w, r)
if !ok {
return
}
out, err := s.cron.OutputTail(r.Context(), j, logCount(r))
if err != nil {
jsonErr(w, 400, err.Error())
return
}
jsonOK(w, 200, map[string]string{"file": j.OutFile, "text": out})
}
func (s *Server) cronJobAction(w http.ResponseWriter, r *http.Request) {
j, ok := s.cronJob(w, r)
if !ok {
return
}
action := r.PathValue("action")
var err error
switch action {
case "enable", "disable":
err = s.cron.Toggle(r.Context(), j, action == "enable")
case "run":
var run *cron.Run
if run, err = s.cron.StartRun(r.Context(), j); err == nil {
log.Printf("cron: run now %q as %s (from %s)", j.Command, j.User, clientIP(r))
jsonOK(w, 202, run.Snapshot())
return
}
default:
jsonErr(w, 400, "unknown action")
return
}
if err != nil {
jsonErr(w, 400, err.Error())
return
}
log.Printf("cron: %s %q in %s (from %s)", action, j.Command, j.Source, clientIP(r))
w.WriteHeader(204)
}
func (s *Server) cronRun(w http.ResponseWriter, r *http.Request) {
run, ok := s.cron.GetRun(r.PathValue("id"))
if !ok {
jsonErr(w, 404, "no such run")
return
}
jsonOK(w, 200, run.Snapshot())
}
func (s *Server) cronTimerAction(w http.ResponseWriter, r *http.Request) {
unit, action := r.PathValue("unit"), r.PathValue("action")
if err := s.cron.TimerAction(r.Context(), unit, action); err != nil {
jsonErr(w, 400, err.Error())
return
}
log.Printf("timer %s %s ok (from %s)", action, unit, clientIP(r))
w.WriteHeader(204)
}
// ---- dashboard layout prefs (opaque JSON object, owned by the UI) ----
func (s *Server) getDashPrefs(w http.ResponseWriter, _ *http.Request) {