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
+13 -2
View File
@@ -123,8 +123,19 @@ something long in one, switch away and back, refresh — it's all still there.
`tmux new -A -s hamlab-<id>`; needs `mouse on` and status-bar handling in xterm. `tmux new -A -s hamlab-<id>`; needs `mouse on` and status-bar handling in xterm.
- **Services tab** — `systemctl list-units`, per-unit page: status, last 200 journal - **Services tab** — `systemctl list-units`, per-unit page: status, last 200 journal
lines (live-tail via WS), start/stop/restart/enable buttons, unit file viewer. lines (live-tail via WS), start/stop/restart/enable buttons, unit file viewer.
- **Cron tab** — parse `/etc/crontab`, `/etc/cron.d/*`, user crontabs, and systemd - **Cron tab** — **shipped 2026-09-17** (`internal/cron`, `Cron.tsx`): one list built
timers into one table with next-run times; edit via `crontab -` round-trip. from `/etc/crontab`, `/etc/cron.d/*`, every user crontab (via sudo), the scripts in
the `run-parts` directories those drive, plus systemd timers. Next run computed by an
in-house cron-expression evaluator (tested: ranges, steps, names, dom-OR-dow rule,
`@` aliases); last run from the journal's `(user) CMD (command)` lines (cron logs via
syslog to journald; reading needs sudo/adm). Commented-out jobs show as disabled;
job-shaped lines inside comment blocks (Debian's header examples) are ignored. Per
job: Run now (background, as the job's user, `sh -c` with crontab `%` rules, output
captured), Enable/Disable (comments the line in place, refuses if the file changed
underneath), Edit source (textarea; validated line-by-line; user crontabs installed
with `crontab -`, system files written via `sudo tee`), Cron log, and Output file
when the command redirects to one. Timers: Run now (starts the activated service),
start/stop/enable/disable, live logs of the service via the Services tab's tail.
- **ZFS tab** — pools, datasets (`used/avail/refer/usedbysnapshots`), snapshot list, - **ZFS tab** — pools, datasets (`used/avail/refer/usedbysnapshots`), snapshot list,
scrub status, and the `freeing` counter. `grid` is the target, which implies scrub status, and the `freeing` counter. `grid` is the target, which implies
multi-host. multi-host.
+4 -1
View File
@@ -21,6 +21,7 @@ import (
"hamlab/internal/api" "hamlab/internal/api"
"hamlab/internal/auth" "hamlab/internal/auth"
"hamlab/internal/config" "hamlab/internal/config"
"hamlab/internal/cron"
"hamlab/internal/metrics" "hamlab/internal/metrics"
"hamlab/internal/services" "hamlab/internal/services"
"hamlab/internal/store" "hamlab/internal/store"
@@ -142,7 +143,9 @@ func serve(cfg *config.Config) error {
col := metrics.NewCollector() col := metrics.NewCollector()
go col.Run(context.Background()) go col.Run(context.Background())
srv := api.New(cfg, sessions, mgr, col, st, services.New(), web.FS(), version) svc := services.New()
cr := cron.New(svc.Sudo(), svc.JournalSudo())
srv := api.New(cfg, sessions, mgr, col, st, svc, cr, web.FS(), version)
hs := &http.Server{ hs := &http.Server{
Addr: cfg.Listen, Addr: cfg.Listen,
Handler: srv.Handler(), Handler: srv.Handler(),
+126 -2
View File
@@ -19,6 +19,7 @@ import (
"hamlab/internal/auth" "hamlab/internal/auth"
"hamlab/internal/config" "hamlab/internal/config"
"hamlab/internal/cron"
"hamlab/internal/metrics" "hamlab/internal/metrics"
"hamlab/internal/services" "hamlab/internal/services"
"hamlab/internal/store" "hamlab/internal/store"
@@ -35,6 +36,7 @@ type Server struct {
metrics *metrics.Collector metrics *metrics.Collector
store *store.Store store *store.Store
svc *services.Manager svc *services.Manager
cron *cron.Manager
version string version string
started time.Time started time.Time
user string user string
@@ -43,9 +45,9 @@ type Server struct {
mux *http.ServeMux 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() 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")} started: time.Now(), host: host, user: os.Getenv("USER")}
if u, err := user.Current(); err == nil && u.Username != "" { if u, err := user.Current(); err == nil && u.Username != "" {
s.user = 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", s.auth(s.serviceLogs))
m.HandleFunc("GET /api/services/{unit}/logs/tail", s.auth(s.serviceTail)) 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("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("GET /api/prefs/dashboard", s.auth(s.getDashPrefs))
m.HandleFunc("PUT /api/prefs/dashboard", s.auth(s.putDashPrefs)) m.HandleFunc("PUT /api/prefs/dashboard", s.auth(s.putDashPrefs))
m.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) { jsonErr(w, 404, "not found") }) 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) 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) ---- // ---- dashboard layout prefs (opaque JSON object, owned by the UI) ----
func (s *Server) getDashPrefs(w http.ResponseWriter, _ *http.Request) { func (s *Server) getDashPrefs(w http.ResponseWriter, _ *http.Request) {
+761
View File
@@ -0,0 +1,761 @@
// Package cron reads every scheduled job on the host into one list: /etc/crontab,
// /etc/cron.d/*, per-user crontabs, the run-parts directories they drive, and systemd
// timers. It computes next runs from the cron expressions, pulls last runs from the
// journal (cron logs "(user) CMD (command)" via syslog), and can toggle, edit and run
// jobs. Reads/writes that need root go through passwordless sudo when available.
package cron
import (
"bufio"
"bytes"
"context"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"os/user"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
)
type Job struct {
ID string `json:"id"`
Source string `json:"source"` // "/etc/crontab", "/etc/cron.d/x", "crontab:user", "/etc/cron.daily"
Line int `json:"line"` // 1-based line in Source (0 for run-parts scripts)
User string `json:"user"`
Schedule string `json:"schedule"`
Command string `json:"command"`
Enabled bool `json:"enabled"`
Next int64 `json:"next,omitempty"` // unix ms
Last int64 `json:"last,omitempty"`
OutFile string `json:"out_file,omitempty"` // parsed from a ">> file" redirect
Note string `json:"note,omitempty"`
Editable bool `json:"editable"`
raw string
}
type Timer struct {
Unit string `json:"unit"`
Activates string `json:"activates"`
Description string `json:"description"`
Active string `json:"active"`
Sub string `json:"sub"`
Enabled string `json:"enabled"`
Next int64 `json:"next,omitempty"`
Last int64 `json:"last,omitempty"`
}
type Overview struct {
Jobs []Job `json:"jobs"`
Timers []Timer `json:"timers"`
Logging bool `json:"logging"` // cron lines seen in the journal
Anacron bool `json:"anacron"`
Sudo bool `json:"sudo"`
}
type Run struct {
ID string `json:"id"`
JobID string `json:"job_id"`
Command string `json:"command"`
User string `json:"user"`
Started time.Time `json:"started"`
Finished time.Time `json:"finished,omitempty"`
Running bool `json:"running"`
ExitCode int `json:"exit_code"`
Output string `json:"output"`
mu sync.Mutex
buf bytes.Buffer
}
type Manager struct {
sudo bool
journalSudo bool
self string
mu sync.Mutex
lastRuns map[string]int64 // user\x00command -> unix ms
lastAt time.Time
logging bool
runs map[string]*Run
}
func New(sudo, journalSudo bool) *Manager {
m := &Manager{sudo: sudo, journalSudo: journalSudo, runs: map[string]*Run{}}
if u, err := user.Current(); err == nil {
m.self = u.Username
}
return m
}
// ---- helpers ----
func run(ctx context.Context, stdin string, args ...string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
if stdin != "" {
cmd.Stdin = strings.NewReader(stdin)
}
var out, errb bytes.Buffer
cmd.Stdout, cmd.Stderr = &out, &errb
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(errb.String())
if msg == "" {
msg = err.Error()
}
return out.String(), errors.New(msg)
}
return out.String(), nil
}
func (m *Manager) sudoArgs(args ...string) []string {
if m.sudo {
return append([]string{"sudo", "-n"}, args...)
}
return args
}
func jobID(source, raw string) string {
h := sha1.Sum([]byte(source + "\n" + raw))
return hex.EncodeToString(h[:6])
}
var userRe = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,31}$`)
var cronDName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) // cron ignores other names in /etc/cron.d
// ---- parsing ----
// parseLine splits one crontab line. withUser is true for system crontabs (6th field is
// the user). Returns ok=false for comments, blanks, env assignments and garbage.
func parseLine(line string, withUser bool) (schedule, usr, command string, ok bool) {
t := strings.TrimSpace(line)
if t == "" || strings.HasPrefix(t, "#") {
return
}
if i := strings.Index(t, "="); i > 0 && !strings.ContainsAny(t[:i], " \t*") { // NAME=value
return
}
var fields []string
if strings.HasPrefix(t, "@") {
f := strings.Fields(t)
if len(f) < 2 {
return
}
fields = []string{f[0]}
t = strings.TrimSpace(t[len(f[0]):])
} else {
f := strings.Fields(t)
if len(f) < 6 {
return
}
fields = f[:5]
for _, x := range fields {
t = strings.TrimSpace(t[len(x):])
}
}
schedule = strings.Join(fields, " ")
if _, err := parseSchedule(schedule); err != nil {
return
}
if withUser {
f := strings.Fields(t)
if len(f) < 2 || !userRe.MatchString(f[0]) {
return
}
usr = f[0]
t = strings.TrimSpace(t[len(f[0]):])
}
if t == "" {
return
}
return schedule, usr, t, true
}
var redirectRe = regexp.MustCompile(`(?:^|\s)(?:>>|>|2>&1\s*>>|&>>|&>)\s*("?)([^\s"&|;]+)`)
func outFile(cmd string) string {
for _, m := range redirectRe.FindAllStringSubmatch(cmd, -1) {
if p := m[2]; strings.HasPrefix(p, "/") && p != "/dev/null" {
return p
}
}
return ""
}
// parseSource turns crontab text into jobs. Commented-out lines that still parse as a
// job are reported disabled, so a "# 0 5 * * * ..." example shows up as such.
func (m *Manager) parseSource(source, text string, withUser, editable bool, defaultUser string, now time.Time, anacron bool) []Job {
var jobs []Job
lines := strings.Split(text, "\n")
isComment := func(i int) bool {
return i >= 0 && i < len(lines) && strings.HasPrefix(strings.TrimSpace(lines[i]), "#")
}
for i, line := range lines {
enabled := true
probe := line
if t := strings.TrimSpace(line); strings.HasPrefix(t, "#") {
// A commented-out job sits on its own; a job-shaped line inside a block of
// comments is documentation (Debian's stock header has two such examples).
if isComment(i-1) && isComment(i+1) {
continue
}
enabled = false
probe = strings.TrimLeft(t, "# ")
}
schedule, usr, command, ok := parseLine(probe, withUser)
if !ok {
continue
}
if usr == "" {
usr = defaultUser
}
if !enabled {
if _, err := user.Lookup(usr); err != nil {
continue // "user-name" placeholder and the like
}
}
j := Job{ID: jobID(source, line), Source: source, Line: i + 1, User: usr, Schedule: schedule, Command: command,
Enabled: enabled, OutFile: outFile(command), Editable: editable, raw: line}
if enabled {
if next, ok := nextRun(schedule, now); ok {
j.Next = next.UnixMilli()
}
}
if strings.Contains(command, "run-parts") {
if dir := runPartsDir(command); dir != "" {
if anacron && strings.Contains(command, "anacron") {
j.Note = "skipped: anacron is installed and runs " + dir + " instead"
}
jobs = append(jobs, j)
jobs = append(jobs, m.runPartsJobs(dir, j)...)
continue
}
}
jobs = append(jobs, j)
}
return jobs
}
var runPartsRe = regexp.MustCompile(`run-parts\s+(?:--\S+\s+)*(/\S+)`)
func runPartsDir(cmd string) string {
if m := runPartsRe.FindStringSubmatch(cmd); m != nil {
return m[1]
}
return ""
}
// runPartsJobs lists the executable scripts a run-parts line will run, as read-only jobs.
func (m *Manager) runPartsJobs(dir string, parent Job) []Job {
ents, err := os.ReadDir(dir)
if err != nil {
return nil
}
var out []Job
for _, e := range ents {
if e.IsDir() || !cronDName.MatchString(e.Name()) {
continue
}
info, err := e.Info()
if err != nil || info.Mode()&0o111 == 0 {
continue
}
p := filepath.Join(dir, e.Name())
out = append(out, Job{ID: jobID(dir, e.Name()), Source: dir, User: parent.User, Schedule: parent.Schedule, Command: p,
Enabled: parent.Enabled, Next: parent.Next, Note: "run by " + parent.Source + " via run-parts", raw: e.Name()})
}
return out
}
// ---- sources ----
type source struct {
name string // job Source
withUser bool
user string // default user for user crontabs
editable bool
}
func (m *Manager) readSource(ctx context.Context, s source) (string, error) {
if strings.HasPrefix(s.name, "crontab:") {
u := s.user
if u == m.self {
out, err := run(ctx, "", "crontab", "-l")
if err != nil && strings.Contains(err.Error(), "no crontab") {
return "", nil
}
return out, err
}
if !m.sudo {
return "", errors.New("needs sudo")
}
out, err := run(ctx, "", "sudo", "-n", "crontab", "-u", u, "-l")
if err != nil && strings.Contains(err.Error(), "no crontab") {
return "", nil
}
return out, err
}
b, err := os.ReadFile(s.name)
if err != nil && m.sudo {
out, err2 := run(ctx, "", "sudo", "-n", "cat", s.name)
return out, err2
}
return string(b), err
}
func (m *Manager) sources(ctx context.Context) []source {
var out []source
if _, err := os.Stat("/etc/crontab"); err == nil {
out = append(out, source{name: "/etc/crontab", withUser: true, editable: m.sudo})
}
if ents, err := os.ReadDir("/etc/cron.d"); err == nil {
for _, e := range ents {
if !e.IsDir() && cronDName.MatchString(e.Name()) {
out = append(out, source{name: "/etc/cron.d/" + e.Name(), withUser: true, editable: m.sudo})
}
}
}
// per-user crontabs: Debian keeps them in /var/spool/cron/crontabs, cronie in /var/spool/cron
users := map[string]bool{}
if m.sudo {
for _, dir := range []string{"/var/spool/cron/crontabs", "/var/spool/cron"} {
if out, err := run(ctx, "", "sudo", "-n", "ls", "-1", dir); err == nil {
for _, n := range strings.Split(out, "\n") {
if n = strings.TrimSpace(n); userRe.MatchString(n) {
users[n] = true
}
}
break
}
}
}
if m.self != "" {
users[m.self] = true
}
names := make([]string, 0, len(users))
for u := range users {
names = append(names, u)
}
sort.Strings(names)
for _, u := range names {
out = append(out, source{name: "crontab:" + u, user: u, editable: u == m.self || m.sudo})
}
return out
}
func (m *Manager) findSource(ctx context.Context, name string) (source, bool) {
for _, s := range m.sources(ctx) {
if s.name == name {
return s, true
}
}
return source{}, false
}
// ---- overview ----
func (m *Manager) Overview(ctx context.Context) Overview {
now := time.Now()
_, anacronErr := os.Stat("/usr/sbin/anacron")
anacron := anacronErr == nil
ov := Overview{Jobs: []Job{}, Timers: []Timer{}, Anacron: anacron, Sudo: m.sudo}
for _, s := range m.sources(ctx) {
text, err := m.readSource(ctx, s)
if err != nil {
continue
}
ov.Jobs = append(ov.Jobs, m.parseSource(s.name, text, s.withUser, s.editable, s.user, now, anacron)...)
}
last := m.lastRunsFromJournal(ctx)
ov.Logging = m.logging
for i := range ov.Jobs {
j := &ov.Jobs[i]
if t, ok := last[j.User+"\x00"+j.Command]; ok {
j.Last = t
}
}
ov.Timers = m.timers(ctx)
return ov
}
var cronLogRe = regexp.MustCompile(`^\((\S+)\) CMD \((.*)\)$`)
// lastRunsFromJournal maps user+command to the most recent "(user) CMD (command)" line.
func (m *Manager) lastRunsFromJournal(ctx context.Context) map[string]int64 {
m.mu.Lock()
if time.Since(m.lastAt) < 60*time.Second && m.lastRuns != nil {
r := m.lastRuns
m.mu.Unlock()
return r
}
m.mu.Unlock()
args := []string{"journalctl", "-t", "CRON", "-t", "cron", "-t", "crond", "-o", "json", "--no-pager", "-q", "--since", "-30d", "-n", "20000"}
if m.journalSudo {
args = append([]string{"sudo", "-n"}, args...)
}
out, _ := run(ctx, "", args...)
res := map[string]int64{}
seen := false
sc := bufio.NewScanner(strings.NewReader(out))
sc.Buffer(make([]byte, 0, 64<<10), 4<<20)
for sc.Scan() {
var raw struct {
Msg any `json:"MESSAGE"`
TS string `json:"__REALTIME_TIMESTAMP"`
}
if json.Unmarshal(sc.Bytes(), &raw) != nil {
continue
}
seen = true
msg, _ := raw.Msg.(string)
mm := cronLogRe.FindStringSubmatch(msg)
if mm == nil {
continue
}
us, _ := strconv.ParseInt(raw.TS, 10, 64)
k := mm[1] + "\x00" + mm[2]
if us/1000 > res[k] {
res[k] = us / 1000
}
}
m.mu.Lock()
m.lastRuns, m.lastAt, m.logging = res, time.Now(), seen
m.mu.Unlock()
return res
}
// JobLogs returns the journal lines for one job's command (most recent last).
func (m *Manager) JobLogs(ctx context.Context, j Job, n int) ([]map[string]any, error) {
args := []string{"journalctl", "-t", "CRON", "-t", "cron", "-t", "crond", "-o", "json", "--no-pager", "-q", "-n", "20000"}
if m.journalSudo {
args = append([]string{"sudo", "-n"}, args...)
}
out, err := run(ctx, "", args...)
if err != nil {
return nil, err
}
var res []map[string]any
sc := bufio.NewScanner(strings.NewReader(out))
sc.Buffer(make([]byte, 0, 64<<10), 4<<20)
for sc.Scan() {
var raw struct {
Msg any `json:"MESSAGE"`
TS string `json:"__REALTIME_TIMESTAMP"`
PID string `json:"_PID"`
}
if json.Unmarshal(sc.Bytes(), &raw) != nil {
continue
}
msg, _ := raw.Msg.(string)
if !strings.Contains(msg, "("+j.User+") CMD ("+j.Command+")") {
continue
}
us, _ := strconv.ParseInt(raw.TS, 10, 64)
res = append(res, map[string]any{"t": us / 1000, "p": 6, "ident": "CRON", "pid": raw.PID, "msg": msg})
}
if len(res) > n {
res = res[len(res)-n:]
}
if res == nil {
res = []map[string]any{}
}
return res, nil
}
// OutputTail returns the last n lines of the job's redirect target.
func (m *Manager) OutputTail(ctx context.Context, j Job, n int) (string, error) {
if j.OutFile == "" {
return "", errors.New("job has no output file")
}
out, err := run(ctx, "", m.sudoArgs("tail", "-n", strconv.Itoa(n), j.OutFile)...)
return out, err
}
// ---- timers ----
func (m *Manager) timers(ctx context.Context) []Timer {
out, err := run(ctx, "", "systemctl", "list-timers", "--all", "--output=json", "--no-pager")
if err != nil {
return []Timer{}
}
var lt []struct {
Next int64 `json:"next"`
Last int64 `json:"last"`
Unit string `json:"unit"`
Activates string `json:"activates"`
}
_ = json.Unmarshal([]byte(out), &lt)
units, _ := run(ctx, "", "systemctl", "list-units", "--type=timer", "--all", "--output=json", "--no-pager", "--plain")
var lu []struct{ Unit, Active, Sub, Description string }
_ = json.Unmarshal([]byte(units), &lu)
meta := map[string]struct{ Active, Sub, Description string }{}
for _, u := range lu {
meta[u.Unit] = struct{ Active, Sub, Description string }{u.Active, u.Sub, u.Description}
}
files, _ := run(ctx, "", "systemctl", "list-unit-files", "--type=timer", "--output=json", "--no-pager")
var uf []struct {
UnitFile string `json:"unit_file"`
State string `json:"state"`
}
_ = json.Unmarshal([]byte(files), &uf)
enabled := map[string]string{}
for _, f := range uf {
enabled[f.UnitFile] = f.State
}
res := make([]Timer, 0, len(lt))
for _, t := range lt {
x := Timer{Unit: t.Unit, Activates: t.Activates, Enabled: enabled[t.Unit]}
if mt, ok := meta[t.Unit]; ok {
x.Active, x.Sub, x.Description = mt.Active, mt.Sub, mt.Description
}
if t.Next > 0 {
x.Next = t.Next / 1000
}
if t.Last > 0 {
x.Last = t.Last / 1000
}
res = append(res, x)
}
sort.Slice(res, func(i, j int) bool { return res[i].Unit < res[j].Unit })
return res
}
var timerRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9@._:\\-]{0,255}\.timer$`)
func (m *Manager) TimerAction(ctx context.Context, unit, action string) error {
if !timerRe.MatchString(unit) {
return errors.New("invalid timer name")
}
switch action {
case "start", "stop", "enable", "disable":
_, err := run(ctx, "", m.sudoArgs("systemctl", action, unit)...)
return err
case "run": // fire the service the timer activates, right now
out, err := run(ctx, "", "systemctl", "show", unit, "-p", "Triggers", "--value")
if err != nil {
return err
}
svc := strings.TrimSpace(strings.Split(out, " ")[0])
if svc == "" {
return errors.New("timer triggers nothing")
}
_, err = run(ctx, "", m.sudoArgs("systemctl", "start", svc)...)
return err
}
return errors.New("unknown action")
}
// ---- job lookup and editing ----
func (m *Manager) FindJob(ctx context.Context, id string) (Job, bool) {
for _, j := range m.Overview(ctx).Jobs {
if j.ID == id {
return j, true
}
}
return Job{}, false
}
// Source returns a crontab's raw text for the editor.
func (m *Manager) Source(ctx context.Context, name string) (text string, editable bool, err error) {
s, ok := m.findSource(ctx, name)
if !ok {
return "", false, errors.New("unknown source")
}
text, err = m.readSource(ctx, s)
return text, s.editable, err
}
// validate rejects any line that is neither blank, comment, env assignment, nor a job.
func validate(text string, withUser bool) error {
for i, line := range strings.Split(text, "\n") {
t := strings.TrimSpace(line)
if t == "" || strings.HasPrefix(t, "#") {
continue
}
if k, _, ok := strings.Cut(t, "="); ok && !strings.ContainsAny(strings.TrimSpace(k), " \t*") {
continue
}
if _, _, _, ok := parseLine(t, withUser); !ok {
return fmt.Errorf("line %d: not a valid cron entry", i+1)
}
}
return nil
}
// WriteSource replaces a crontab. User crontabs go through crontab(1) so cron reloads
// them; system files are written in place with their permissions preserved.
func (m *Manager) WriteSource(ctx context.Context, name, text string) error {
s, ok := m.findSource(ctx, name)
if !ok {
return errors.New("unknown source")
}
if !s.editable {
return errors.New("source is read-only here (no sudo)")
}
if !strings.HasSuffix(text, "\n") {
text += "\n"
}
if err := validate(text, s.withUser); err != nil {
return err
}
if strings.HasPrefix(name, "crontab:") {
if s.user == m.self {
_, err := run(ctx, text, "crontab", "-")
return err
}
_, err := run(ctx, text, "sudo", "-n", "crontab", "-u", s.user, "-")
return err
}
if name != "/etc/crontab" && !strings.HasPrefix(name, "/etc/cron.d/") {
return errors.New("refusing to write outside /etc/crontab and /etc/cron.d")
}
_, err := run(ctx, text, "sudo", "-n", "tee", name)
m.mu.Lock()
m.lastAt = time.Time{}
m.mu.Unlock()
return err
}
// Toggle comments or uncomments a job's line. The line must still read as the job did
// when it was listed, so a concurrent edit can't be clobbered.
func (m *Manager) Toggle(ctx context.Context, j Job, enable bool) error {
if j.Line == 0 || !j.Editable {
return errors.New("this job can't be toggled here")
}
s, ok := m.findSource(ctx, j.Source)
if !ok {
return errors.New("unknown source")
}
text, err := m.readSource(ctx, s)
if err != nil {
return err
}
lines := strings.Split(text, "\n")
if j.Line > len(lines) || lines[j.Line-1] != j.raw {
return errors.New("crontab changed underneath; reload and try again")
}
if enable {
lines[j.Line-1] = strings.TrimLeft(strings.TrimSpace(j.raw), "# ")
} else {
lines[j.Line-1] = "#" + j.raw
}
return m.WriteSource(ctx, j.Source, strings.Join(lines, "\n"))
}
// ---- run now ----
// StartRun executes the job's command as its user, the way cron would (sh -c, cwd = home,
// '%' turned into newlines/stdin), in the background. Output is kept in memory.
func (m *Manager) StartRun(ctx context.Context, j Job) (*Run, error) {
if j.User != m.self && !m.sudo {
return nil, errors.New("needs sudo to run as " + j.User)
}
cmdline, stdin := cronPercent(j.Command)
args := []string{"sh", "-c", cmdline}
if j.User != m.self {
args = append([]string{"sudo", "-n", "-u", j.User, "-H"}, args...)
}
r := &Run{ID: jobID("run", strconv.FormatInt(time.Now().UnixNano(), 10)), JobID: j.ID, Command: j.Command, User: j.User, Started: time.Now(), Running: true}
runCtx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
cmd := exec.CommandContext(runCtx, args[0], args[1:]...)
if stdin != "" {
cmd.Stdin = strings.NewReader(stdin)
}
w := &capWriter{r: r}
cmd.Stdout, cmd.Stderr = w, w
if err := cmd.Start(); err != nil {
cancel()
return nil, err
}
m.mu.Lock()
m.runs[r.ID] = r
for id, old := range m.runs { // keep the map small
if !old.Running && time.Since(old.Finished) > time.Hour {
delete(m.runs, id)
}
}
m.mu.Unlock()
go func() {
defer cancel()
err := cmd.Wait()
r.mu.Lock()
r.Running, r.Finished = false, time.Now()
if err != nil {
if ee, ok := err.(*exec.ExitError); ok {
r.ExitCode = ee.ExitCode()
} else {
r.ExitCode = -1
}
}
r.mu.Unlock()
m.mu.Lock()
m.lastAt = time.Time{}
m.mu.Unlock()
}()
return r, nil
}
func (m *Manager) GetRun(id string) (*Run, bool) {
m.mu.Lock()
r, ok := m.runs[id]
m.mu.Unlock()
return r, ok
}
// Snapshot returns a copy safe to encode.
func (r *Run) Snapshot() Run {
r.mu.Lock()
defer r.mu.Unlock()
return Run{ID: r.ID, JobID: r.JobID, Command: r.Command, User: r.User, Started: r.Started, Finished: r.Finished,
Running: r.Running, ExitCode: r.ExitCode, Output: r.buf.String()}
}
type capWriter struct{ r *Run }
func (w *capWriter) Write(p []byte) (int, error) {
w.r.mu.Lock()
defer w.r.mu.Unlock()
w.r.buf.Write(p)
if w.r.buf.Len() > 256<<10 { // keep the tail
b := w.r.buf.Bytes()
w.r.buf = *bytes.NewBuffer(append([]byte("…\n"), b[len(b)-200<<10:]...))
}
return len(p), nil
}
// cronPercent applies crontab(5) rules: an unescaped % ends the command; later ones are
// newlines fed to stdin. "\%" is a literal percent.
func cronPercent(cmd string) (string, string) {
var out, in strings.Builder
cur := &out
esc := false
for _, c := range cmd {
switch {
case esc:
if c != '%' {
cur.WriteRune('\\')
}
cur.WriteRune(c)
esc = false
case c == '\\':
esc = true
case c == '%':
if cur == &out {
cur = &in
} else {
in.WriteRune('\n')
}
default:
cur.WriteRune(c)
}
}
return out.String(), in.String()
}
+163
View File
@@ -0,0 +1,163 @@
package cron
import (
"errors"
"fmt"
"strconv"
"strings"
"time"
)
// schedule is a parsed five-field cron expression as sets.
type schedule struct {
min, hour, dom, mon, dow [64]bool
domStar, dowStar bool // "*" in dom/dow changes the match rule
reboot bool
}
var monthNames = map[string]int{"jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6, "jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12}
var dayNames = map[string]int{"sun": 0, "mon": 1, "tue": 2, "wed": 3, "thu": 4, "fri": 5, "sat": 6}
var aliases = map[string]string{
"@yearly": "0 0 1 1 *", "@annually": "0 0 1 1 *", "@monthly": "0 0 1 * *", "@weekly": "0 0 * * 0",
"@daily": "0 0 * * *", "@midnight": "0 0 * * *", "@hourly": "0 * * * *",
}
func parseSchedule(expr string) (*schedule, error) {
expr = strings.TrimSpace(expr)
if expr == "@reboot" {
return &schedule{reboot: true}, nil
}
if a, ok := aliases[strings.ToLower(expr)]; ok {
expr = a
}
f := strings.Fields(expr)
if len(f) != 5 {
return nil, errors.New("expected 5 fields")
}
s := &schedule{}
var err error
if s.min, err = parseField(f[0], 0, 59, nil); err != nil {
return nil, fmt.Errorf("minute: %w", err)
}
if s.hour, err = parseField(f[1], 0, 23, nil); err != nil {
return nil, fmt.Errorf("hour: %w", err)
}
if s.dom, err = parseField(f[2], 1, 31, nil); err != nil {
return nil, fmt.Errorf("day of month: %w", err)
}
if s.mon, err = parseField(f[3], 1, 12, monthNames); err != nil {
return nil, fmt.Errorf("month: %w", err)
}
if s.dow, err = parseField(f[4], 0, 7, dayNames); err != nil {
return nil, fmt.Errorf("day of week: %w", err)
}
if s.dow[7] { // 7 == Sunday
s.dow[0] = true
}
s.domStar = f[2] == "*"
s.dowStar = f[4] == "*"
return s, nil
}
func parseField(field string, lo, hi int, names map[string]int) ([64]bool, error) {
var set [64]bool
atom := func(s string) (int, error) {
if names != nil {
if v, ok := names[strings.ToLower(s)]; ok {
return v, nil
}
}
v, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("bad value %q", s)
}
return v, nil
}
for _, part := range strings.Split(field, ",") {
step := 1
if i := strings.Index(part, "/"); i >= 0 {
v, err := strconv.Atoi(part[i+1:])
if err != nil || v <= 0 {
return set, fmt.Errorf("bad step in %q", part)
}
step, part = v, part[:i]
}
a, b := lo, hi
switch {
case part == "*":
case strings.Contains(part, "-"):
x, y, _ := strings.Cut(part, "-")
var err error
if a, err = atom(x); err != nil {
return set, err
}
if b, err = atom(y); err != nil {
return set, err
}
default:
v, err := atom(part)
if err != nil {
return set, err
}
a = v
if step == 1 {
b = v
}
}
if a < lo || b > hi || a > b {
return set, fmt.Errorf("out of range: %q", part)
}
for v := a; v <= b; v += step {
set[v] = true
}
}
return set, nil
}
func (s *schedule) dayMatches(t time.Time) bool {
if !s.mon[int(t.Month())] {
return false
}
dom, dow := s.dom[t.Day()], s.dow[int(t.Weekday())]
switch {
case s.domStar && s.dowStar:
return true
case s.domStar:
return dow
case s.dowStar:
return dom
default: // both restricted: cron runs when either matches
return dom || dow
}
}
// nextRun finds the first time strictly after `from` that the schedule fires.
func nextRun(expr string, from time.Time) (time.Time, bool) {
s, err := parseSchedule(expr)
if err != nil || s.reboot {
return time.Time{}, false
}
t := from.Truncate(time.Minute).Add(time.Minute)
day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
for i := 0; i < 366*2; i++ {
if s.dayMatches(day) {
for h := 0; h < 24; h++ {
if !s.hour[h] {
continue
}
for mi := 0; mi < 60; mi++ {
if !s.min[mi] {
continue
}
c := time.Date(day.Year(), day.Month(), day.Day(), h, mi, 0, 0, day.Location())
if !c.Before(t) {
return c, true
}
}
}
}
day = day.AddDate(0, 0, 1)
}
return time.Time{}, false
}
+68
View File
@@ -0,0 +1,68 @@
package cron
import (
"testing"
"time"
)
func TestNextRun(t *testing.T) {
loc := time.UTC
from := time.Date(2026, 9, 17, 15, 30, 0, 0, loc) // Thursday
cases := []struct {
expr string
want time.Time
}{
{"0 1 * * *", time.Date(2026, 9, 18, 1, 0, 0, 0, loc)},
{"17 * * * *", time.Date(2026, 9, 17, 16, 17, 0, 0, loc)},
{"*/15 * * * *", time.Date(2026, 9, 17, 15, 45, 0, 0, loc)},
{"47 6 * * 7", time.Date(2026, 9, 20, 6, 47, 0, 0, loc)}, // Sunday as 7
{"52 6 1 * *", time.Date(2026, 10, 1, 6, 52, 0, 0, loc)}, // first of month
{"24 0 1-7 * *", time.Date(2026, 10, 1, 0, 24, 0, 0, loc)}, // range
{"30 3 * * 0", time.Date(2026, 9, 20, 3, 30, 0, 0, loc)}, // Sunday as 0
{"0 9 15 * mon", time.Date(2026, 9, 21, 9, 0, 0, 0, loc)}, // dom OR dow
{"@daily", time.Date(2026, 9, 18, 0, 0, 0, 0, loc)},
{"@weekly", time.Date(2026, 9, 20, 0, 0, 0, 0, loc)},
{"0 0 29 feb *", time.Date(2028, 2, 29, 0, 0, 0, 0, loc)},
}
for _, c := range cases {
got, ok := nextRun(c.expr, from)
if !ok || !got.Equal(c.want) {
t.Errorf("%q: got %v ok=%v, want %v", c.expr, got, ok, c.want)
}
}
if _, ok := nextRun("@reboot", from); ok {
t.Error("@reboot should have no next run")
}
for _, bad := range []string{"* * * *", "60 * * * *", "a b c d e", "*/0 * * * *"} {
if _, err := parseSchedule(bad); err == nil {
t.Errorf("%q should fail", bad)
}
}
}
func TestParseLine(t *testing.T) {
sch, u, cmd, ok := parseLine("25 6\t* * *\troot\ttest -x /usr/sbin/anacron || { cd / && run-parts --report /etc/cron.daily; }", true)
if !ok || sch != "25 6 * * *" || u != "root" || !strings_HasPrefix(cmd, "test -x") {
t.Errorf("system line: %q %q %q %v", sch, u, cmd, ok)
}
if _, _, _, ok := parseLine("PATH=/usr/bin:/bin", true); ok {
t.Error("env line parsed as job")
}
if _, _, _, ok := parseLine("# m h dom mon dow command", false); ok {
t.Error("comment parsed as job")
}
if _, u, cmd, ok := parseLine("@reboot /usr/local/bin/thing --flag", false); !ok || u != "" || cmd != "/usr/local/bin/thing --flag" {
t.Errorf("@reboot: %q %q %v", u, cmd, ok)
}
if got := outFile("foo >> /var/log/foo.log 2>&1"); got != "/var/log/foo.log" {
t.Errorf("outFile: %q", got)
}
if c, in := cronPercent("echo hi%line1%line2"); c != "echo hi" || in != "line1\nline2" {
t.Errorf("percent: %q %q", c, in)
}
if c, _ := cronPercent(`date +\%w`); c != "date +%w" {
t.Errorf("escaped percent: %q", c)
}
}
func strings_HasPrefix(s, p string) bool { return len(s) >= len(p) && s[:len(p)] == p }
+3
View File
@@ -84,6 +84,9 @@ func New() *Manager {
// Sudo reports whether actions will run through passwordless sudo. // Sudo reports whether actions will run through passwordless sudo.
func (m *Manager) Sudo() bool { return m.sudo } func (m *Manager) Sudo() bool { return m.sudo }
// JournalSudo reports whether reading the system journal needs sudo for this user.
func (m *Manager) JournalSudo() bool { return m.journalSudo }
func run(ctx context.Context, args ...string) (string, error) { func run(ctx context.Context, args ...string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, 20*time.Second) ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel() defer cancel()
+235
View File
@@ -0,0 +1,235 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { api, CronJob, CronOverview, CronRun, CronTimer, LogEntry } from './api'
import { Logs } from './Services'
const fmtTime = (ms: number) => { const d = new Date(ms); return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: '2-digit' }) + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }
function rel(ms: number): string {
const s = Math.round((ms - Date.now()) / 1000), a = Math.abs(s)
const d = Math.floor(a / 86400), h = Math.floor((a % 86400) / 3600), m = Math.floor((a % 3600) / 60)
const txt = d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : m > 0 ? `${m}m` : `${a}s`
return s >= 0 ? `in ${txt}` : `${txt} ago`
}
// Plain-English reading of the common five-field shapes; anything else shows the raw expression.
function describe(expr: string): string {
const alias: Record<string, string> = { '@reboot': 'at boot', '@hourly': 'every hour', '@daily': 'every day at 00:00', '@midnight': 'every day at 00:00', '@weekly': 'every Sunday at 00:00', '@monthly': 'on the 1st at 00:00', '@yearly': 'every 1 Jan', '@annually': 'every 1 Jan' }
if (alias[expr]) return alias[expr]
const f = expr.split(/\s+/)
if (f.length !== 5) return expr
const [mi, h, dom, mon, dow] = f
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
const at = /^\d+$/.test(mi) && /^\d+$/.test(h) ? `at ${h.padStart(2, '0')}:${mi.padStart(2, '0')}` : /^\d+$/.test(mi) && h === '*' ? `every hour at :${mi.padStart(2, '0')}` : mi.startsWith('*/') && h === '*' ? `every ${mi.slice(2)} min` : null
if (!at) return expr
const dowTxt = dow === '*' ? '' : /^\d$/.test(dow) ? `every ${days[+dow]}` : `on days ${dow}`
const domTxt = dom === '*' ? '' : /^\d+$/.test(dom) ? `on the ${dom}${['st', 'nd', 'rd'][+dom - 1] ?? 'th'}` : `on days ${dom}`
const monTxt = mon === '*' ? '' : `in month ${mon}`
const when = [dowTxt, domTxt, monTxt].filter(Boolean).join(', ')
return (when ? `${when} ` : dow === '*' && dom === '*' && !at.startsWith('every') ? 'every day ' : '') + at
}
const srcLabel = (s: string) => (s.startsWith('crontab:') ? `${s.slice(8)}'s crontab` : s)
// ---- source editor ----
function SourceEditor({ name, onClose, onSaved }: { name: string; onClose: () => void; onSaved: () => void }) {
const [text, setText] = useState<string | null>(null)
const [editable, setEditable] = useState(false)
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
useEffect(() => { api.cronSource(name).then((r) => { setText(r.text); setEditable(r.editable) }).catch((e) => setErr(String(e))) }, [name])
async function save() {
if (text === null) return
setBusy(true); setErr('')
try { await api.cronWriteSource(name, text); onSaved(); onClose() }
catch (e) { setErr(e instanceof Error ? e.message : 'save failed') }
finally { setBusy(false) }
}
return (
<div className="cron-detail">
<button className="btn back" onClick={onClose}>‹ Back</button>
<h2>{srcLabel(name)}</h2>
{!editable && <div className="notice">Read-only: writing this file needs passwordless sudo on the host.</div>}
{text === null ? <div className="muted">…</div> : <textarea className="editor" value={text} spellCheck={false} readOnly={!editable} onChange={(e) => setText(e.target.value)} />}
{err && <div className="error">{err}</div>}
<div className="svc-actions">
<button className="btn on" disabled={!editable || busy || text === null} onClick={save}>{busy ? '…' : 'Save'}</button>
<button className="btn" onClick={onClose}>Cancel</button>
</div>
<div className="notice">Lines are validated before saving; user crontabs are installed with <code>crontab -</code> so cron picks them up immediately.</div>
</div>
)
}
// ---- job detail ----
function JobDetail({ job, logging, onChanged, onBack, onEdit }: { job: CronJob; logging: boolean; onChanged: () => void; onBack: () => void; onEdit: (src: string) => void }) {
const [tab, setTab] = useState<'logs' | 'output' | 'run'>('logs')
const [logs, setLogs] = useState<LogEntry[] | null>(null)
const [out, setOut] = useState<{ file: string; text: string } | null>(null)
const [run, setRun] = useState<CronRun | null>(null)
const [err, setErr] = useState('')
const [busy, setBusy] = useState('')
const poll = useRef<number | undefined>(undefined)
useEffect(() => { setLogs(null); setOut(null); setRun(null); setTab('logs'); setErr('') }, [job.id])
useEffect(() => { if (tab === 'logs' && !logs) api.cronJobLogs(job.id).then(setLogs).catch((e) => setErr(String(e))) }, [tab, logs, job.id])
useEffect(() => { if (tab === 'output' && !out) api.cronJobOutput(job.id).then(setOut).catch((e) => setOut({ file: job.out_file ?? '', text: String(e) })) }, [tab, out, job.id, job.out_file])
useEffect(() => () => clearTimeout(poll.current), [])
async function toggle() {
if (job.enabled && !window.confirm(`Disable this job? It will be commented out in ${srcLabel(job.source)}.`)) return
setBusy('toggle'); setErr('')
try { await api.cronJobToggle(job.id, !job.enabled); onChanged() } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } finally { setBusy('') }
}
async function runNow() {
if (!window.confirm(`Run now as ${job.user}?\n\n${job.command}`)) return
setBusy('run'); setErr('')
try {
const r = await api.cronJobRun(job.id)
setRun(r); setTab('run')
const tick = async () => {
try { const cur = await api.cronRun(r.id); setRun(cur); if (cur.running) poll.current = window.setTimeout(tick, 1000); else onChanged() }
catch { /* run expired */ }
}
poll.current = window.setTimeout(tick, 1000)
} catch (e) { setErr(e instanceof Error ? e.message : 'failed') } finally { setBusy('') }
}
return (
<div className="cron-detail">
<button className="btn back" onClick={onBack}>‹ Cron</button>
<h2>{job.command}</h2>
<div className="props">
<div><span className="k">Schedule</span><span className="mono" title={job.schedule}>{job.schedule} · {describe(job.schedule)}</span></div>
<div><span className="k">Next</span><span>{job.next ? `${rel(job.next)} · ${fmtTime(job.next)}` : job.enabled ? '—' : 'disabled'}</span></div>
<div><span className="k">Last</span><span>{job.last ? `${rel(job.last)} · ${fmtTime(job.last)}` : logging ? 'not seen in the last 30 days' : 'unknown (cron isn’t logging here)'}</span></div>
<div><span className="k">User</span><span>{job.user}</span></div>
<div><span className="k">Source</span><span className="mono">{srcLabel(job.source)}{job.line ? `:${job.line}` : ''}</span></div>
{job.out_file && <div><span className="k">Output</span><span className="mono">{job.out_file}</span></div>}
</div>
{job.note && <div className="notice">{job.note}</div>}
<div className="svc-actions">
<button className="btn" disabled={!!busy} onClick={runNow}>{busy === 'run' ? '…' : 'Run now'}</button>
{job.line > 0 && <button className="btn" disabled={!!busy || !job.editable} onClick={toggle}>{job.enabled ? 'Disable' : 'Enable'}</button>}
{job.line > 0 && <button className="btn" onClick={() => onEdit(job.source)}>Edit {srcLabel(job.source)}</button>}
</div>
{err && <div className="error">{err}</div>}
<div className="tabs">
<button className={tab === 'logs' ? 'on' : ''} onClick={() => setTab('logs')}>Cron log</button>
{job.out_file && <button className={tab === 'output' ? 'on' : ''} onClick={() => setTab('output')}>Output file</button>}
{run && <button className={tab === 'run' ? 'on' : ''} onClick={() => setTab('run')}>Run output</button>}
</div>
{tab === 'logs' && (
<div className="logs"><div className="log-lines">
{logs === null ? <div className="muted">…</div> : logs.length === 0 ? <div className="muted">{logging ? 'No runs of this exact command in the journal.' : 'cron isn’t writing to the journal on this host, so there is no run history.'}</div>
: logs.map((l, i) => <div key={i} className="log"><span className="lt">{fmtTime(l.t)}</span><span className="lm">{l.msg}</span></div>)}
</div></div>
)}
{tab === 'output' && <pre className="run-out">{out ? out.text || '(empty)' : '…'}</pre>}
{tab === 'run' && run && (
<>
<div className="muted">{run.running ? 'running…' : `exited ${run.exit_code} · ${new Date(run.finished!).toLocaleTimeString()}`}</div>
<pre className="run-out">{run.output || (run.running ? '' : '(no output)')}</pre>
</>
)}
</div>
)
}
// ---- timer detail ----
function TimerDetail({ t, onChanged, onBack }: { t: CronTimer; onChanged: () => void; onBack: () => void }) {
const [busy, setBusy] = useState('')
const [err, setErr] = useState('')
async function act(a: string) {
if ((a === 'stop' || a === 'disable') && !window.confirm(`${a} ${t.unit}?`)) return
if (a === 'run' && !window.confirm(`Start ${t.activates} now?`)) return
setBusy(a); setErr('')
try { await api.cronTimerAction(t.unit, a); onChanged() } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } finally { setBusy('') }
}
const B = ({ a, label }: { a: string; label: string }) => <button className="btn" disabled={!!busy} onClick={() => act(a)}>{busy === a ? '…' : label}</button>
return (
<div className="cron-detail">
<button className="btn back" onClick={onBack}>‹ Cron</button>
<h2>{t.unit}</h2>
<div className="muted">{t.description}</div>
<div className="props">
<div><span className="k">Runs</span><span className="mono">{t.activates}</span></div>
<div><span className="k">State</span><span>{t.active}{t.sub ? ` (${t.sub})` : ''} · {t.enabled || '—'}</span></div>
<div><span className="k">Next</span><span>{t.next ? `${rel(t.next)} · ${fmtTime(t.next)}` : '—'}</span></div>
<div><span className="k">Last</span><span>{t.last ? `${rel(t.last)} · ${fmtTime(t.last)}` : 'never'}</span></div>
</div>
<div className="svc-actions">
<B a="run" label="Run now" />
{t.active === 'active' ? <B a="stop" label="Stop timer" /> : <B a="start" label="Start timer" />}
{t.enabled === 'enabled' ? <B a="disable" label="Disable" /> : t.enabled === 'static' ? null : <B a="enable" label="Enable" />}
</div>
{err && <div className="error">{err}</div>}
<div className="tabs"><button className="on">Logs of {t.activates}</button></div>
<Logs unit={t.activates} />
</div>
)
}
// ---- page ----
export default function Cron() {
const [ov, setOv] = useState<CronOverview | null>(null)
const [err, setErr] = useState('')
const [q, setQ] = useState('')
const [sel, setSel] = useState<{ kind: 'job' | 'timer'; id: string } | null>(null)
const [editing, setEditing] = useState<string | null>(null)
const load = useCallback(async () => {
try { setOv(await api.cron()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
}, [])
useEffect(() => { load() }, [load])
useEffect(() => { const t = setInterval(() => { if (document.visibilityState === 'visible') load() }, 30000); return () => clearInterval(t) }, [load])
const needle = q.trim().toLowerCase()
const match = (...s: string[]) => !needle || s.some((x) => x.toLowerCase().includes(needle))
const jobs = (ov?.jobs ?? []).filter((j) => match(j.command, j.source, j.user, j.schedule))
const timers = (ov?.timers ?? []).filter((t) => match(t.unit, t.description, t.activates))
const selJob = sel?.kind === 'job' ? jobs.find((j) => j.id === sel.id) ?? ov?.jobs.find((j) => j.id === sel.id) : undefined
const selTimer = sel?.kind === 'timer' ? ov?.timers.find((t) => t.unit === sel.id) : undefined
const sources = [...new Set((ov?.jobs ?? []).filter((j) => j.line > 0).map((j) => j.source))]
let pane
if (editing) pane = <SourceEditor name={editing} onClose={() => setEditing(null)} onSaved={load} />
else if (selJob) pane = <JobDetail job={selJob} logging={!!ov?.logging} onChanged={load} onBack={() => setSel(null)} onEdit={setEditing} />
else if (selTimer) pane = <TimerDetail t={selTimer} onChanged={load} onBack={() => setSel(null)} />
else pane = <div className="svc-empty muted">Select a job or timer.</div>
return (
<div className={'cron' + (sel || editing ? ' has-sel' : '')}>
<div className="cron-list">
<div className="svc-tools">
<input placeholder="filter jobs and timers" value={q} onChange={(e) => setQ(e.target.value)} />
<div className="chips">
{sources.map((s) => <button key={s} className="chip" title="edit" onClick={() => { setSel(null); setEditing(s) }}>✎ {srcLabel(s)}</button>)}
</div>
</div>
{err && <div className="error">{err}</div>}
{ov && !ov.logging && <div className="notice" style={{ margin: 8 }}>cron isn’t logging to the journal on this host, so “last run” is unavailable for cron jobs.</div>}
<div className="cron-rows">
<h3>Cron jobs · {jobs.length}</h3>
{jobs.map((j) => (
<div key={j.id} className={'cron-row' + (sel?.kind === 'job' && sel.id === j.id ? ' active' : '') + (j.enabled ? '' : ' off')} onClick={() => { setEditing(null); setSel({ kind: 'job', id: j.id }) }}>
<span className={'sdot ' + (j.enabled ? 'ok' : '')} />
<span className="cmd" title={j.command}>{j.command}</span>
<span className="when">{j.next ? rel(j.next) : j.enabled ? '' : 'off'}</span>
<span className="meta"><span>{describe(j.schedule)}</span><span>{j.user}</span><span>{srcLabel(j.source)}</span>{j.last && <span>last {rel(j.last)}</span>}</span>
</div>
))}
{ov && jobs.length === 0 && <div className="muted" style={{ padding: 12 }}>No cron jobs.</div>}
<h3>systemd timers · {timers.length}</h3>
{timers.map((t) => (
<div key={t.unit} className={'cron-row' + (sel?.kind === 'timer' && sel.id === t.unit ? ' active' : '') + (t.active === 'active' ? '' : ' off')} onClick={() => { setEditing(null); setSel({ kind: 'timer', id: t.unit }) }}>
<span className={'sdot ' + (t.active === 'active' ? 'ok' : '')} />
<span className="cmd" title={t.unit}>{t.unit.replace(/\.timer$/, '')}</span>
<span className="when">{t.next ? rel(t.next) : t.active !== 'active' ? 'stopped' : ''}</span>
<span className="meta"><span>{t.description}</span>{t.last && <span>last {rel(t.last)}</span>}</span>
</div>
))}
</div>
</div>
{pane}
</div>
)
}
+1 -1
View File
@@ -32,7 +32,7 @@ function stateClass(u: ServiceUnit): string {
} }
// ---- log viewer: seeded from the REST fetch, then followed over a WebSocket ---- // ---- log viewer: seeded from the REST fetch, then followed over a WebSocket ----
function Logs({ unit }: { unit: string }) { export function Logs({ unit }: { unit: string }) {
const [lines, setLines] = useState<LogEntry[]>([]) const [lines, setLines] = useState<LogEntry[]>([])
const [live, setLive] = useState(true) const [live, setLive] = useState(true)
const [paused, setPaused] = useState(false) const [paused, setPaused] = useState(false)
+5 -3
View File
@@ -5,16 +5,17 @@ import TerminalPane, { TermHandle } from './TerminalPane'
import KeyBar from './KeyBar' import KeyBar from './KeyBar'
import Dashboard from './Dashboard' import Dashboard from './Dashboard'
import Services from './Services' import Services from './Services'
import Cron from './Cron'
const GROUPS: Group[] = [ const GROUPS: Group[] = [
{ id: 'dashboard', label: 'Dashboard', icon: '◔', page: true }, { id: 'dashboard', label: 'Dashboard', icon: '◔', page: true },
{ id: 'terminal', label: 'Terminal', icon: '>_' }, { id: 'terminal', label: 'Terminal', icon: '>_' },
{ id: 'services', label: 'Services', icon: '⚙', page: true }, { id: 'services', label: 'Services', icon: '⚙', page: true },
{ id: 'cron', label: 'Cron', icon: '⏱', soon: true }, { id: 'cron', label: 'Cron', icon: '⏱', page: true },
{ id: 'zfs', label: 'ZFS', icon: '▤', soon: true }, { id: 'zfs', label: 'ZFS', icon: '▤', soon: true },
] ]
type View = 'terminal' | 'dashboard' | 'services' type View = 'terminal' | 'dashboard' | 'services' | 'cron'
function fmtUptime(s: number): string { function fmtUptime(s: number): string {
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60) const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
@@ -81,6 +82,7 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
<main className="main"> <main className="main">
{view === 'dashboard' && <Dashboard />} {view === 'dashboard' && <Dashboard />}
{view === 'services' && <Services />} {view === 'services' && <Services />}
{view === 'cron' && <Cron />}
{termView && terms.length === 0 && ( {termView && terms.length === 0 && (
<div className="center muted"> <div className="center muted">
<div>No terminals.</div> <div>No terminals.</div>
@@ -103,7 +105,7 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
<span>{me.version.startsWith('v') ? me.version : 'v' + me.version}</span> <span>{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
<span>up {fmtUptime(me.uptime)}</span> <span>up {fmtUptime(me.uptime)}</span>
<span className="spacer" /> <span className="spacer" />
<span className="muted">{view === 'dashboard' ? 'Dashboard' : view === 'services' ? 'Services' : activeTerm ? activeTerm.name : ''}</span> <span className="muted">{view === 'dashboard' ? 'Dashboard' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : activeTerm ? activeTerm.name : ''}</span>
</footer> </footer>
</div> </div>
) )
+14
View File
@@ -46,7 +46,21 @@ export interface ServiceUnit { name: string; description: string; load: string;
export interface ServiceDetail extends ServiceUnit { props: Record<string, string> } export interface ServiceDetail extends ServiceUnit { props: Record<string, string> }
export interface LogEntry { t: number; p: number; ident: string; pid?: string; msg: string } export interface LogEntry { t: number; p: number; ident: string; pid?: string; msg: string }
export interface CronJob { id: string; source: string; line: number; user: string; schedule: string; command: string; enabled: boolean; next?: number; last?: number; out_file?: string; note?: string; editable: boolean }
export interface CronTimer { unit: string; activates: string; description: string; active: string; sub: string; enabled: string; next?: number; last?: number }
export interface CronOverview { jobs: CronJob[]; timers: CronTimer[]; logging: boolean; anacron: boolean; sudo: boolean }
export interface CronRun { id: string; job_id: string; command: string; user: string; started: string; finished?: string; running: boolean; exit_code: number; output: string }
export const api = { export const api = {
cron: () => req<CronOverview>('GET', '/api/cron'),
cronSource: (name: string) => req<{ text: string; editable: boolean }>('GET', `/api/cron/source?name=${encodeURIComponent(name)}`),
cronWriteSource: (name: string, text: string) => req<void>('PUT', `/api/cron/source?name=${encodeURIComponent(name)}`, { text }),
cronJobLogs: (id: string, n = 200) => req<LogEntry[]>('GET', `/api/cron/jobs/${id}/logs?n=${n}`),
cronJobOutput: (id: string, n = 200) => req<{ file: string; text: string }>('GET', `/api/cron/jobs/${id}/output?n=${n}`),
cronJobToggle: (id: string, enable: boolean) => req<void>('POST', `/api/cron/jobs/${id}/${enable ? 'enable' : 'disable'}`),
cronJobRun: (id: string) => req<CronRun>('POST', `/api/cron/jobs/${id}/run`),
cronRun: (id: string) => req<CronRun>('GET', `/api/cron/runs/${id}`),
cronTimerAction: (unit: string, action: string) => req<void>('POST', `/api/cron/timers/${encodeURIComponent(unit)}/${action}`),
services: async () => (await req<{ units: ServiceUnit[]; sudo: boolean }>('GET', '/api/services')).units, services: async () => (await req<{ units: ServiceUnit[]; sudo: boolean }>('GET', '/api/services')).units,
service: (unit: string) => req<ServiceDetail>('GET', `/api/services/${encodeURIComponent(unit)}`), service: (unit: string) => req<ServiceDetail>('GET', `/api/services/${encodeURIComponent(unit)}`),
serviceFile: (unit: string) => req<{ content: string }>('GET', `/api/services/${encodeURIComponent(unit)}/file`), serviceFile: (unit: string) => req<{ content: string }>('GET', `/api/services/${encodeURIComponent(unit)}/file`),
+22
View File
@@ -160,3 +160,25 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
.svc-list { width: 100%; border-right: 0; } .has-sel .svc-list { display: none; } .svc-empty { display: none; } .svc-list { width: 100%; border-right: 0; } .has-sel .svc-list { display: none; } .svc-empty { display: none; }
.svc-detail .back { display: inline-block; } .props { grid-template-columns: 1fr; } .svc-detail .back { display: inline-block; } .props { grid-template-columns: 1fr; }
} }
/* cron */
.cron { position: absolute; inset: 0; display: flex; background: var(--bg); }
.cron-list { width: 420px; flex-shrink: 0; display: flex; flex-direction: column; border-right: 1px solid var(--border); background: var(--bg2); min-height: 0; }
.cron-rows { flex: 1; overflow-y: auto; }
.cron-rows h3 { margin: 0; padding: 8px 12px 4px; font-size: 11px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; background: var(--bg2); position: sticky; top: 0; }
.cron-row { display: grid; grid-template-columns: 8px 1fr auto; gap: 4px 10px; align-items: center; padding: 8px 12px; cursor: pointer; border-bottom: 1px solid var(--border); }
.cron-row:hover { background: var(--bg3); } .cron-row.active { background: var(--bg3); box-shadow: inset 3px 0 0 var(--accent); }
.cron-row .cmd { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, monospace; font-size: 12px; }
.cron-row .meta { grid-column: 2 / 4; font-size: 11px; color: var(--muted); display: flex; gap: 8px; flex-wrap: wrap; }
.cron-row .when { font-size: 11px; color: var(--muted); white-space: nowrap; font-variant-numeric: tabular-nums; }
.cron-row.off .cmd { color: var(--muted); text-decoration: line-through; }
.cron-detail { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10px; padding: 12px; overflow-y: auto; }
.cron-detail .back { display: none; align-self: flex-start; }
.cron-detail h2 { margin: 0; font-size: 15px; font-weight: 600; font-family: ui-monospace, monospace; word-break: break-all; white-space: pre-wrap; }
.notice { padding: 8px 10px; border: 1px solid var(--border); border-radius: 6px; font-size: 12px; color: var(--muted); background: var(--bg2); }
.editor { width: 100%; min-height: 260px; padding: 10px; background: #0d1117; color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font: 12px/1.5 ui-monospace, monospace; resize: vertical; }
.run-out { margin: 0; padding: 10px; background: #0d1117; border: 1px solid var(--border); border-radius: 6px; font: 12px/1.5 ui-monospace, monospace; white-space: pre-wrap; word-break: break-word; max-height: 50vh; overflow: auto; }
@media (max-width: 800px) {
.cron-list { width: 100%; border-right: 0; } .has-sel .cron-list { display: none; }
.cron-detail .back { display: inline-block; }
}