diff --git a/TODO.md b/TODO.md
index 1b3544c..f69c257 100755
--- a/TODO.md
+++ b/TODO.md
@@ -9,29 +9,29 @@
x last output
x add titles to jobs
x job title includes last pass/fail icon
- 1. button to modify (copies to upsert form)
- 1. button to delete
+ x button to modify (copies to upsert form)
+ x button to delete
1. UI to mutate
- 1. submit job
- 1. delete job
- 1. pause jobs
+ x submit job
+ x delete job
+ x pause jobs
1. interrupt job
1. force run
-1. JS
+x JS
x ajax for json calls
# Backend
x load from file
1. interrupt running jobs
-1. temporarily disable jobs
+1. disable jobs
1. json API
1. list
- 1. last run output
- 1. last run pass/fail bool
- 1. last run timestamp
+ x last run output
+ x last run pass/fail bool
+ x last run timestamp
1. next run
- 1. upsert
+ x upsert
1. delete job
1. pause/disable job
1. running job
@@ -41,4 +41,4 @@ x load from file
x add optional second for test main
1. test main
x add titles to jobs
-1. namespace for jobs, output, lastrun, laststatus
+x namespace for jobs, output, lastrun, laststatus
diff --git a/main.go b/main.go
index e818697..9907a40 100755
--- a/main.go
+++ b/main.go
@@ -14,17 +14,14 @@ import (
)
func main() {
- var err error
- s := scheduler.New()
if config.Config != "" {
var err error
- s, err = scheduler.NewFromFile(config.Config)
+ scheduler.Schedule, err = scheduler.NewFromFile(config.Config)
if err != nil {
panic(err)
}
}
- err = s.Start()
- if err != nil {
+ if err := scheduler.Schedule.Start(); err != nil {
panic(err)
}
@@ -46,7 +43,7 @@ func main() {
stop := make(chan os.Signal)
signal.Notify(stop, os.Interrupt)
<-stop
- s.Stop()
+ scheduler.Schedule.Stop()
}
func EnqueueBackups() {
diff --git a/public/index.html b/public/index.html
index aa66f16..cbdd4a5 100755
--- a/public/index.html
+++ b/public/index.html
@@ -10,11 +10,12 @@
Upsert Job
diff --git a/public/js/js.js b/public/js/js.js
index 1e1f287..03ab058 100755
--- a/public/js/js.js
+++ b/public/js/js.js
@@ -14,7 +14,11 @@ function http(method, remote, callback, body) {
function upsert() {
function cb(body, status) {
- console.log(status, body)
+ if (status == 200) {
+ init()
+ } else {
+ console.log("error upserting:", status, body)
+ }
}
http("POST", "/api/job/upsert", cb, jsonifyForm("upsert"))
}
@@ -28,66 +32,74 @@ function jsonifyForm(id) {
}
function init() {
+ var table = document.getElementById("jobs").getElementsByTagName("tbody")[0]
function cb(body, status) {
var jobs = JSON.parse(body)
+ getJobsTable().innerHTML = ""
jobs.forEach(function(job) {
var s = format(job)
inject(s)
})
}
- function format(job) {
- var pause = "⏸"
- var passing = "9711"
- if (job.last.status != 0) {
- passing = "129314"
- passing = "129324"
- }
- passing = `${passing};`
- var buttons = ""
- var btns = [
- {"name":"disable", "icon":"11035"},
- {"name":"enable", "icon":"9654"},
- {"name":"modify", "icon":"9999"},
- {"name":"delete", "icon":"10006"}
- ]
- btns.forEach(function(e) {
- buttons += `
-
-
-
- `
- })
- return `
-
- ${job.title}
- ${passing}
- ${buttons}
-
-
-
- ${job.cron}
- ${job.language}
-
-
- ${job.last.run}
- ${job.last.runtime}
-
-
- ${job.script}
-
-
- ${job.last.output}
-
-
- `
- }
- function inject(s) {
- var table = document.getElementById("jobs").getElementsByTagName("tbody")[0]
- table.innerHTML += s
- }
http("GET", "/api/job/list", cb, null)
}
+function format(job) {
+ var pause = "⏸"
+ var passing = "9711"
+ if (job.last.status != 0) {
+ passing = "129314"
+ passing = "129324"
+ }
+ passing = `${passing};`
+ var buttons = ""
+ var btns = [
+ {"name":"refresh", "icon":"8635"},
+ {"name":"disable", "icon":"11035"},
+ {"name":"enable", "icon":"9654"},
+ {"name":"modify", "icon":"9999"},
+ {"name":"delete", "icon":"10006"}
+ ]
+ btns.forEach(function(e) {
+ buttons += `
+
+
+
+ `
+ })
+ return `
+
+ ${job.title}
+ ${passing}
+ ${buttons}
+
+
+
+ ${job.cron}
+ ${job.language}
+
+
+ ${job.last.run}
+ ${job.last.runtime}
+
+
+ ${job.script}
+
+
+ ${job.last.output}
+
+
+ `
+}
+
+function inject(s) {
+ getJobsTable().innerHTML += s
+}
+
+function getJobsTable() {
+ return document.getElementById("jobs").getElementsByTagName("tbody")[0]
+}
+
init()
function jobdisable(input) {
@@ -103,7 +115,7 @@ function jobenable(input) {
function jobmodify(input) {
var form = getForm()
var job = jobFromInput(input)
- var fields = ["id", "language", "cron", "script", "disabled"]
+ var fields = ["id", "language", "cron", "script", "disabled", "title"]
fields.forEach(function(field) {
var e = getField(field)
e.checked = job[field]
@@ -117,6 +129,31 @@ function jobdelete(input) {
http("DELETE", "/api/job/delete", cb, null)
}
+function jobrefresh(input) {
+ var job = jobFromInput(input)
+ function cb(body, status) {
+ var table = getJobsTable()
+ var summaries = Array.from(table.getElementsByTagName("summary"))
+ summaries.forEach(function(e) {
+ if (e.getAttribute("name") == job.id) {
+ e = e.parentElement
+ var showing = false
+ if (e.getAttribute("open") != null) {
+ showing = true
+ }
+ e = e.parentElement.parentElement
+ var table = document.createElement("table")
+ table.innerHTML = format(JSON.parse(body))
+ if (showing) {
+ table.getElementsByTagName("details")[0].setAttribute("open", "")
+ }
+ e.innerHTML = table.innerHTML
+ }
+ })
+ }
+ http("GET", "/api/job/get/"+job.id, cb, null)
+}
+
function jobFromInput(input) {
var b64 = input.getAttribute("job")
var json = atob(b64)
diff --git a/scheduler/job.go b/scheduler/job.go
index 73de80b..3a5f11d 100755
--- a/scheduler/job.go
+++ b/scheduler/job.go
@@ -20,12 +20,12 @@ type Job struct {
Schedule string
Raw string
Runner Runner
+ Disabled bool
foo func()
LastStatus int
LastOutput string
LastRuntime time.Duration
LastRun time.Time
- Disabled bool
}
func NewJob(runner Runner, schedule, raw string) (*Job, error) {
@@ -59,7 +59,7 @@ func newBashJob(schedule, sh string, title ...string) (*Job, error) {
out, err := cmd.CombinedOutput()
j.LastRuntime = time.Since(start)
if err != nil {
- out = []byte(fmt.Sprintf("error running command: %v: %v", err, out))
+ out = []byte(fmt.Sprintf("error running command: %v: %s", err, out))
}
j.LastOutput = strings.TrimSpace(string(out))
if cmd != nil && cmd.ProcessState != nil {
diff --git a/scheduler/scheduler.go b/scheduler/scheduler.go
index 883dd01..4f6c1ac 100755
--- a/scheduler/scheduler.go
+++ b/scheduler/scheduler.go
@@ -15,6 +15,8 @@ import (
cron "github.com/robfig/cron/v3"
)
+var Schedule *Scheduler = New()
+
type Scheduler struct {
cron *cron.Cron
running map[string]cron.EntryID
@@ -154,6 +156,40 @@ func (s *Scheduler) loadJobFromStore(k string) (*Job, error) {
return j, err
}
+func (s *Scheduler) Update(j *Job) error {
+ entryID, ok := s.getEntry(j)
+ if !ok {
+ return errors.New("job not found in storage")
+ }
+
+ i, err := s.loadJobFromStore(j.Name)
+ if err != nil {
+ return err
+ }
+ j.LastStatus = i.LastStatus
+ j.LastOutput = i.LastOutput
+ j.LastRuntime = i.LastRuntime
+ j.LastRun = i.LastRun
+
+ b, err := j.Encode()
+ if err != nil {
+ return err
+ }
+
+ if err := config.Store.Set(j.Name, b, ns.Jobs...); err != nil {
+ return err
+ }
+
+ s.cron.Remove(entryID)
+
+ entryID, err = s.cron.AddJob(j.Schedule, j)
+ if err != nil {
+ return err
+ }
+ s.running[j.Name] = entryID
+ return nil
+}
+
func (s *Scheduler) Add(j *Job) error {
if _, ok := s.getEntry(j); ok {
return ErrDuplicateJob
diff --git a/scheduler/scheduler_test.go b/scheduler/scheduler_test.go
index 66a79db..e367513 100755
--- a/scheduler/scheduler_test.go
+++ b/scheduler/scheduler_test.go
@@ -2,11 +2,13 @@ package scheduler
import (
"bytes"
+ "fmt"
"io/ioutil"
"local/firestormy/config"
"local/storage"
"os"
"testing"
+ "time"
)
func TestSchedulerAddRemove(t *testing.T) {
@@ -192,3 +194,49 @@ func TestSplitScheduleCommandTitle(t *testing.T) {
})
}
}
+
+func TestSchedulerUpdate(t *testing.T) {
+ config.Store, _ = storage.New(storage.MAP)
+ was := config.Store
+ defer func() {
+ config.Store = was
+ }()
+ s := New()
+ j, err := NewJob(Bash, "* * * * *", "hostname")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := s.Add(j); err != nil {
+ t.Fatal(err)
+ }
+ if list, err := s.List(); err != nil {
+ t.Fatal(err)
+ } else if len(list) != 1 {
+ t.Fatal(err)
+ }
+ time.Sleep(time.Millisecond * 1500)
+ j.Raw = "echo 2"
+ j.Title = "title 2"
+ if err := s.Update(j); err != nil {
+ t.Fatal(err)
+ }
+ if list, err := s.List(); err != nil {
+ t.Fatal(err)
+ } else if len(list) != 1 {
+ t.Fatal(err)
+ } else if j, err := s.loadJobFromStore(j.Name); err != nil {
+ t.Fatal(err)
+ } else if j.Raw != "echo 2" {
+ t.Error(j.Raw)
+ } else if j.Title != "title 2" {
+ t.Error(j.Title)
+ } else if entry := s.cron.Entry(s.running[j.Name]); entry == s.cron.Entry(-99) {
+ t.Error(entry)
+ } else if entries := s.cron.Entries(); len(entries) != 1 {
+ t.Error(entries)
+ } else if job, ok := entries[0].Job.(*Job); !ok {
+ t.Error(fmt.Sprintf("%T", entries[0].Job))
+ } else if fmt.Sprintf("%+v", job) != fmt.Sprintf("%+v", j) {
+ t.Error(job)
+ }
+}
diff --git a/server/get.go b/server/get.go
new file mode 100644
index 0000000..6a45534
--- /dev/null
+++ b/server/get.go
@@ -0,0 +1,28 @@
+package server
+
+import (
+ "encoding/json"
+ "local/firestormy/config"
+ "local/firestormy/config/ns"
+ "local/firestormy/scheduler"
+ "net/http"
+ "strings"
+)
+
+func (s *Server) get(w http.ResponseWriter, r *http.Request) {
+ keys := strings.Split(r.URL.Path, "/")
+ key := keys[len(keys)-1]
+
+ j := &scheduler.Job{}
+ b, err := config.Store.Get(key, ns.Jobs...)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusNotFound)
+ return
+ }
+ if err := j.Decode(b); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ json.NewEncoder(w).Encode(toMap(j))
+}
diff --git a/server/job.go b/server/job.go
new file mode 100644
index 0000000..ce6fa78
--- /dev/null
+++ b/server/job.go
@@ -0,0 +1,28 @@
+package server
+
+import (
+ "local/firestormy/scheduler"
+ "strings"
+ "time"
+)
+
+func toMap(j *scheduler.Job) map[string]interface{} {
+ tz, err := time.LoadLocation("America/Denver")
+ if err != nil {
+ panic(err)
+ }
+ out := make(map[string]interface{})
+ out["disabled"] = j.Disabled
+ out["id"] = j.Name
+ out["title"] = j.Title
+ out["cron"] = j.Schedule
+ out["language"] = j.Runner.String()
+ out["script"] = j.Raw
+ out["last"] = map[string]interface{}{
+ "run": j.LastRun.In(tz).Format(`2006-01-02 15:04:05 MST`),
+ "runtime": j.LastRuntime.String(),
+ "output": strings.ReplaceAll(j.LastOutput, "\n", " "),
+ "status": j.LastStatus,
+ }
+ return out
+}
diff --git a/server/list.go b/server/list.go
index 3a9b8c4..b012f17 100755
--- a/server/list.go
+++ b/server/list.go
@@ -7,7 +7,6 @@ import (
"local/firestormy/scheduler"
"net/http"
"sort"
- "time"
)
func (s *Server) list(w http.ResponseWriter, r *http.Request) {
@@ -29,22 +28,7 @@ func (s *Server) list(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
- tz, err := time.LoadLocation("America/Denver")
- if err != nil {
- panic(err)
- }
- out[i]["disabled"] = j.Disabled
- out[i]["id"] = j.Name
- out[i]["title"] = j.Title
- out[i]["cron"] = j.Schedule
- out[i]["language"] = j.Runner.String()
- out[i]["script"] = j.Raw
- out[i]["last"] = map[string]interface{}{
- "run": j.LastRun.In(tz).Format(`2006-01-02 15:04:05 MST`),
- "runtime": j.LastRuntime.String(),
- "output": string(j.LastOutput),
- "status": j.LastStatus,
- }
+ out[i] = toMap(j)
}
sort.Slice(out, func(i, j int) bool {
return out[i]["title"].(string) < out[j]["title"].(string)
diff --git a/server/routes.go b/server/routes.go
index aae54c5..c610f3b 100755
--- a/server/routes.go
+++ b/server/routes.go
@@ -12,6 +12,10 @@ func (s *Server) Routes() error {
path string
handler http.HandlerFunc
}{
+ {
+ path: fmt.Sprintf("/api/job/get/%s", router.Wildcard),
+ handler: s.gzip(s.authenticate(s.get)),
+ },
{
path: fmt.Sprintf("/api/job/upsert"),
handler: s.gzip(s.authenticate(s.upsert)),
diff --git a/server/upsert.go b/server/upsert.go
index 34fe3b1..fe65e39 100755
--- a/server/upsert.go
+++ b/server/upsert.go
@@ -7,7 +7,7 @@ import (
"io"
"local/firestormy/config"
"local/firestormy/config/ns"
- "log"
+ "local/firestormy/scheduler"
"net/http"
"github.com/google/uuid"
@@ -52,12 +52,31 @@ func (u *upsertRequest) validate() error {
return nil
}
+func (u *upsertRequest) toJob() (*scheduler.Job, error) {
+ j, err := scheduler.NewJob(scheduler.Bash, u.Cron, u.Script)
+ if err != nil {
+ return nil, err
+ }
+ j.Title = u.Title
+ j.Name = u.ID
+ j.Disabled = u.Disabled
+ return j, err
+}
+
func (s *Server) upsert(w http.ResponseWriter, r *http.Request) {
upsert, err := newUpsertRequest(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
- log.Println("received", upsert)
- http.Error(w, "not impl", http.StatusNotImplemented)
+ job, err := upsert.toJob()
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ if err := scheduler.Schedule.Update(job); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ json.NewEncoder(w).Encode(map[string]interface{}{})
}