Skip to content
Open
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
28 changes: 23 additions & 5 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ Get notified when tasks succeed or fail. Three notification channels available:
"enabled": true,
"type": "webhook",
"target": "https://your-webhook.example.com/hook",
"webhook_format": "generic",
"on_success": false,
"on_failure": true
}, {
Expand All @@ -68,7 +69,18 @@ Configure once in Settings (gear icon), applies to all tasks:
- `PUT /cron/settings` — set bot token, chat ID, trigger conditions
- `POST /cron/settings/test-telegram` — send a test message

**Webhook payload:**
**Webhook format** (`webhook_format` field — select in UI or set via API):

| Format | Value | Payload |
|--------|-------|---------|
| Generic | `generic` (default) | Nested JSON with `event`, `task`, `result`, `timestamp` |
| n8n | `n8n` | Flat JSON: `event`, `task_id`, `task_name`, `command`, `success`, `message`, `duration_ms`, `timestamp` |
| Discord | `discord` | `{"content": "✅ SUCCESS — TaskName (1250ms)\n```output```"}` |
| Slack | `slack` | `{"text": "..."}` — same message as Discord |
| Home Assistant | `home_assistant` | `{"message", "task_name", "success", "duration_ms", "output", "timestamp"}` |
| Uptime Kuma | `uptime_kuma` | Query params on push URL: `?status=up\|down&msg=...&ping=duration_ms` |

**Generic webhook payload:**
```json
{
"event": "task_completed",
Expand All @@ -78,8 +90,6 @@ Configure once in Settings (gear icon), applies to all tasks:
}
```

Works with n8n, Home Assistant, Discord webhooks, Slack, Uptime Kuma, or any HTTP endpoint.

### 4. Categories, Tags & Priority

Organize tasks with metadata:
Expand Down Expand Up @@ -261,16 +271,23 @@ go run ./cmd/cron
- Environment variables (key-value editor)
- Max log entries
- Task dependencies (select from existing tasks)
- Webhook notification URL + triggers
- Webhook type (Generic, n8n, Discord, Slack, Home Assistant, Uptime Kuma) + URL + triggers
6. Click **Create**

### Editing a Task

1. Click **Edit** on any task row
2. The create form opens pre-filled with current settings
3. Modify fields and click **Save** (`PUT /cron/tasks/{id}`)

### Task List

- **Filter** by category or tag using the dropdowns above the table
- **Run Once** — trigger immediate execution
- **Pause/Resume** — toggle the schedule
- **Show Logs** — expand inline log viewer with search, CSV/JSON export
- **Delete** — remove the task
- **Edit** — modify task settings
- **Delete** — remove the task (confirmation dialog)

### Log Viewer

Expand All @@ -294,6 +311,7 @@ Base path: `/cron`
| `GET` | `/tasks?tag=X` | Filter by tag |
| `POST` | `/tasks` | Create a task |
| `GET` | `/tasks/{id}` | Get single task |
| `PUT` | `/tasks/{id}` | Update a task |
| `DELETE` | `/tasks/{id}` | Delete a task |
| `POST` | `/tasks/{id}/run` | Run task once |
| `POST` | `/tasks/{id}/toggle` | Pause/resume task |
Expand Down
16 changes: 12 additions & 4 deletions app.js

Large diffs are not rendered by default.

159 changes: 140 additions & 19 deletions cmd/cron/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,89 @@ type createReq struct {
MaxLogEntries int `json:"max_log_entries,omitempty"`
}

const maskedSecret = "********"

func validateNotifications(notifs []notify.Config) error {
for _, n := range notifs {
if n.Type != "webhook" && n.Type != "email" && n.Type != "telegram" {
return fmt.Errorf("invalid notification type: %s", n.Type)
}
if n.Type == "webhook" {
if err := notify.ValidateWebhookFormat(n.WebhookFormat); err != nil {
return err
}
}
}
return nil
}

func mergeNotificationCredentials(incoming, existing []notify.Config) []notify.Config {
if len(incoming) == 0 {
return incoming
}
merged := make([]notify.Config, len(incoming))
copy(merged, incoming)
for i := range merged {
if merged[i].SMTPPass == maskedSecret {
for _, e := range existing {
if e.Type == "email" && e.Target == merged[i].Target && e.SMTPHost == merged[i].SMTPHost {
merged[i].SMTPPass = e.SMTPPass
break
}
}
}
if merged[i].TelegramBotToken == maskedSecret {
for _, e := range existing {
if e.Type == "telegram" && e.Target == merged[i].Target {
merged[i].TelegramBotToken = e.TelegramBotToken
break
}
}
}
}
return merged
}

func applyCreateReq(t *Task, req createReq) error {
t.Name = req.Name
t.Command = req.Command
t.Type = req.Type
t.TimeoutSec = req.TimeoutSec
t.RetryCount = req.RetryCount
t.RetryDelaySec = req.RetryDelaySec
t.Env = req.Env
t.Category = req.Category
t.Tags = req.Tags
t.Priority = req.Priority
t.DependsOn = req.DependsOn
t.AllowParallel = req.AllowParallel
t.MaxLogEntries = req.MaxLogEntries
if req.Type == "interval" {
if req.IntervalMin < 1 {
return fmt.Errorf("interval_min >=1")
}
t.Interval = time.Duration(req.IntervalMin) * time.Minute
t.CronExpr = ""
} else {
if !isValidCron(req.CronExpr) {
return fmt.Errorf("invalid cron")
}
t.CronExpr = req.CronExpr
t.Interval = 0
}
return nil
}

func scheduleChanged(t *Task, req createReq) bool {
if t.Type != req.Type {
return true
}
if req.Type == "interval" {
return int(t.Interval/time.Minute) != req.IntervalMin
}
return t.CronExpr != req.CronExpr
}

func tasksHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
Expand Down Expand Up @@ -357,12 +440,9 @@ func tasksHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "invalid type", 400)
return
}
// Validate notification configs
for _, n := range req.Notifications {
if n.Type != "webhook" && n.Type != "email" && n.Type != "telegram" {
http.Error(w, "invalid notification type: "+n.Type, 400)
return
}
if err := validateNotifications(req.Notifications); err != nil {
http.Error(w, err.Error(), 400)
return
}
id := newTaskID()
t := &Task{
Expand All @@ -375,18 +455,9 @@ func tasksHandler(w http.ResponseWriter, r *http.Request) {
DependsOn: req.DependsOn, AllowParallel: req.AllowParallel,
MaxLogEntries: req.MaxLogEntries,
}
if req.Type == "interval" {
if req.IntervalMin < 1 {
http.Error(w, "interval_min >=1", 400)
return
}
t.Interval = time.Duration(req.IntervalMin) * time.Minute
} else {
if !isValidCron(req.CronExpr) {
http.Error(w, "invalid cron", 400)
return
}
t.CronExpr = req.CronExpr
if err := applyCreateReq(t, req); err != nil {
http.Error(w, err.Error(), 400)
return
}
mu.Lock()
tasks[id] = t
Expand Down Expand Up @@ -429,6 +500,46 @@ func taskActionHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(204)
return
}
if len(parts) == 1 && r.Method == http.MethodPut {
var req createReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), 400)
return
}
if strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.Command) == "" {
http.Error(w, "name/command required", 400)
return
}
if req.Type != "interval" && req.Type != "cron" {
http.Error(w, "invalid type", 400)
return
}
if err := validateNotifications(req.Notifications); err != nil {
http.Error(w, err.Error(), 400)
return
}
mu.Lock()
existingNotifs := append([]notify.Config(nil), t.Notifications...)
needsReschedule := scheduleChanged(t, req)
wasRunning := t.Status == "running"
if err := applyCreateReq(t, req); err != nil {
mu.Unlock()
http.Error(w, err.Error(), 400)
return
}
t.Notifications = mergeNotificationCredentials(req.Notifications, existingNotifs)
if needsReschedule {
clearSchedule(t)
if wasRunning {
startSchedule(t)
}
}
mu.Unlock()
persistTask(t)
jsonResponse(w)
json.NewEncoder(w).Encode(sanitizeTask(t))
return
}
if len(parts) < 2 {
w.WriteHeader(404)
return
Expand Down Expand Up @@ -1460,7 +1571,17 @@ func cronNext(expr string, from time.Time) time.Time {
monthOk := monSet.set[mon]
domOk := domSet.set[dom]
dowOk := dowSet.set[dow]
dayOk := (domSet.isAll && dowSet.isAll) || (domSet.isAll && dowOk) || (dowSet.isAll && domOk) || (domOk || dowOk)
var dayOk bool
switch {
case domSet.isAll && dowSet.isAll:
dayOk = true
case domSet.isAll:
dayOk = dowOk
case dowSet.isAll:
dayOk = domOk
default:
dayOk = domOk || dowOk
}
if minuteOk && hourOk && monthOk && dayOk {
return d
}
Expand Down
25 changes: 23 additions & 2 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,9 @@ <h2 data-i18n="taskList">Task List</h2>
</main>
<div class="modal-overlay" id="createTaskModal">
<div class="modal">
<h3 data-i18n="createTitle">Create Task</h3>
<h3 id="taskModalTitle" data-i18n="createTitle">Create Task</h3>
<div class="modal-body">
<div class="form-row">
<div class="form-row" id="templateRow">
<label for="templateSelect" data-i18n="template">Start from Template</label>
<select id="templateSelect">
<option value="" data-i18n-opt="noTemplate">-- Blank Task --</option>
Expand Down Expand Up @@ -134,6 +134,15 @@ <h3 data-i18n="createTitle">Create Task</h3>
</div>
<div class="form-row">
<label data-i18n="webhookLabel">Webhook Notification</label>
<label for="webhookFormatSelect" data-i18n="webhookFormat">Webhook Type</label>
<select id="webhookFormatSelect">
<option value="generic" data-i18n-opt="webhookGeneric">Generic (JSON)</option>
<option value="n8n" data-i18n-opt="webhookN8n">n8n</option>
<option value="discord" data-i18n-opt="webhookDiscord">Discord</option>
<option value="slack" data-i18n-opt="webhookSlack">Slack</option>
<option value="home_assistant" data-i18n-opt="webhookHA">Home Assistant</option>
<option value="uptime_kuma" data-i18n-opt="webhookKuma">Uptime Kuma</option>
</select>
<input id="webhookUrlInput" type="url" placeholder="https://example.com/webhook" data-i18n-ph="phWebhookUrl">
<div class="checkbox-row">
<label><input type="checkbox" id="notifyOnSuccess"> <span data-i18n="onSuccess">On Success</span></label>
Expand Down Expand Up @@ -164,6 +173,18 @@ <h3 data-i18n="createTitle">Create Task</h3>
</div>
</div>
</div>
<div class="modal-overlay" id="confirmModal">
<div class="modal modal-sm">
<h3 data-i18n="deleteConfirmTitle">Delete Task?</h3>
<div class="modal-body">
<p id="confirmMessage" class="muted"></p>
</div>
<div class="modal-actions">
<button id="confirmCancelBtn" data-i18n="cancel">Cancel</button>
<button id="confirmOkBtn" class="danger" data-i18n="deleteConfirmBtn">Delete</button>
</div>
</div>
</div>
<div class="modal-overlay" id="settingsModal">
<div class="modal">
<h3 data-i18n="settingsTitle">Settings</h3>
Expand Down
Loading