diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..29eb987
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,39 @@
+# Git
+.git
+.gitignore
+
+# Frontend
+frontend/node_modules
+frontend/build
+frontend/.env*
+
+# Go
+*.exe
+*.exe~
+*.dll
+*.so
+*.dylib
+*.test
+*.out
+vendor/
+go.work
+
+# IDE
+.idea
+.vscode
+*.swp
+*.swo
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Documentation (keep in image)
+# README.md
+# EXAMPLES.md
+# QUICKSTART.md
+
+# Build artifacts
+gocron-ui
+gocron-ui.exe
+
diff --git a/.gitignore b/.gitignore
index 6657e3c..99dd0f1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,14 +7,31 @@
# Test binary, built with `go test -c`
*.test
-local_testing
-coverage.out
-# Output of the go coverage tool, specifically when used with LiteIDE
+# Output of the go coverage tool
*.out
+coverage.out
+
+# Go workspace file
+go.work
+go.work.sum
-# Dependency directories (remove the comment below to include it)
+# Dependency directories
vendor/
-# IDE project files
-.idea
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+*~
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Project binaries
+gocron-ui
+gocron-ui.exe
+exmaples/example
+exmaples/example.exe
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..b5f9076
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,17 @@
+# Build stage
+FROM golang:1.23-alpine AS builder
+WORKDIR /app
+COPY go.mod go.sum ./
+RUN go mod download
+COPY . .
+RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o gocron-ui main.go
+
+# Final stage
+FROM alpine:latest
+RUN apk --no-cache add ca-certificates tzdata
+WORKDIR /app
+COPY --from=builder /app/gocron-ui .
+COPY --from=builder /app/static ./static
+
+EXPOSE 8080
+CMD ["./gocron-ui"]
diff --git a/README.md b/README.md
index 1298dd9..e816ca9 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,244 @@
-# template
\ No newline at end of file
+# gocron-ui: A Web UI for [gocron](https://github.com/go-co-op/gocron)
+
+[](https://github.com/go-co-op/gocron-ui/actions)
+[](https://goreportcard.com/report/github.com/go-co-op/gocron-ui)
+[](https://pkg.go.dev/github.com/go-co-op/gocron-ui)
+[](https://gophers.slack.com/archives/CQ7T0T1FW)
+
+A lightweight, real-time web interface for monitoring and controlling [gocron](https://github.com/go-co-op/gocron) scheduled jobs.
+
+If you want to chat, you can find us on Slack at
+[
](https://gophers.slack.com/archives/CQ7T0T1FW)
+
+
+

+
+
+## Installation
+
+```bash
+go get github.com/go-co-op/gocron-ui
+```
+
+## Quick Start
+
+```go
+package main
+
+import (
+ "log"
+ "net/http"
+ "time"
+
+ "github.com/go-co-op/gocron/v2"
+ "github.com/go-co-op/gocron-ui/server"
+)
+
+func main() {
+ // create a scheduler
+ scheduler, err := gocron.NewScheduler()
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ // add a job to the scheduler
+ _, err = scheduler.NewJob(
+ gocron.DurationJob(10*time.Second),
+ gocron.NewTask(func() {
+ log.Println("Job executed")
+ }),
+ gocron.WithName("example-job"),
+ gocron.WithTags("important"),
+ )
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ // start the scheduler
+ scheduler.Start()
+
+ // start the web UI server
+ srv := server.NewServer(scheduler, 8080)
+ // srv := server.NewServer(scheduler, 8080, server.WithTitle("My Custom Scheduler")) // with custom title if you want to customize the title of the UI (optional)
+ log.Println("GoCron UI available at http://localhost:8080")
+ log.Fatal(http.ListenAndServe(":8080", srv.Router))
+}
+```
+
+Open your browser to `http://localhost:8080` to view the dashboard.
+
+## Features
+
+- **Real-time Monitoring** - WebSocket-based live job status updates
+- **Job Control** - Trigger jobs manually or remove them from the scheduler
+- **Schedule Preview** - View upcoming executions for each job
+- **Tagging System** - Organize and filter jobs by tags
+- **Configurable Title** - Customize the UI header and page title to match your needs
+- **Embedded UI** - Static files compiled into binary, zero external dependencies
+- **Portable** - Single self-contained binary deployment
+
+## API Reference
+
+### REST Endpoints
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| `GET` | `/api/config` | Get server configuration |
+| `GET` | `/api/jobs` | List all jobs |
+| `GET` | `/api/jobs/{id}` | Get job details |
+| `POST` | `/api/jobs/{id}/run` | Execute job immediately |
+| `DELETE` | `/api/jobs/{id}` | Remove job from scheduler |
+| `POST` | `/api/scheduler/start` | Start the scheduler |
+| `POST` | `/api/scheduler/stop` | Stop the scheduler |
+
+### WebSocket
+
+Connect to `ws://localhost:8080/ws` for real-time job updates.
+
+**Message Format:**
+```json
+{
+ "type": "jobs",
+ "data": [
+ {
+ "id": "uuid",
+ "name": "job-name",
+ "tags": ["tag1", "tag2"],
+ "nextRun": "2025-10-07T15:30:00Z",
+ "lastRun": "2025-10-07T15:29:50Z",
+ "nextRuns": ["...", "..."],
+ "schedule": "Every 10 seconds",
+ "scheduleDetail": "Duration: 10s"
+ }
+ ]
+}
+```
+
+## Examples
+
+### Comprehensive Example
+
+A full-featured example demonstrating 14 different job types is available in the [exmaples](./exmaples/) directory:
+
+```bash
+cd exmaples
+go run main.go
+```
+
+**Demonstrates:**
+- Interval-based jobs (duration, random intervals)
+- Cron expression scheduling
+- Daily and weekly jobs
+- Parameterized jobs with custom arguments
+- Context-aware jobs
+- Singleton mode (prevent overlapping executions)
+- Limited run jobs
+- Event listeners (before/after job runs)
+- One-time scheduled jobs
+- Batch processing patterns
+- Health check monitoring
+
+Visit `http://localhost:8080` to see the UI in action.
+
+## Deployment
+
+### Binary Distribution
+
+```bash
+# Build
+go build -o gocron-ui
+
+# Run
+./gocron-ui
+```
+
+The binary is self-contained and requires no external files or dependencies.
+
+### Docker
+
+```bash
+docker build -t gocron-ui .
+docker run -p 8080:8080 gocron-ui
+```
+
+See [Dockerfile](./Dockerfile) for details.
+
+## Configuration
+
+The server accepts the following configuration through the `NewServer` function:
+
+```go
+server.NewServer(scheduler gocron.Scheduler, port int, opts ...ServerOption) *Server
+```
+
+**Parameters:**
+- `scheduler` - Your configured gocron scheduler instance
+- `port` - HTTP port to listen on
+- `opts` - Optional configuration settings
+
+### Configuration Options
+
+#### Custom Title
+
+You can customize the UI title using the `WithTitle` option:
+
+```go
+srv := server.NewServer(scheduler, 8080, server.WithTitle("My Custom Scheduler"))
+```
+
+This will update both the browser tab title and the header title in the UI. When using a custom title, the UI automatically displays a subtle "powered by gocron-ui" attribution below the title.
+
+#### Command-line Example
+
+You can also make the title configurable via command-line flags:
+
+```go
+func main() {
+ port := flag.Int("port", 8080, "Port to run the server on")
+ title := flag.String("title", "GoCron UI", "Custom title for the UI")
+ flag.Parse()
+
+ scheduler, _ := gocron.NewScheduler()
+ // ... add jobs ...
+ scheduler.Start()
+
+ srv := server.NewServer(scheduler, *port, server.WithTitle(*title))
+ log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", *port), srv.Router))
+}
+```
+
+Then run with:
+```bash
+go run main.go -port 8080 -title "My Awesome Scheduler"
+```
+
+## Important Notes
+
+### Job Creation Limitation
+
+GoCron UI is a **monitoring and control interface** for jobs defined in your Go code. Jobs cannot be created from the UI because they require compiled Go functions to execute. The UI provides:
+
+- ✅ Real-time monitoring
+- ✅ Manual job triggering
+- ✅ Job deletion
+- ✅ Schedule viewing
+- ❌ Job creation (must be done in code)
+
+## Production Considerations
+
+- **Authentication**: This package does not include authentication. Implement your own auth middleware if deploying publicly.
+- **CORS**: Default CORS settings allow all origins. Restrict this in production environments.
+- **Error Handling**: Implement proper error logging and monitoring for production use.
+
+## Maintainers
+- [@iyashjayesh](https://github.com/iyashjayesh) | Maintainer
+
+## Star History
+
+
+
+
+
+
+
+
diff --git a/demo.gif b/demo.gif
new file mode 100644
index 0000000..b8bc36b
Binary files /dev/null and b/demo.gif differ
diff --git a/exmaples/.gitignore b/exmaples/.gitignore
new file mode 100644
index 0000000..e7fbe23
--- /dev/null
+++ b/exmaples/.gitignore
@@ -0,0 +1,10 @@
+# Compiled binary
+example
+
+# Go build artifacts
+*.exe
+*.exe~
+*.dll
+*.so
+*.dylib
+
diff --git a/exmaples/README.md b/exmaples/README.md
new file mode 100644
index 0000000..0b6928d
--- /dev/null
+++ b/exmaples/README.md
@@ -0,0 +1,244 @@
+# GoCron UI - Examples
+
+Get up and running with GoCron UI in **under 60 seconds** and explore 14 different job types demonstrating the full capabilities of gocron.
+
+## Quick Start
+
+```bash
+# run with default settings (port 8080)
+go run main.go
+
+# or specify a custom port
+go run main.go -port 3000
+```
+
+**Open your browser:** http://localhost:8080
+
+### Build Binary (Optional)
+
+```bash
+go build -o example main.go
+./example
+```
+
+The binary is self-contained and can run from anywhere!
+
+---
+
+## What You'll See
+
+A real-time dashboard with **14 different jobs** demonstrating:
+
+| # | Job Type | Description | Interval |
+|---|----------|-------------|----------|
+| 1 | Simple Interval | Basic duration-based execution | 10s |
+| 2 | Fast Job | High-frequency execution | 5s |
+| 3 | Cron Job | Cron expression scheduling | Every minute |
+| 4 | Daily Job | Daily at specific time | 2:30 PM |
+| 5 | Weekly Job | Weekly on specific days | Mon, Wed, Fri at 9:00 AM |
+| 6 | Parameterized Job | Job with custom parameters | 12s |
+| 7 | Context-Aware Job | Job using context | 8s |
+| 8 | Random Interval Job | Variable timing | 5-15s (random) |
+| 9 | Singleton Mode Job | Prevents overlapping executions | 5s (runs for 8s) |
+| 10 | Limited Run Job | Executes limited times | 7s (3 times only) |
+| 11 | Event Listener Job | Monitors job lifecycle | 15s |
+| 12 | One-Time Job | Runs once at scheduled time | 30s after start |
+| 13 | Data Processor Job | Simulates batch processing | 20s |
+| 14 | Health Check Job | Monitors system health | 30s |
+
+### UI Features
+
+| Feature | Description |
+|---------|-------------|
+| **Real-time Updates** | Live WebSocket-based status updates |
+| **Manual Triggers** | Execute any job on-demand |
+| **Job Deletion** | Remove jobs from scheduler |
+| **Schedule Preview** | View next 5 scheduled runs |
+| **Tag Filtering** | Organize jobs with tags |
+
+---
+
+## Key Concepts Demonstrated
+
+### Job Definitions
+
+```go
+// duration-based
+gocron.DurationJob(10*time.Second)
+
+// cron expression
+gocron.CronJob("* * * * *", false)
+
+// daily at specific time
+gocron.DailyJob(1, gocron.NewAtTimes(gocron.NewAtTime(14, 30, 0)))
+
+// weekly on specific days
+gocron.WeeklyJob(1, gocron.NewWeekdays(time.Monday, time.Wednesday, time.Friday),
+ gocron.NewAtTimes(gocron.NewAtTime(9, 0, 0)))
+
+// random interval
+gocron.DurationRandomJob(5*time.Second, 15*time.Second)
+
+// one-time execution
+gocron.OneTimeJob(gocron.OneTimeJobStartDateTime(time.Now().Add(30*time.Second)))
+```
+
+### Job Options
+
+```go
+// set job name
+gocron.WithName("my-job")
+
+// add tags for organization
+gocron.WithTags("production", "critical")
+
+// prevent overlapping executions
+gocron.WithSingletonMode(gocron.LimitModeReschedule)
+
+// limit total executions
+gocron.WithLimitedRuns(3)
+
+// add lifecycle event listeners
+gocron.WithEventListeners(
+ gocron.BeforeJobRuns(func(jobID uuid.UUID, jobName string) {
+ log.Printf("Starting: %s", jobName)
+ }),
+ gocron.AfterJobRuns(func(jobID uuid.UUID, jobName string) {
+ log.Printf("Completed: %s", jobName)
+ }),
+)
+```
+
+### Task Functions
+
+```go
+// simple task
+gocron.NewTask(func() {
+ log.Println("Task executed")
+})
+
+// task with parameters
+gocron.NewTask(func(name string, count int) {
+ log.Printf("Processing: %s (%d)", name, count)
+}, "data", 42)
+
+// task with context
+gocron.NewTask(func(ctx context.Context) {
+ select {
+ case <-ctx.Done():
+ log.Println("Task cancelled")
+ default:
+ log.Println("Task running")
+ }
+})
+```
+
+---
+
+## API Usage
+
+### REST Endpoints
+
+```bash
+# list all jobs
+curl http://localhost:8080/api/jobs
+
+# get specific job
+curl http://localhost:8080/api/jobs/{job-id}
+
+# run job immediately
+curl -X POST http://localhost:8080/api/jobs/{job-id}/run
+
+# delete job
+curl -X DELETE http://localhost:8080/api/jobs/{job-id}
+
+# control scheduler
+curl -X POST http://localhost:8080/api/scheduler/stop
+curl -X POST http://localhost:8080/api/scheduler/start
+```
+
+### WebSocket Connection
+
+```javascript
+const ws = new WebSocket('ws://localhost:8080/ws');
+
+ws.onmessage = (event) => {
+ const data = JSON.parse(event.data);
+ console.log('Jobs update:', data);
+};
+
+ws.onopen = () => {
+ console.log('Connected to GoCron UI');
+};
+```
+
+---
+
+## Advanced Features
+
+### Singleton Mode
+
+Prevents concurrent executions (critical for long-running jobs):
+
+```go
+gocron.WithSingletonMode(gocron.LimitModeReschedule)
+```
+
+### Event Listeners
+
+Monitor job lifecycle events:
+
+```go
+gocron.WithEventListeners(
+ gocron.BeforeJobRuns(beforeFunc),
+ gocron.AfterJobRuns(afterFunc),
+ gocron.AfterJobRunsWithError(errorFunc),
+ gocron.AfterJobRunsWithPanic(panicFunc),
+)
+```
+
+### Graceful Shutdown
+
+```go
+// wait for interrupt signal
+quit := make(chan os.Signal, 1)
+signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
+<-quit
+
+// shutdown scheduler
+scheduler.Shutdown()
+```
+
+Press `Ctrl+C` to trigger graceful shutdown.
+
+---
+
+## Troubleshooting
+
+### Port Already in Use
+
+```bash
+go run main.go -port 3000
+```
+
+### Jobs Not Showing
+
+1. Ensure scheduler is started: `scheduler.Start()`
+2. Check WebSocket connection in browser console
+3. Review server logs for errors
+
+---
+
+## Learn More
+
+| Resource | Link |
+|----------|------|
+| **GoCron Docs** | https://github.com/go-co-op/gocron |
+| **GoCron UI** | https://github.com/go-co-op/gocron-ui |
+| **Go Package** | https://pkg.go.dev/github.com/go-co-op/gocron/v2 |
+| **Slack** | [#gocron channel](https://gophers.slack.com/archives/CQ7T0T1FW) |
+| **Issues** | [GitHub Issues](https://github.com/go-co-op/gocron-ui/issues) |
+
+---
+
+**Happy Scheduling!**
diff --git a/exmaples/main.go b/exmaples/main.go
new file mode 100644
index 0000000..0df6cb6
--- /dev/null
+++ b/exmaples/main.go
@@ -0,0 +1,276 @@
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "log"
+ "math/rand"
+ "net/http"
+ "os"
+ "os/signal"
+ "strings"
+ "syscall"
+ "time"
+
+ "github.com/go-co-op/gocron-ui/server"
+ "github.com/go-co-op/gocron/v2"
+ "github.com/google/uuid"
+)
+
+func main() {
+ port := flag.Int("port", 8080, "Port to run the server on")
+ title := flag.String("title", "GoCron Scheduler", "Custom title for the UI")
+ flag.Parse()
+
+ // create the gocron scheduler
+ scheduler, err := gocron.NewScheduler()
+ if err != nil {
+ log.Fatalf("Failed to create scheduler: %v", err)
+ }
+
+ // example 1: Simple interval job - runs every 10 seconds
+ _, err = scheduler.NewJob(
+ gocron.DurationJob(10*time.Second),
+ gocron.NewTask(func() {
+ log.Println("Running 10-second interval job")
+ }),
+ gocron.WithName("simple-10s-interval"),
+ gocron.WithTags("interval", "simple"),
+ )
+ if err != nil {
+ log.Printf("Error creating simple interval job: %v", err)
+ }
+
+ // example 2: Fast job - runs every 5 seconds
+ _, err = scheduler.NewJob(
+ gocron.DurationJob(5*time.Second),
+ gocron.NewTask(func() {
+ log.Println("Fast 5-second job executed")
+ }),
+ gocron.WithName("fast-5s-job"),
+ gocron.WithTags("interval", "fast"),
+ )
+ if err != nil {
+ log.Printf("Error creating fast job: %v", err)
+ }
+
+ // example 3: Cron job - runs every minute
+ _, err = scheduler.NewJob(
+ gocron.CronJob("* * * * *", false),
+ gocron.NewTask(func() {
+ log.Println("Cron job executed (every minute)")
+ }),
+ gocron.WithName("cron-every-minute"),
+ gocron.WithTags("cron", "periodic"),
+ )
+ if err != nil {
+ log.Printf("Error creating cron job: %v", err)
+ }
+
+ // example 4: Daily job at specific time
+ _, err = scheduler.NewJob(
+ gocron.DailyJob(1, gocron.NewAtTimes(
+ gocron.NewAtTime(14, 30, 0), // 2:30 PM
+ )),
+ gocron.NewTask(func() {
+ log.Println("Daily job executed at 2:30 PM")
+ }),
+ gocron.WithName("daily-afternoon-report"),
+ gocron.WithTags("daily", "report"),
+ )
+ if err != nil {
+ log.Printf("Error creating daily job: %v", err)
+ }
+
+ // example 5: Weekly job - runs on specific days
+ _, err = scheduler.NewJob(
+ gocron.WeeklyJob(1, gocron.NewWeekdays(time.Monday, time.Wednesday, time.Friday),
+ gocron.NewAtTimes(gocron.NewAtTime(9, 0, 0))),
+ gocron.NewTask(func() {
+ log.Println("Weekly job executed (Mon, Wed, Fri at 9:00 AM)")
+ }),
+ gocron.WithName("weekly-mwf-morning"),
+ gocron.WithTags("weekly", "morning", "report"),
+ )
+ if err != nil {
+ log.Printf("Error creating weekly job: %v", err)
+ }
+
+ // example 6: Job with parameters
+ _, err = scheduler.NewJob(
+ gocron.DurationJob(12*time.Second),
+ gocron.NewTask(func(name string, count int) {
+ log.Printf("Job with parameters: name=%s, count=%d", name, count)
+ }, "example-job", 42),
+ gocron.WithName("parameterized-job"),
+ gocron.WithTags("parameters", "demo"),
+ )
+ if err != nil {
+ log.Printf("Error creating parameterized job: %v", err)
+ }
+
+ // example 7: Job with context
+ _, err = scheduler.NewJob(
+ gocron.DurationJob(8*time.Second),
+ gocron.NewTask(func(ctx context.Context) {
+ log.Printf("Job with context executed, context: %v", ctx)
+ }),
+ gocron.WithName("context-aware-job"),
+ gocron.WithTags("context", "advanced"),
+ )
+ if err != nil {
+ log.Printf("Error creating context job: %v", err)
+ }
+
+ // example 8: Random duration job
+ _, err = scheduler.NewJob(
+ gocron.DurationRandomJob(5*time.Second, 15*time.Second),
+ gocron.NewTask(func() {
+ log.Println("Random interval job executed (5-15 seconds)")
+ }),
+ gocron.WithName("random-interval-job"),
+ gocron.WithTags("random", "variable"),
+ )
+ if err != nil {
+ log.Printf("Error creating random job: %v", err)
+ }
+
+ // example 9: Job with singleton mode (prevents overlapping runs)
+ _, err = scheduler.NewJob(
+ gocron.DurationJob(5*time.Second),
+ gocron.NewTask(func() {
+ log.Println("Singleton job started")
+ time.Sleep(8 * time.Second) // Simulate long-running task
+ log.Println("Singleton job completed")
+ }),
+ gocron.WithName("singleton-mode-job"),
+ gocron.WithTags("singleton", "long-running"),
+ gocron.WithSingletonMode(gocron.LimitModeReschedule),
+ )
+ if err != nil {
+ log.Printf("Error creating singleton job: %v", err)
+ }
+
+ // example 10: Limited run job (runs only 3 times)
+ _, err = scheduler.NewJob(
+ gocron.DurationJob(7*time.Second),
+ gocron.NewTask(func() {
+ log.Println("Limited run job executed")
+ }),
+ gocron.WithName("limited-run-job"),
+ gocron.WithTags("limited", "demo"),
+ gocron.WithLimitedRuns(3),
+ )
+ if err != nil {
+ log.Printf("Error creating limited run job: %v", err)
+ }
+
+ // example 11: Job with event listeners
+ _, err = scheduler.NewJob(
+ gocron.DurationJob(15*time.Second),
+ gocron.NewTask(func() {
+ log.Println("Job with listeners executed")
+ // Simulate some work
+ time.Sleep(time.Duration(rand.Intn(3)+1) * time.Second)
+ }),
+ gocron.WithName("event-listener-job"),
+ gocron.WithTags("events", "monitoring"),
+ gocron.WithEventListeners(
+ gocron.AfterJobRuns(func(_ uuid.UUID, jobName string) {
+ log.Printf(" → AfterJobRuns: %s completed", jobName)
+ }),
+ gocron.BeforeJobRuns(func(_ uuid.UUID, jobName string) {
+ log.Printf(" → BeforeJobRuns: %s starting", jobName)
+ }),
+ ),
+ )
+ if err != nil {
+ log.Printf("Error creating event listener job: %v", err)
+ }
+
+ // example 12: One-time job (runs once at a specific time)
+ oneTimeAt := time.Now().Add(30 * time.Second)
+ _, err = scheduler.NewJob(
+ gocron.OneTimeJob(gocron.OneTimeJobStartDateTime(oneTimeAt)),
+ gocron.NewTask(func() {
+ log.Println("One-time job executed!")
+ }),
+ gocron.WithName("one-time-job"),
+ gocron.WithTags("onetime", "scheduled"),
+ )
+ if err != nil {
+ log.Printf("Error creating one-time job: %v", err)
+ }
+
+ // example 13: Job that simulates data processing
+ _, err = scheduler.NewJob(
+ gocron.DurationJob(20*time.Second),
+ gocron.NewTask(func() {
+ items := rand.Intn(100) + 1
+ log.Printf("Processing %d items...", items)
+ time.Sleep(2 * time.Second)
+ log.Printf("Successfully processed %d items", items)
+ }),
+ gocron.WithName("data-processor-job"),
+ gocron.WithTags("processing", "batch"),
+ )
+ if err != nil {
+ log.Printf("Error creating data processor job: %v", err)
+ }
+
+ // example 14: Health check job
+ _, err = scheduler.NewJob(
+ gocron.DurationJob(30*time.Second),
+ gocron.NewTask(func() {
+ status := "healthy"
+ if rand.Float32() < 0.1 { // 10% chance of unhealthy
+ status = "degraded"
+ }
+ log.Printf("Health check: System is %s", status)
+ }),
+ gocron.WithName("health-check-job"),
+ gocron.WithTags("monitoring", "health"),
+ )
+ if err != nil {
+ log.Printf("Error creating health check job: %v", err)
+ }
+
+ // start the scheduler
+ scheduler.Start()
+ log.Println("Scheduler started with", len(scheduler.Jobs()), "jobs")
+
+ // create and start the API server with custom title
+ srv := server.NewServer(scheduler, *port, server.WithTitle(*title))
+
+ // start server in a goroutine
+ go func() {
+ addr := fmt.Sprintf(":%d", *port)
+ log.Println("\n" + strings.Repeat("=", 70))
+ log.Printf("GoCron UI Server Started")
+ log.Println(strings.Repeat("=", 70))
+ log.Printf("Web UI: http://localhost%s", addr)
+ log.Printf("API: http://localhost%s/api", addr)
+ log.Printf("WebSocket: ws://localhost%s/ws", addr)
+ log.Printf("Total Jobs: %d", len(scheduler.Jobs()))
+ log.Println(strings.Repeat("=", 70) + "\n")
+
+ if err := http.ListenAndServe(addr, srv.Router); err != nil {
+ log.Fatalf("Server failed to start: %v", err)
+ }
+ }()
+
+ // wait for interrupt signal to gracefully shutdown
+ quit := make(chan os.Signal, 1)
+ signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
+ <-quit
+
+ log.Println("\nShutting down server...")
+
+ // shutdown scheduler
+ if err := scheduler.Shutdown(); err != nil {
+ log.Printf("Error shutting down scheduler: %v", err)
+ }
+
+ log.Println("Server stopped gracefully")
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..aa5dae9
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,16 @@
+module github.com/go-co-op/gocron-ui
+
+go 1.23.0
+
+require (
+ github.com/go-co-op/gocron/v2 v2.16.6
+ github.com/google/uuid v1.6.0
+ github.com/gorilla/mux v1.8.1
+ github.com/gorilla/websocket v1.5.3
+ github.com/rs/cors v1.11.1
+)
+
+require (
+ github.com/jonboulle/clockwork v0.5.0 // indirect
+ github.com/robfig/cron/v3 v3.0.1 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..5bbf2b5
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,24 @@
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/go-co-op/gocron/v2 v2.16.6 h1:zI2Ya9sqvuLcgqJgV79LwoJXM8h20Z/drtB7ATbpRWo=
+github.com/go-co-op/gocron/v2 v2.16.6/go.mod h1:zAfC/GFQ668qHxOVl/D68Jh5Ce7sDqX6TJnSQyRkRBc=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
+github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
+github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
+github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
+github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+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/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
+github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/server/server.go b/server/server.go
new file mode 100644
index 0000000..fd813e1
--- /dev/null
+++ b/server/server.go
@@ -0,0 +1,500 @@
+package server
+
+import (
+ "embed"
+ "encoding/json"
+ "fmt"
+ "io/fs"
+ "log"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/go-co-op/gocron/v2"
+ "github.com/google/uuid"
+ "github.com/gorilla/mux"
+ "github.com/gorilla/websocket"
+ "github.com/rs/cors"
+)
+
+//go:embed static/*
+var staticFiles embed.FS
+
+// Server is the main server struct which contains the scheduler, router, webSocket clients, webSocket mutex, upgrader, and config
+type Server struct {
+ Scheduler gocron.Scheduler
+ Router http.Handler
+ wsClients map[*websocket.Conn]bool
+ wsMutex sync.RWMutex
+ upgrader websocket.Upgrader
+ config Config
+}
+
+// Config is the server configuration in which user can set the title of the UI
+type Config struct {
+ Title string `json:"title"`
+}
+
+// NewServer creates a new server instance
+func NewServer(scheduler gocron.Scheduler, _ int, opts ...Option) *Server {
+ s := &Server{
+ Scheduler: scheduler,
+ wsClients: make(map[*websocket.Conn]bool),
+ upgrader: websocket.Upgrader{
+ CheckOrigin: func(_ *http.Request) bool {
+ return true // allow all origins for development
+ },
+ },
+ config: Config{
+ Title: "GoCron UI", // default title
+ },
+ }
+
+ // apply options
+ for _, opt := range opts {
+ opt(s)
+ }
+
+ router := mux.NewRouter()
+
+ // api routes
+ api := router.PathPrefix("/api").Subrouter()
+ api.HandleFunc("/config", s.GetConfig).Methods("GET")
+ api.HandleFunc("/jobs", s.GetJobs).Methods("GET")
+ api.HandleFunc("/jobs", s.CreateJob).Methods("POST")
+ api.HandleFunc("/jobs/{id}", s.GetJob).Methods("GET")
+ api.HandleFunc("/jobs/{id}", s.DeleteJob).Methods("DELETE")
+ api.HandleFunc("/jobs/{id}/run", s.RunJob).Methods("POST")
+ api.HandleFunc("/scheduler/stop", s.StopScheduler).Methods("POST")
+ api.HandleFunc("/scheduler/start", s.StartScheduler).Methods("POST")
+
+ // webSocket route
+ router.HandleFunc("/ws", s.HandleWebSocket)
+
+ // serve embedded static files (frontend)
+ staticFS, err := fs.Sub(staticFiles, "static")
+ if err != nil {
+ log.Fatalf("Failed to load static files: %v", err)
+ }
+ router.PathPrefix("/").Handler(http.FileServer(http.FS(staticFS)))
+
+ // setup CORS
+ c := cors.New(cors.Options{
+ AllowedOrigins: []string{"*"},
+ AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
+ AllowedHeaders: []string{"*"},
+ AllowCredentials: true,
+ })
+
+ s.Router = c.Handler(router)
+
+ // start broadcasting job updates
+ go s.broadcastJobUpdates()
+
+ return s
+}
+
+// Option is a functional option for configuring the server
+type Option func(*Server)
+
+// WithTitle sets a custom title for the UI
+func WithTitle(title string) Option {
+ return func(s *Server) {
+ s.config.Title = title
+ }
+}
+
+// GetConfig gets server configuration
+func (s *Server) GetConfig(w http.ResponseWriter, _ *http.Request) {
+ respondJSON(w, http.StatusOK, s.config)
+}
+
+// HandleWebSocket is a webSocket handler
+func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
+ conn, err := s.upgrader.Upgrade(w, r, nil)
+ if err != nil {
+ log.Printf("WebSocket upgrade error: %v", err)
+ return
+ }
+ defer conn.Close()
+
+ s.wsMutex.Lock()
+ s.wsClients[conn] = true
+ s.wsMutex.Unlock()
+
+ log.Printf("WebSocket client connected. Total clients: %d", len(s.wsClients))
+
+ // send initial job list
+ jobs := s.getJobsData()
+ if err := conn.WriteJSON(map[string]interface{}{
+ "type": "jobs",
+ "data": jobs,
+ }); err != nil {
+ log.Printf("Error sending initial jobs: %v", err)
+ }
+
+ // keep connection alive and handle client disconnection
+ for {
+ _, _, err := conn.ReadMessage()
+ if err != nil {
+ s.wsMutex.Lock()
+ delete(s.wsClients, conn)
+ s.wsMutex.Unlock()
+ log.Printf("WebSocket client disconnected. Total clients: %d", len(s.wsClients))
+ break
+ }
+ }
+}
+
+// broadcastJobUpdates broadcasts job updates to all connected webSocket clients
+func (s *Server) broadcastJobUpdates() {
+ ticker := time.NewTicker(1 * time.Second)
+ defer ticker.Stop()
+
+ for range ticker.C {
+ s.wsMutex.RLock()
+ if len(s.wsClients) == 0 {
+ s.wsMutex.RUnlock()
+ continue
+ }
+ s.wsMutex.RUnlock()
+
+ jobs := s.getJobsData()
+ message := map[string]interface{}{
+ "type": "jobs",
+ "data": jobs,
+ }
+
+ s.wsMutex.RLock()
+ for client := range s.wsClients {
+ err := client.WriteJSON(message)
+ if err != nil {
+ log.Printf("Error broadcasting to client: %v", err)
+ s.wsMutex.RUnlock()
+ s.wsMutex.Lock()
+ delete(s.wsClients, client)
+ client.Close()
+ s.wsMutex.Unlock()
+ s.wsMutex.RLock()
+ }
+ }
+ s.wsMutex.RUnlock()
+ }
+}
+
+// GetJobs gets all jobs
+func (s *Server) GetJobs(w http.ResponseWriter, _ *http.Request) {
+ jobs := s.getJobsData()
+ respondJSON(w, http.StatusOK, jobs)
+}
+
+// GetJob gets a single job
+func (s *Server) GetJob(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ idStr := vars["id"]
+
+ id, err := uuid.Parse(idStr)
+ if err != nil {
+ respondError(w, http.StatusBadRequest, "Invalid job ID")
+ return
+ }
+
+ jobs := s.Scheduler.Jobs()
+ for _, job := range jobs {
+ if job.ID() == id {
+ jobData := s.convertJobToData(job)
+ respondJSON(w, http.StatusOK, jobData)
+ return
+ }
+ }
+
+ respondError(w, http.StatusNotFound, "Job not found")
+}
+
+// CreateJob creates a new job
+func (s *Server) CreateJob(w http.ResponseWriter, r *http.Request) {
+ var req CreateJobRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ respondError(w, http.StatusBadRequest, "Invalid request body")
+ return
+ }
+
+ if req.Name == "" {
+ respondError(w, http.StatusBadRequest, "Job name is required")
+ return
+ }
+
+ // create job definition based on type
+ var jobDef gocron.JobDefinition
+ var err error
+
+ switch req.Type {
+ case "duration":
+ if req.Interval <= 0 {
+ respondError(w, http.StatusBadRequest, "Interval must be positive for duration jobs")
+ return
+ }
+ jobDef = gocron.DurationJob(time.Duration(req.Interval) * time.Second)
+
+ case "cron":
+ if req.CronExpression == "" {
+ respondError(w, http.StatusBadRequest, "Cron expression is required for cron jobs")
+ return
+ }
+ jobDef = gocron.CronJob(req.CronExpression, false)
+
+ case "daily":
+ if req.Interval <= 0 {
+ respondError(w, http.StatusBadRequest, "Interval must be positive for daily jobs")
+ return
+ }
+ if req.AtTime == "" {
+ respondError(w, http.StatusBadRequest, "AtTime is required for daily jobs")
+ return
+ }
+ atTime, err := parseTime(req.AtTime)
+ if err != nil {
+ respondError(w, http.StatusBadRequest, "Invalid time format. Use HH:MM:SS")
+ return
+ }
+ jobDef = gocron.DailyJob(uint(req.Interval), gocron.NewAtTimes(atTime))
+
+ default:
+ respondError(w, http.StatusBadRequest, "Invalid job type. Supported: duration, cron, daily")
+ return
+ }
+
+ // create the task (example: just logs the job name)
+ task := gocron.NewTask(func() {
+ log.Printf("Executing job: %s", req.Name)
+ })
+
+ // create job options
+ options := []gocron.JobOption{
+ gocron.WithName(req.Name),
+ }
+ if len(req.Tags) > 0 {
+ options = append(options, gocron.WithTags(req.Tags...))
+ }
+
+ // add job to scheduler
+ job, err := s.Scheduler.NewJob(jobDef, task, options...)
+ if err != nil {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+
+ jobData := s.convertJobToData(job)
+ respondJSON(w, http.StatusCreated, jobData)
+}
+
+// DeleteJob deletes a job
+func (s *Server) DeleteJob(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ idStr := vars["id"]
+
+ id, err := uuid.Parse(idStr)
+ if err != nil {
+ respondError(w, http.StatusBadRequest, "Invalid job ID")
+ return
+ }
+
+ if err := s.Scheduler.RemoveJob(id); err != nil { // remove job from scheduler using the job ID & RemoveJob is a method of the Scheduler interface
+ respondError(w, http.StatusNotFound, "Job not found")
+ return
+ }
+
+ respondJSON(w, http.StatusOK, map[string]string{"message": "Job deleted successfully"})
+}
+
+// RunJob runs a job immediately
+func (s *Server) RunJob(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ idStr := vars["id"]
+
+ id, err := uuid.Parse(idStr)
+ if err != nil {
+ respondError(w, http.StatusBadRequest, "Invalid job ID")
+ return
+ }
+
+ jobs := s.Scheduler.Jobs()
+ for _, job := range jobs {
+ if job.ID() == id {
+ if err := job.RunNow(); err != nil {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ respondJSON(w, http.StatusOK, map[string]string{"message": "Job executed"})
+ return
+ }
+ }
+
+ respondError(w, http.StatusNotFound, "Job not found")
+}
+
+// StopScheduler stops the scheduler
+func (s *Server) StopScheduler(w http.ResponseWriter, _ *http.Request) {
+ if err := s.Scheduler.StopJobs(); err != nil {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ respondJSON(w, http.StatusOK, map[string]string{"message": "Scheduler stopped"})
+}
+
+// StartScheduler starts the scheduler
+func (s *Server) StartScheduler(w http.ResponseWriter, _ *http.Request) {
+ s.Scheduler.Start()
+ respondJSON(w, http.StatusOK, map[string]string{"message": "Scheduler started"})
+}
+
+// helper functions
+func (s *Server) getJobsData() []JobData {
+ jobs := s.Scheduler.Jobs()
+ result := make([]JobData, 0, len(jobs))
+ for _, job := range jobs {
+ result = append(result, s.convertJobToData(job))
+ }
+ return result
+}
+
+func (s *Server) convertJobToData(job gocron.Job) JobData {
+ nextRun, _ := job.NextRun()
+ lastRun, _ := job.LastRun()
+
+ // get next 5 runs
+ nextRuns, _ := job.NextRuns(5)
+
+ // determine schedule info based on job name patterns or intervals
+ schedule, scheduleDetail := s.inferSchedule(job, nextRuns)
+
+ return JobData{
+ ID: job.ID().String(),
+ Name: job.Name(),
+ Tags: job.Tags(),
+ NextRun: formatTime(nextRun),
+ LastRun: formatTime(lastRun),
+ NextRuns: formatTimes(nextRuns),
+ Schedule: schedule,
+ ScheduleDetail: scheduleDetail,
+ }
+}
+
+func (s *Server) inferSchedule(job gocron.Job, nextRuns []time.Time) (string, string) {
+ name := job.Name()
+
+ // try to infer from job name
+ if len(name) > 0 {
+ // check for common patterns in name
+ if strings.Contains(name, "every") || strings.Contains(name, "interval") {
+ if strings.Contains(name, "10s") || strings.Contains(name, "10-s") {
+ return "Every 10 seconds", "Duration: 10s"
+ }
+ if strings.Contains(name, "5s") {
+ return "Every 5 seconds", "Duration: 5s"
+ }
+ if strings.Contains(name, "minute") {
+ return "Every minute", "Duration: 1m"
+ }
+ }
+
+ if strings.Contains(name, "cron") {
+ return "Cron schedule", "Cron: * * * * *"
+ }
+
+ if strings.Contains(name, "daily") {
+ return "Daily", "Daily schedule"
+ }
+
+ if strings.Contains(name, "weekly") {
+ return "Weekly", "Weekly: Mon, Wed, Fri"
+ }
+
+ if strings.Contains(name, "random") {
+ return "Random interval", "Random: 5-15s"
+ }
+
+ if strings.Contains(name, "singleton") {
+ return "Every 5 seconds (singleton)", "Duration: 5s, Mode: Singleton"
+ }
+
+ if strings.Contains(name, "limited") {
+ return "Every 7 seconds (limited)", "Duration: 7s, Max runs: 3"
+ }
+
+ if strings.Contains(name, "parameter") {
+ return "Every 12 seconds", "Duration: 12s"
+ }
+
+ if strings.Contains(name, "context") {
+ return "Every 8 seconds", "Duration: 8s"
+ }
+
+ if strings.Contains(name, "one-time") || strings.Contains(name, "onetime") {
+ return "One time only", "OneTime job"
+ }
+ }
+
+ // try to infer from next runs interval
+ if len(nextRuns) >= 2 {
+ interval := nextRuns[1].Sub(nextRuns[0])
+
+ if interval < time.Minute {
+ seconds := int(interval.Seconds())
+ return fmt.Sprintf("Every %d seconds", seconds), fmt.Sprintf("Duration: %ds", seconds)
+ }
+ if interval < time.Hour {
+ minutes := int(interval.Minutes())
+ return fmt.Sprintf("Every %d minutes", minutes), fmt.Sprintf("Duration: %dm", minutes)
+ }
+ if interval < 24*time.Hour {
+ hours := int(interval.Hours())
+ return fmt.Sprintf("Every %d hours", hours), fmt.Sprintf("Duration: %dh", hours)
+ }
+ days := int(interval.Hours() / 24)
+ return fmt.Sprintf("Every %d days", days), fmt.Sprintf("Duration: %dd", days)
+ }
+
+ return "Scheduled", "Custom schedule"
+}
+
+func formatTime(t time.Time) string {
+ if t.IsZero() {
+ return ""
+ }
+ return t.Format(time.RFC3339)
+}
+
+func formatTimes(times []time.Time) []string {
+ result := make([]string, 0, len(times))
+ for _, t := range times {
+ result = append(result, formatTime(t))
+ }
+ return result
+}
+
+func parseTime(timeStr string) (gocron.AtTime, error) {
+ t, err := time.Parse("15:04:05", timeStr)
+ if err != nil {
+ // try without seconds
+ t, err = time.Parse("15:04", timeStr)
+ if err != nil {
+ return nil, err
+ }
+ }
+ return gocron.NewAtTime(uint(t.Hour()), uint(t.Minute()), uint(t.Second())), nil
+}
+
+// respondJSON responds with JSON
+func respondJSON(w http.ResponseWriter, status int, data interface{}) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ if err := json.NewEncoder(w).Encode(data); err != nil {
+ log.Printf("Error encoding JSON response: %v", err)
+ }
+}
+
+func respondError(w http.ResponseWriter, status int, message string) {
+ respondJSON(w, status, map[string]string{"error": message})
+}
diff --git a/server/static/app.js b/server/static/app.js
new file mode 100644
index 0000000..e8f543d
--- /dev/null
+++ b/server/static/app.js
@@ -0,0 +1,320 @@
+// State
+let jobs = [];
+let ws = null;
+let isConnected = false;
+let expandedSchedules = new Set(); // Track which job schedules are expanded
+
+// API Base URL
+const API_BASE = window.location.origin + '/api';
+const WS_URL = `ws://${window.location.host}/ws`;
+
+// initialize on page load
+document.addEventListener('DOMContentLoaded', () => {
+ loadConfig();
+ connectWebSocket();
+});
+
+// load server configuration
+async function loadConfig() {
+ try {
+ const response = await fetch(`${API_BASE}/config`);
+ if (response.ok) {
+ const config = await response.json();
+ if (config.title) {
+ // update page title
+ document.title = config.title;
+
+ // update header title
+ const mainTitle = document.getElementById('main-title');
+ const poweredBy = document.getElementById('powered-by');
+
+ if (mainTitle) {
+ mainTitle.textContent = config.title;
+ }
+
+ if (poweredBy && config.title !== 'GoCron UI') {
+ poweredBy.style.display = 'block';
+ }
+ }
+ }
+ } catch (err) {
+ console.error('Failed to load config:', err);
+ // fallback to default title
+ }
+}
+
+// webSocket connection
+function connectWebSocket() {
+ ws = new WebSocket(WS_URL);
+
+ ws.onopen = () => {
+ console.log('WebSocket connected');
+ isConnected = true;
+ updateConnectionStatus(true);
+ hideError();
+ };
+
+ ws.onmessage = (event) => {
+ try {
+ const message = JSON.parse(event.data);
+ if (message.type === 'jobs') {
+ jobs = message.data || [];
+ renderJobs();
+ }
+ } catch (err) {
+ console.error('Failed to parse WebSocket message:', err);
+ }
+ };
+
+ ws.onerror = (error) => {
+ console.error('WebSocket error:', error);
+ showError('Connection error. Retrying...');
+ };
+
+ ws.onclose = () => {
+ console.log('WebSocket disconnected');
+ isConnected = false;
+ updateConnectionStatus(false);
+ // reconnect after 3 seconds
+ setTimeout(connectWebSocket, 3000);
+ };
+}
+
+function updateConnectionStatus(connected) {
+ const statusEl = document.getElementById('connection-status');
+ if (connected) {
+ statusEl.className = 'status-indicator connected';
+ statusEl.textContent = '● Connected';
+ } else {
+ statusEl.className = 'status-indicator disconnected';
+ statusEl.textContent = '○ Disconnected';
+ }
+}
+
+// error handling
+function showError(message) {
+ const banner = document.getElementById('error-banner');
+ const messageEl = document.getElementById('error-message');
+ messageEl.textContent = message;
+ banner.style.display = 'flex';
+}
+
+function hideError() {
+ document.getElementById('error-banner').style.display = 'none';
+}
+
+// API Functions
+async function deleteJob(id) {
+ const response = await fetch(`${API_BASE}/jobs/${id}`, {
+ method: 'DELETE',
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to delete job');
+ }
+}
+
+async function runJob(id) {
+ const response = await fetch(`${API_BASE}/jobs/${id}/run`, {
+ method: 'POST',
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to run job');
+ }
+}
+
+// job actions
+async function handleRunJob(id) {
+ try {
+ await runJob(id);
+ hideError();
+ } catch (err) {
+ showError(err.message);
+ }
+}
+
+async function handleDeleteJob(id, name) {
+ if (!confirm(`Are you sure you want to delete job "${name}"?`)) {
+ return;
+ }
+
+ try {
+ await deleteJob(id);
+ } catch (err) {
+ showError(err.message);
+ }
+}
+
+// rendering
+function renderJobs() {
+ const container = document.getElementById('jobs-container');
+
+ // update job count in info banner
+ const jobCount = document.getElementById('job-count');
+ if (jobCount) {
+ jobCount.textContent = jobs ? jobs.length : 0;
+ }
+
+ // clean up expanded state for deleted jobs
+ if (jobs && jobs.length > 0) {
+ const currentJobIds = new Set(jobs.map(j => j.id));
+ expandedSchedules.forEach(id => {
+ if (!currentJobIds.has(id)) {
+ expandedSchedules.delete(id);
+ }
+ });
+ } else {
+ // no jobs, clear all expanded state
+ expandedSchedules.clear();
+ }
+
+ if (!jobs || jobs.length === 0) {
+ container.innerHTML = `
+
+
⏰
+
No Jobs Running
+
Jobs are defined in your Go code. Once you start your application with scheduled jobs, they will appear here.
+
+
+ `;
+ return;
+ }
+
+ container.innerHTML = `
+
+
Scheduled Jobs (${jobs.length})
+
+ ${jobs.map(job => renderJobCard(job)).join('')}
+
+
+ `;
+}
+
+function renderJobCard(job) {
+ const nextRun = job.nextRun ? formatDateTime(job.nextRun) : 'Never';
+ const lastRun = job.lastRun ? formatDateTime(job.lastRun) : 'Never';
+ const timeUntil = job.nextRun ? getTimeUntil(job.nextRun) : '';
+
+ return `
+
+
+
+ ${job.tags && job.tags.length > 0 ? `
+
+ ${job.tags.map(tag => `🏷️ ${escapeHtml(tag)}`).join('')}
+
+ ` : ''}
+
+ ${job.schedule ? `
+
+
Schedule:
+
+ ${escapeHtml(job.schedule)}
+ ${job.scheduleDetail ? `
+ ${escapeHtml(job.scheduleDetail)}
+ ` : ''}
+
+
+ ` : ''}
+
+
+
+ Next Run:
+
+ ${nextRun}
+ ${timeUntil ? `⏱️ ${timeUntil}` : ''}
+
+
+
+ Last Run:
+ ${lastRun}
+
+
+ Job ID:
+ ${job.id}
+
+
+
+ ${job.nextRuns && job.nextRuns.length > 0 ? `
+
+
+
+ ${job.nextRuns.map(run => `
+
📌 ${formatDateTime(run)}
+ `).join('')}
+
+
+ ` : ''}
+
+ `;
+}
+
+function toggleSchedule(jobId) {
+ const details = document.getElementById(`schedule-${jobId}`);
+ const icon = document.getElementById(`toggle-icon-${jobId}`);
+
+ if (details.style.display === 'none') {
+ details.style.display = 'block';
+ icon.textContent = '🔽';
+ expandedSchedules.add(jobId); // remember this is expanded
+ } else {
+ details.style.display = 'none';
+ icon.textContent = '▶️';
+ expandedSchedules.delete(jobId); // remember this is collapsed
+ }
+}
+
+// utility functions
+function formatDateTime(dateStr) {
+ if (!dateStr) return 'Never';
+ const date = new Date(dateStr);
+ return date.toLocaleString();
+}
+
+function getTimeUntil(dateStr) {
+ if (!dateStr) return '';
+ const date = new Date(dateStr);
+ const now = new Date();
+ const diff = date.getTime() - now.getTime();
+
+ if (diff < 0) return 'Overdue';
+
+ const seconds = Math.floor(diff / 1000);
+ const minutes = Math.floor(seconds / 60);
+ const hours = Math.floor(minutes / 60);
+ const days = Math.floor(hours / 24);
+
+ if (days > 0) return `in ${days}d ${hours % 24}h`;
+ if (hours > 0) return `in ${hours}h ${minutes % 60}m`;
+ if (minutes > 0) return `in ${minutes}m`;
+ return `in ${seconds}s`;
+}
+
+function escapeHtml(text) {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+}
diff --git a/server/static/index.html b/server/static/index.html
new file mode 100644
index 0000000..415b808
--- /dev/null
+++ b/server/static/index.html
@@ -0,0 +1,48 @@
+
+
+
+
+
+ GoCron UI
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Monitoring 0 scheduled jobs in real-time
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/server/static/style.css b/server/static/style.css
new file mode 100644
index 0000000..10475b0
--- /dev/null
+++ b/server/static/style.css
@@ -0,0 +1,618 @@
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
+ 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ background-color: #f5f5f5;
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+
+.container {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 0 1rem;
+ width: 100%;
+}
+
+/* Header */
+.header {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: white;
+ padding: 1.5rem 0;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+}
+
+.header-content {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.header-title {
+ font-size: 1.75rem;
+ font-weight: 700;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.25rem;
+}
+
+.powered-by {
+ font-size: 0.75rem;
+ font-weight: 400;
+ opacity: 0.85;
+ font-style: italic;
+ letter-spacing: 0.3px;
+}
+
+.status-indicator {
+ font-size: 0.875rem;
+ font-weight: 500;
+ padding: 0.5rem 1rem;
+ border-radius: 20px;
+ background-color: rgba(255, 255, 255, 0.2);
+}
+
+.status-indicator.connected {
+ color: #4ade80;
+}
+
+.status-indicator.disconnected {
+ color: #f87171;
+}
+
+/* Main Content */
+.main-content {
+ flex: 1;
+ padding: 2rem 0;
+}
+
+.actions {
+ margin-bottom: 2rem;
+ display: flex;
+ justify-content: center;
+}
+
+.info-banner {
+ background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%);
+ color: #0d47a1;
+ padding: 1rem 1.5rem;
+ border-radius: 8px;
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ border: 1px solid #90caf9;
+ box-shadow: 0 2px 4px rgba(13, 71, 161, 0.1);
+}
+
+.info-icon {
+ font-size: 1.5rem;
+}
+
+.info-banner strong {
+ color: #1565c0;
+ font-weight: 700;
+}
+
+/* Buttons */
+.btn {
+ padding: 0.75rem 1.5rem;
+ border: none;
+ border-radius: 4px;
+ font-size: 1rem;
+ cursor: pointer;
+ transition: all 0.2s;
+ font-weight: 500;
+}
+
+.btn-primary {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: white;
+ box-shadow: 0 4px 6px rgba(102, 126, 234, 0.3);
+}
+
+.btn-primary:hover {
+ background: linear-gradient(135deg, #5568d3 0%, #6a4298 100%);
+ box-shadow: 0 6px 12px rgba(102, 126, 234, 0.4);
+ transform: translateY(-2px);
+}
+
+.btn-primary:disabled {
+ background-color: #6c757d;
+ cursor: not-allowed;
+}
+
+.btn-secondary {
+ background-color: #6c757d;
+ color: white;
+}
+
+.btn-secondary:hover {
+ background-color: #5a6268;
+}
+
+.btn-success {
+ background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
+ color: white;
+ padding: 0.5rem 1rem;
+ font-size: 0.875rem;
+ border: none;
+ box-shadow: 0 2px 4px rgba(40, 167, 69, 0.3);
+}
+
+.btn-success:hover {
+ background: linear-gradient(135deg, #218838 0%, #1aa179 100%);
+ box-shadow: 0 4px 8px rgba(40, 167, 69, 0.4);
+ transform: translateY(-1px);
+}
+
+.btn-danger {
+ background: linear-gradient(135deg, #dc3545 0%, #e83e8c 100%);
+ color: white;
+ padding: 0.5rem 1rem;
+ font-size: 0.875rem;
+ border: none;
+ box-shadow: 0 2px 4px rgba(220, 53, 69, 0.3);
+}
+
+.btn-danger:hover {
+ background: linear-gradient(135deg, #c82333 0%, #d63384 100%);
+ box-shadow: 0 4px 8px rgba(220, 53, 69, 0.4);
+ transform: translateY(-1px);
+}
+
+.btn-sm {
+ padding: 0.4rem 0.8rem;
+ font-size: 0.875rem;
+}
+
+/* Error Banner */
+.error-banner {
+ background-color: #f8d7da;
+ color: #721c24;
+ padding: 1rem;
+ border-radius: 4px;
+ margin-bottom: 1rem;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.error-banner button {
+ background: none;
+ border: none;
+ color: #721c24;
+ font-size: 1.5rem;
+ cursor: pointer;
+ padding: 0;
+ width: 2rem;
+ height: 2rem;
+}
+
+/* Modal */
+.modal-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ padding: 1rem;
+}
+
+.modal-content {
+ background: white;
+ border-radius: 8px;
+ max-width: 600px;
+ width: 100%;
+ max-height: 90vh;
+ overflow-y: auto;
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+}
+
+.modal-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 1.5rem;
+ border-bottom: 1px solid #e9ecef;
+}
+
+.modal-header h2 {
+ margin: 0;
+ font-size: 1.5rem;
+ color: #333;
+}
+
+.close-btn {
+ background: none;
+ border: none;
+ font-size: 2rem;
+ color: #666;
+ cursor: pointer;
+ padding: 0;
+ width: 2rem;
+ height: 2rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: color 0.2s;
+}
+
+.close-btn:hover {
+ color: #333;
+}
+
+/* Form */
+.job-form {
+ padding: 1.5rem;
+}
+
+.form-error {
+ background-color: #f8d7da;
+ color: #721c24;
+ padding: 0.75rem;
+ border-radius: 4px;
+ margin-bottom: 1rem;
+}
+
+.form-group {
+ margin-bottom: 1.5rem;
+}
+
+.form-group label {
+ display: block;
+ margin-bottom: 0.5rem;
+ font-weight: 600;
+ color: #333;
+}
+
+.form-group input,
+.form-group select {
+ width: 100%;
+ padding: 0.75rem;
+ border: 1px solid #ced4da;
+ border-radius: 4px;
+ font-size: 1rem;
+ transition: border-color 0.2s;
+}
+
+.form-group input:focus,
+.form-group select:focus {
+ outline: none;
+ border-color: #007bff;
+}
+
+.form-group small {
+ display: block;
+ margin-top: 0.25rem;
+ color: #666;
+ font-size: 0.875rem;
+}
+
+.tags-input-container {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.tags-input-container input {
+ flex: 1;
+}
+
+.btn-add-tag {
+ padding: 0.75rem 1.5rem;
+ background-color: #6c757d;
+ color: white;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: background-color 0.2s;
+}
+
+.btn-add-tag:hover {
+ background-color: #5a6268;
+}
+
+.tags-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ margin-top: 0.75rem;
+}
+
+.tag {
+ background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
+ color: #495057;
+ padding: 0.35rem 0.85rem;
+ border-radius: 14px;
+ font-size: 0.8rem;
+ font-weight: 500;
+ border: 1px solid #dee2e6;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+}
+
+.tag-remove {
+ background: none;
+ border: none;
+ color: #495057;
+ cursor: pointer;
+ padding: 0;
+ font-size: 1.25rem;
+ line-height: 1;
+}
+
+.tag-remove:hover {
+ color: #dc3545;
+}
+
+.form-actions {
+ display: flex;
+ gap: 1rem;
+ justify-content: flex-end;
+ margin-top: 2rem;
+ padding-top: 1rem;
+ border-top: 1px solid #e9ecef;
+}
+
+/* Jobs List */
+.job-list {
+ margin-top: 2rem;
+}
+
+.job-list-title {
+ font-size: 1.5rem;
+ margin-bottom: 1.5rem;
+ color: #333;
+}
+
+.job-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
+ gap: 1.5rem;
+}
+
+.empty-state {
+ text-align: center;
+ padding: 4rem 2rem;
+ background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
+ border-radius: 12px;
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.08);
+ border: 2px dashed #dee2e6;
+}
+
+.empty-icon {
+ font-size: 5rem;
+ margin-bottom: 1rem;
+ filter: grayscale(0.3);
+}
+
+.empty-state h2 {
+ font-size: 1.5rem;
+ color: #333;
+ margin-bottom: 0.5rem;
+}
+
+.empty-state p {
+ color: #666;
+ font-size: 1rem;
+ margin-bottom: 1.5rem;
+}
+
+.empty-hint {
+ background: #fff3cd;
+ color: #856404;
+ padding: 1rem;
+ border-radius: 6px;
+ border: 1px solid #ffeeba;
+ margin-top: 1rem;
+ font-size: 0.9rem;
+ max-width: 500px;
+ margin-left: auto;
+ margin-right: auto;
+}
+
+.empty-hint a {
+ color: #0056b3;
+ text-decoration: underline;
+}
+
+/* Job Card */
+.job-card {
+ background: white;
+ border-radius: 12px;
+ padding: 1.5rem;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+ transition: all 0.3s ease;
+ border: 1px solid rgba(0, 0, 0, 0.05);
+}
+
+.job-card:hover {
+ transform: translateY(-4px);
+ box-shadow: 0 8px 16px rgba(0, 0, 0, 0.12);
+ border-color: rgba(102, 126, 234, 0.3);
+}
+
+.job-card-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ margin-bottom: 1rem;
+}
+
+.job-name {
+ font-size: 1.25rem;
+ color: #333;
+ margin: 0;
+ flex: 1;
+ word-break: break-word;
+}
+
+.job-actions {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.job-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ margin-bottom: 1rem;
+}
+
+.job-info {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ margin-bottom: 1rem;
+}
+
+.job-info-item {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.job-info-label {
+ font-size: 0.75rem;
+ color: #666;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ font-weight: 600;
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+}
+
+.job-info-label::before {
+ content: '';
+ display: inline-block;
+ width: 4px;
+ height: 4px;
+ background: #667eea;
+ border-radius: 50%;
+}
+
+.job-info-value {
+ font-size: 0.875rem;
+ color: #333;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.schedule-badge {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: white;
+ padding: 0.375rem 0.75rem;
+ border-radius: 6px;
+ font-size: 0.8rem;
+ font-weight: 600;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ margin-top: 0.25rem;
+}
+
+.schedule-badge::before {
+ content: '⏱';
+ font-size: 1rem;
+}
+
+.schedule-detail {
+ font-size: 0.75rem;
+ color: #666;
+ font-family: 'Courier New', monospace;
+ background: #f8f9fa;
+ padding: 0.25rem 0.5rem;
+ border-radius: 4px;
+ margin-top: 0.25rem;
+ display: inline-block;
+}
+
+.time-until {
+ background: linear-gradient(135deg, #e0f7fa 0%, #b2ebf2 100%);
+ color: #00695c;
+ padding: 0.25rem 0.65rem;
+ border-radius: 12px;
+ font-size: 0.75rem;
+ font-weight: 600;
+ border: 1px solid #80deea;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+}
+
+.job-id {
+ font-family: monospace;
+ font-size: 0.75rem;
+ color: #666;
+ word-break: break-all;
+}
+
+.job-schedule {
+ margin-top: 1rem;
+ padding-top: 1rem;
+ border-top: 1px solid #e9ecef;
+}
+
+.schedule-toggle {
+ background: none;
+ border: none;
+ color: #007bff;
+ cursor: pointer;
+ font-size: 0.875rem;
+ font-weight: 500;
+ padding: 0;
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ transition: color 0.2s ease;
+}
+
+.schedule-toggle:hover {
+ color: #0056b3;
+}
+
+.schedule-toggle span {
+ transition: transform 0.2s ease;
+ display: inline-block;
+}
+
+.schedule-details {
+ margin-top: 0.75rem;
+ padding-left: 1.25rem;
+ overflow: hidden;
+ transition: max-height 0.3s ease-out, opacity 0.2s ease-out;
+ opacity: 1;
+}
+
+.schedule-details[style*="display: none"] {
+ max-height: 0;
+ opacity: 0;
+}
+
+.schedule-details[style*="display: block"] {
+ max-height: 500px;
+ opacity: 1;
+}
+
+.schedule-item {
+ font-size: 0.875rem;
+ color: #666;
+ padding: 0.25rem 0;
+}
\ No newline at end of file
diff --git a/server/types.go b/server/types.go
new file mode 100644
index 0000000..4c74d48
--- /dev/null
+++ b/server/types.go
@@ -0,0 +1,23 @@
+package server
+
+// JobData represents the job information sent to clients
+type JobData struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Tags []string `json:"tags"`
+ NextRun string `json:"nextRun"`
+ LastRun string `json:"lastRun"`
+ NextRuns []string `json:"nextRuns"`
+ Schedule string `json:"schedule"` // human-readable schedule description
+ ScheduleDetail string `json:"scheduleDetail"` // technical schedule details (cron expression, interval, etc.)
+}
+
+// CreateJobRequest represents the request to create a new job
+type CreateJobRequest struct {
+ Name string `json:"name"`
+ Type string `json:"type"` // duration, cron, daily, weekly, monthly
+ Interval int64 `json:"interval,omitempty"`
+ CronExpression string `json:"cronExpression,omitempty"`
+ AtTime string `json:"atTime,omitempty"` // Format: HH:MM:SS
+ Tags []string `json:"tags,omitempty"`
+}