mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
Build real cron engine + service-detail Cron UI
The cron backend was a stub: calculateNextRun returned now+1h for any input and executeCronJob slept 2s then wrote "success" without running the command. Now robfig/cron parses and validates schedules (400 on invalid), a 60s scheduler goroutine executes due enabled jobs, and commands run via docker exec sh -c inside the service's container with stdout+stderr captured into cron_executions. Added Client.ExecRun for attached output capture. UI: Cron tab on the service detail page with create/edit/enable-toggle, manual Run, and execution history with logs. Verified e2e against real Postgres + Docker (trigger and scheduler tick both produce real captured output). Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
aab785afc1
commit
a1e43524f5
9 files changed
+498
-26
No files matched your search
+11
-2
@@ -262,8 +262,17 @@ All of these have working APIs and no UI. Cheapest feature wins in the repo.
|
|||||||
detail page section for connection info, actions (start/stop/restart),
|
detail page section for connection info, actions (start/stop/restart),
|
||||||
backup create/list/restore. Verify against real Postgres/Redis/MySQL
|
backup create/list/restore. Verify against real Postgres/Redis/MySQL
|
||||||
images.
|
images.
|
||||||
- [ ] **Cron jobs** (`/cron-jobs/*`): list/create/edit per project, execution
|
- [x] **Cron jobs** (`/cron-jobs/*`): real engine + UI. Backend was a stub
|
||||||
history with logs, manual trigger. Add "Cron" to Add Service.
|
(`calculateNextRun` returned now+1h, `executeCronJob` slept 2s and wrote
|
||||||
|
"success" without running anything). Now: `robfig/cron` parses the
|
||||||
|
schedule (validated on create → 400), a 60s scheduler goroutine executes
|
||||||
|
due enabled jobs, and commands run via `docker exec sh -c` inside the
|
||||||
|
service's `containr-<serviceID>-*` container with stdout+stderr captured
|
||||||
|
into `cron_executions` — verified e2e (trigger + scheduler tick, real
|
||||||
|
alpine output). UI: "Cron" tab on the service detail page — list/create/
|
||||||
|
edit/enable-toggle, manual Run, execution history with logs. (Not added
|
||||||
|
to Add Service: jobs exec into an existing service's container, so they
|
||||||
|
live on the service, not as a standalone type.)
|
||||||
- [ ] **Preview environments** (`/preview-environments/*`): per-PR/per-branch
|
- [ ] **Preview environments** (`/preview-environments/*`): per-PR/per-branch
|
||||||
preview creation, promote-to-production action, expiry + cleanup job.
|
preview creation, promote-to-production action, expiry + cleanup job.
|
||||||
Decide first whether this stays Phase 2 or moves with env support (§2.4).
|
Decide first whether this stays Phase 2 or moves with env support (§2.4).
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ require (
|
|||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||||
|
|||||||
@@ -134,6 +134,8 @@ github.com/pressly/goose/v3 v3.24.1 h1:bZmxRco2uy5uu5Ng1MMVEfYsFlrMJI+e/VMXHQ3C4
|
|||||||
github.com/pressly/goose/v3 v3.24.1/go.mod h1:rEWreU9uVtt0DHCyLzF9gRcWiiTF/V+528DV+4DORug=
|
github.com/pressly/goose/v3 v3.24.1/go.mod h1:rEWreU9uVtt0DHCyLzF9gRcWiiTF/V+528DV+4DORug=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
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/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
github.com/rs/cors v1.10.1 h1:L0uuZVXIKlI1SShY2nhFfo44TYvDPQ1w4oFkUJNfhyo=
|
github.com/rs/cors v1.10.1 h1:L0uuZVXIKlI1SShY2nhFfo44TYvDPQ1w4oFkUJNfhyo=
|
||||||
|
|||||||
@@ -2,12 +2,17 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"containr/internal/database"
|
"containr/internal/database"
|
||||||
"encoding/json"
|
"containr/internal/docker"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/robfig/cron/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CronJob struct {
|
type CronJob struct {
|
||||||
@@ -62,6 +67,7 @@ func handleGetCronJobs(c *gin.Context) {
|
|||||||
db := c.MustGet("db").(*database.DB)
|
db := c.MustGet("db").(*database.DB)
|
||||||
userID := c.MustGet("user_id").(string)
|
userID := c.MustGet("user_id").(string)
|
||||||
projectID := c.Query("project_id")
|
projectID := c.Query("project_id")
|
||||||
|
serviceID := c.Query("service_id")
|
||||||
|
|
||||||
query := `SELECT cj.id, cj.project_id, cj.service_id, cj.name, cj.schedule, cj.timezone,
|
query := `SELECT cj.id, cj.project_id, cj.service_id, cj.name, cj.schedule, cj.timezone,
|
||||||
cj.enabled, cj.last_run_at, cj.next_run_at, cj.last_status, cj.last_output,
|
cj.enabled, cj.last_run_at, cj.next_run_at, cj.last_status, cj.last_output,
|
||||||
@@ -72,8 +78,12 @@ func handleGetCronJobs(c *gin.Context) {
|
|||||||
args := []interface{}{userID}
|
args := []interface{}{userID}
|
||||||
|
|
||||||
if projectID != "" {
|
if projectID != "" {
|
||||||
query += " AND cj.project_id = $2"
|
|
||||||
args = append(args, projectID)
|
args = append(args, projectID)
|
||||||
|
query += fmt.Sprintf(" AND cj.project_id = $%d", len(args))
|
||||||
|
}
|
||||||
|
if serviceID != "" {
|
||||||
|
args = append(args, serviceID)
|
||||||
|
query += fmt.Sprintf(" AND cj.service_id = $%d", len(args))
|
||||||
}
|
}
|
||||||
|
|
||||||
query += " ORDER BY cj.created_at DESC"
|
query += " ORDER BY cj.created_at DESC"
|
||||||
@@ -130,7 +140,11 @@ func handleCreateCronJob(c *gin.Context) {
|
|||||||
req.Retention = 30
|
req.Retention = 30
|
||||||
}
|
}
|
||||||
|
|
||||||
nextRun := calculateNextRun(req.Schedule, req.Timezone)
|
nextRun, err := calculateNextRun(req.Schedule, req.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid schedule: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
job := CronJob{
|
job := CronJob{
|
||||||
ID: uuid.New().String(),
|
ID: uuid.New().String(),
|
||||||
@@ -227,8 +241,13 @@ func handleUpdateCronJob(c *gin.Context) {
|
|||||||
updates["name"] = req.Name
|
updates["name"] = req.Name
|
||||||
}
|
}
|
||||||
if req.Schedule != "" {
|
if req.Schedule != "" {
|
||||||
|
next, err := calculateNextRun(req.Schedule, req.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid schedule: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
updates["schedule"] = req.Schedule
|
updates["schedule"] = req.Schedule
|
||||||
updates["next_run_at"] = calculateNextRun(req.Schedule, "UTC")
|
updates["next_run_at"] = next
|
||||||
}
|
}
|
||||||
if req.Command != "" {
|
if req.Command != "" {
|
||||||
updates["command"] = req.Command
|
updates["command"] = req.Command
|
||||||
@@ -352,11 +371,12 @@ func handleTriggerCronJob(c *gin.Context) {
|
|||||||
var job CronJob
|
var job CronJob
|
||||||
var ownerCheck string
|
var ownerCheck string
|
||||||
err := db.QueryRow(
|
err := db.QueryRow(
|
||||||
`SELECT cj.command, p.owner_id FROM cron_jobs cj
|
`SELECT cj.service_id, cj.command, cj.schedule, cj.timezone, cj.retention, p.owner_id
|
||||||
|
FROM cron_jobs cj
|
||||||
JOIN projects p ON cj.project_id = p.id
|
JOIN projects p ON cj.project_id = p.id
|
||||||
WHERE cj.id = $1`,
|
WHERE cj.id = $1`,
|
||||||
jobID,
|
jobID,
|
||||||
).Scan(&job.Command, &ownerCheck)
|
).Scan(&job.ServiceID, &job.Command, &job.Schedule, &job.Timezone, &job.Retention, &ownerCheck)
|
||||||
|
|
||||||
if err != nil || ownerCheck != userID {
|
if err != nil || ownerCheck != userID {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||||
@@ -372,7 +392,9 @@ func handleTriggerCronJob(c *gin.Context) {
|
|||||||
execID, jobID, now, "running",
|
execID, jobID, now, "running",
|
||||||
)
|
)
|
||||||
|
|
||||||
go executeCronJob(jobID, execID, job.Command)
|
dockerClient, _ := c.Get("docker_client")
|
||||||
|
dockerCli, _ := dockerClient.(*docker.Client)
|
||||||
|
go executeCronJob(db, dockerCli, jobID, job.ServiceID, execID, job.Command, job.Schedule, job.Timezone, job.Retention)
|
||||||
|
|
||||||
LogAudit(userID, "cron_job", jobID, "trigger", map[string]interface{}{
|
LogAudit(userID, "cron_job", jobID, "trigger", map[string]interface{}{
|
||||||
"execution_id": execID,
|
"execution_id": execID,
|
||||||
@@ -384,33 +406,140 @@ func handleTriggerCronJob(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func calculateNextRun(schedule, timezone string) *time.Time {
|
// cronScheduleParser parses standard 5-field cron expressions plus
|
||||||
now := time.Now()
|
// descriptors (@daily etc.) and CRON_TZ= prefixes.
|
||||||
next := now.Add(1 * time.Hour)
|
var cronScheduleParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
|
||||||
return &next
|
|
||||||
|
func calculateNextRun(schedule, timezone string) (*time.Time, error) {
|
||||||
|
expr := strings.TrimSpace(schedule)
|
||||||
|
if tz := strings.TrimSpace(timezone); tz != "" && tz != "UTC" {
|
||||||
|
expr = "CRON_TZ=" + tz + " " + expr
|
||||||
|
}
|
||||||
|
parsed, err := cronScheduleParser.Parse(expr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
next := parsed.Next(time.Now())
|
||||||
|
return &next, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func executeCronJob(jobID, execID, command string) {
|
// StartCronScheduler ticks every minute and executes enabled cron jobs whose
|
||||||
db := auditDB
|
// next_run_at is due. Each run is recorded in cron_executions and output is
|
||||||
if db == nil {
|
// captured from docker exec inside the service's container.
|
||||||
|
func StartCronScheduler(ctx context.Context, db *database.DB, dockerClient *docker.Client) {
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
runDueCronJobs(db, dockerClient)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDueCronJobs(db *database.DB, dockerClient *docker.Client) {
|
||||||
|
rows, err := db.Query(
|
||||||
|
`SELECT id, project_id, service_id, name, schedule, command, timezone, enabled, retention
|
||||||
|
FROM cron_jobs
|
||||||
|
WHERE enabled = TRUE AND next_run_at IS NOT NULL AND next_run_at <= NOW()`)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("cron scheduler: failed to list due jobs: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
time.Sleep(2 * time.Second)
|
type dueJob struct {
|
||||||
|
CronJob
|
||||||
|
Command string
|
||||||
|
}
|
||||||
|
var jobs []dueJob
|
||||||
|
for rows.Next() {
|
||||||
|
var j dueJob
|
||||||
|
if err := rows.Scan(&j.ID, &j.ProjectID, &j.ServiceID, &j.Name, &j.Schedule,
|
||||||
|
&j.Command, &j.Timezone, &j.Enabled, &j.Retention); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
jobs = append(jobs, j)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, j := range jobs {
|
||||||
|
execID := uuid.New().String()
|
||||||
|
if _, err := db.Exec(
|
||||||
|
`INSERT INTO cron_executions (id, cron_job_id, started_at, status) VALUES ($1, $2, $3, $4)`,
|
||||||
|
execID, j.ID, time.Now(), "running"); err != nil {
|
||||||
|
log.Printf("cron scheduler: failed to record execution for %s: %v", j.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
go executeCronJob(db, dockerClient, j.ID, j.ServiceID, execID, j.Command, j.Schedule, j.Timezone, j.Retention)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// findServiceContainer returns the first running container whose name matches
|
||||||
|
// the deployment naming convention containr-<serviceID>-*.
|
||||||
|
func findServiceContainer(ctx context.Context, dockerClient *docker.Client, serviceID string) (string, error) {
|
||||||
|
containers, err := dockerClient.ListContainers(ctx, false)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
prefix := "containr-" + serviceID + "-"
|
||||||
|
for _, ctr := range containers {
|
||||||
|
for _, name := range ctr.Names {
|
||||||
|
if strings.HasPrefix(strings.TrimPrefix(name, "/"), prefix) {
|
||||||
|
return ctr.ID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("no running container for service %s", serviceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeCronJob(db *database.DB, dockerClient *docker.Client, jobID, serviceID, execID, command, schedule, timezone string, retention int) {
|
||||||
|
status := "success"
|
||||||
|
var output, errText string
|
||||||
|
|
||||||
|
if dockerClient == nil {
|
||||||
|
status, errText = "failed", "docker unavailable"
|
||||||
|
} else {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
containerID, findErr := findServiceContainer(ctx, dockerClient, serviceID)
|
||||||
|
if findErr != nil {
|
||||||
|
status, errText = "failed", findErr.Error()
|
||||||
|
} else {
|
||||||
|
out, exitCode, runErr := dockerClient.ExecRun(ctx, containerID, []string{"sh", "-c", command})
|
||||||
|
output = out
|
||||||
|
if len(output) > 64*1024 {
|
||||||
|
output = output[:64*1024]
|
||||||
|
}
|
||||||
|
if runErr != nil {
|
||||||
|
status, errText = "failed", runErr.Error()
|
||||||
|
} else if exitCode != 0 {
|
||||||
|
status, errText = "failed", fmt.Sprintf("exit code %d", exitCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
db.Exec(
|
db.Exec(
|
||||||
`UPDATE cron_executions SET finished_at = $1, status = $2, output = $3 WHERE id = $4`,
|
`UPDATE cron_executions SET finished_at = $1, status = $2, output = $3, error = $4 WHERE id = $5`,
|
||||||
now, "success", "Job completed successfully", execID,
|
now, status, output, errText, execID,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
next, _ := calculateNextRun(schedule, timezone)
|
||||||
db.Exec(
|
db.Exec(
|
||||||
`UPDATE cron_jobs SET last_run_at = $1, last_status = $2, next_run_at = $3 WHERE id = $4`,
|
`UPDATE cron_jobs SET last_run_at = $1, last_status = $2, last_output = $3, next_run_at = $4 WHERE id = $5`,
|
||||||
now, "success", time.Now().Add(1*time.Hour), jobID,
|
now, status, output, next, jobID,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
if retention > 0 {
|
||||||
cronJobsData, _ := json.Marshal([]CronJob{})
|
db.Exec(
|
||||||
_ = cronJobsData
|
`DELETE FROM cron_executions WHERE cron_job_id = $1 AND id NOT IN (
|
||||||
|
SELECT id FROM cron_executions WHERE cron_job_id = $1
|
||||||
|
ORDER BY started_at DESC LIMIT $2)`,
|
||||||
|
jobID, retention,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -57,6 +57,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
// Initialize agent handler (sqlc-backed)
|
// Initialize agent handler (sqlc-backed)
|
||||||
agentHandler := NewNodeAgentHandler(db)
|
agentHandler := NewNodeAgentHandler(db)
|
||||||
|
|
||||||
|
// Start the cron scheduler — executes due cron_jobs via docker exec.
|
||||||
|
StartCronScheduler(context.Background(), db, dockerClient)
|
||||||
|
|
||||||
// Initialize database handler
|
// Initialize database handler
|
||||||
databaseHandler := NewDatabaseHandler(db.DB, dockerClient)
|
databaseHandler := NewDatabaseHandler(db.DB, dockerClient)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package docker
|
package docker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -15,6 +16,7 @@ import (
|
|||||||
"github.com/docker/docker/api/types/system"
|
"github.com/docker/docker/api/types/system"
|
||||||
"github.com/docker/docker/api/types/volume"
|
"github.com/docker/docker/api/types/volume"
|
||||||
"github.com/docker/docker/client"
|
"github.com/docker/docker/client"
|
||||||
|
"github.com/docker/docker/pkg/stdcopy"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Client wraps the Docker client with additional functionality
|
// Client wraps the Docker client with additional functionality
|
||||||
@@ -313,6 +315,36 @@ func (c *Client) ExecInspect(ctx context.Context, execID string) (container.Exec
|
|||||||
return c.cli.ContainerExecInspect(ctx, execID)
|
return c.cli.ContainerExecInspect(ctx, execID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExecRun runs a command in a container and returns its combined
|
||||||
|
// stdout/stderr output and exit code.
|
||||||
|
func (c *Client) ExecRun(ctx context.Context, containerID string, cmd []string) (string, int, error) {
|
||||||
|
exec, err := c.cli.ContainerExecCreate(ctx, containerID, container.ExecOptions{
|
||||||
|
Cmd: cmd,
|
||||||
|
AttachStdout: true,
|
||||||
|
AttachStderr: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", -1, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.cli.ContainerExecAttach(ctx, exec.ID, container.ExecAttachOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return "", -1, err
|
||||||
|
}
|
||||||
|
defer resp.Close()
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if _, err := stdcopy.StdCopy(&buf, &buf, resp.Reader); err != nil {
|
||||||
|
return "", -1, err
|
||||||
|
}
|
||||||
|
|
||||||
|
inspect, err := c.cli.ContainerExecInspect(ctx, exec.ID)
|
||||||
|
if err != nil {
|
||||||
|
return buf.String(), -1, err
|
||||||
|
}
|
||||||
|
return buf.String(), inspect.ExitCode, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetImageInfo returns information about a Docker image
|
// GetImageInfo returns information about a Docker image
|
||||||
func (c *Client) GetImageInfo(ctx context.Context, imageName string) (*ImageInfo, error) {
|
func (c *Client) GetImageInfo(ctx context.Context, imageName string) (*ImageInfo, error) {
|
||||||
images, err := c.cli.ImageList(ctx, image.ListOptions{})
|
images, err := c.cli.ImageList(ctx, image.ListOptions{})
|
||||||
|
|||||||
@@ -11,8 +11,15 @@ import {
|
|||||||
listDeployments,
|
listDeployments,
|
||||||
listServiceLogs,
|
listServiceLogs,
|
||||||
listServiceVariables,
|
listServiceVariables,
|
||||||
|
listCronJobs,
|
||||||
|
createCronJob,
|
||||||
|
updateCronJob,
|
||||||
|
deleteCronJob,
|
||||||
|
listCronExecutions,
|
||||||
|
triggerCronJob,
|
||||||
rollbackDeployment,
|
rollbackDeployment,
|
||||||
updateServiceVariables,
|
updateServiceVariables,
|
||||||
|
type CronJobEntity,
|
||||||
} from '@/lib/api-client';
|
} from '@/lib/api-client';
|
||||||
import { getDemoProjectById, getDemoServiceById } from '@/lib/demo-data';
|
import { getDemoProjectById, getDemoServiceById } from '@/lib/demo-data';
|
||||||
import { parseDotenv, validateVariableRows, type VariableDraft } from '../variable-utils';
|
import { parseDotenv, validateVariableRows, type VariableDraft } from '../variable-utils';
|
||||||
@@ -40,15 +47,18 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Box,
|
Box,
|
||||||
|
Play,
|
||||||
|
ChevronDown,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
type ServiceSection = 'metrics' | 'logs' | 'config' | 'variables' | 'settings';
|
type ServiceSection = 'metrics' | 'logs' | 'config' | 'variables' | 'cron' | 'settings';
|
||||||
|
|
||||||
const sectionItems: Array<{ key: ServiceSection; label: string; icon: typeof Activity }> = [
|
const sectionItems: Array<{ key: ServiceSection; label: string; icon: typeof Activity }> = [
|
||||||
{ key: 'metrics', label: 'Metrics', icon: Activity },
|
{ key: 'metrics', label: 'Metrics', icon: Activity },
|
||||||
{ key: 'logs', label: 'Logs', icon: FileText },
|
{ key: 'logs', label: 'Logs', icon: FileText },
|
||||||
{ key: 'config', label: 'Config', icon: Sliders },
|
{ key: 'config', label: 'Config', icon: Sliders },
|
||||||
{ key: 'variables', label: 'Variables', icon: KeyRound },
|
{ key: 'variables', label: 'Variables', icon: KeyRound },
|
||||||
|
{ key: 'cron', label: 'Cron', icon: Clock },
|
||||||
{ key: 'settings', label: 'Settings', icon: Settings },
|
{ key: 'settings', label: 'Settings', icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -135,6 +145,66 @@ export function ServiceDetailPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Cron jobs ---
|
||||||
|
const [cronForm, setCronForm] = useState<{
|
||||||
|
name: string; schedule: string; command: string; timezone: string; enabled: boolean; retention: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [editingCronId, setEditingCronId] = useState<string | null>(null);
|
||||||
|
const [historyCronId, setHistoryCronId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const cronJobsQuery = useQuery({
|
||||||
|
queryKey: ['service-cron-jobs', serviceId],
|
||||||
|
queryFn: () => listCronJobs(serviceId),
|
||||||
|
enabled: Boolean(serviceId) && !isDemoMode && activeSection === 'cron',
|
||||||
|
});
|
||||||
|
const cronExecutionsQuery = useQuery({
|
||||||
|
queryKey: ['cron-executions', historyCronId],
|
||||||
|
queryFn: () => listCronExecutions(historyCronId as string),
|
||||||
|
enabled: Boolean(historyCronId) && !isDemoMode && activeSection === 'cron',
|
||||||
|
});
|
||||||
|
const invalidateCron = () => queryClient.invalidateQueries({ queryKey: ['service-cron-jobs', serviceId] });
|
||||||
|
|
||||||
|
const saveCronMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!cronForm) return;
|
||||||
|
const payload = {
|
||||||
|
name: cronForm.name.trim(),
|
||||||
|
schedule: cronForm.schedule.trim(),
|
||||||
|
command: cronForm.command.trim(),
|
||||||
|
timezone: cronForm.timezone.trim() || 'UTC',
|
||||||
|
enabled: cronForm.enabled,
|
||||||
|
retention: cronForm.retention,
|
||||||
|
};
|
||||||
|
if (editingCronId) {
|
||||||
|
await updateCronJob(editingCronId, payload);
|
||||||
|
} else {
|
||||||
|
await createCronJob({ project_id: projectId, service_id: serviceId, ...payload });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
setCronForm(null);
|
||||||
|
setEditingCronId(null);
|
||||||
|
invalidateCron();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const deleteCronMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => deleteCronJob(id),
|
||||||
|
onSuccess: invalidateCron,
|
||||||
|
});
|
||||||
|
const toggleCronMutation = useMutation({
|
||||||
|
mutationFn: (job: CronJobEntity) => updateCronJob(job.id as string, { enabled: !job.enabled }),
|
||||||
|
onSuccess: invalidateCron,
|
||||||
|
});
|
||||||
|
const triggerCronMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => triggerCronJob(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
invalidateCron();
|
||||||
|
if (historyCronId) queryClient.invalidateQueries({ queryKey: ['cron-executions', historyCronId] });
|
||||||
|
}, 2500);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const serviceLogsQuery = useQuery({
|
const serviceLogsQuery = useQuery({
|
||||||
queryKey: ['service-logs', serviceId, logTail],
|
queryKey: ['service-logs', serviceId, logTail],
|
||||||
queryFn: () => listServiceLogs(serviceId, { tail: logTail }),
|
queryFn: () => listServiceLogs(serviceId, { tail: logTail }),
|
||||||
@@ -1041,6 +1111,180 @@ export function ServiceDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeSection === 'cron' && (
|
||||||
|
<div className="panel p-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
|
||||||
|
<Clock size={20} className="text-[var(--accent-primary)]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Cron Jobs</h2>
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">Scheduled commands executed inside the service container</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => { setEditingCronId(null); setCronForm({ name: '', schedule: '', command: '', timezone: 'UTC', enabled: true, retention: 30 }); }}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={12} />
|
||||||
|
New job
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{cronForm && (
|
||||||
|
<div className="mb-4 panel-soft p-4 space-y-3">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<input
|
||||||
|
value={cronForm.name}
|
||||||
|
onChange={(e) => setCronForm({ ...cronForm, name: e.target.value })}
|
||||||
|
placeholder="Name (e.g. Nightly backup)"
|
||||||
|
className="px-3 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-xs text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={cronForm.schedule}
|
||||||
|
onChange={(e) => setCronForm({ ...cronForm, schedule: e.target.value })}
|
||||||
|
placeholder="Schedule (e.g. 0 3 * * * or @daily)"
|
||||||
|
className="px-3 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] mono text-xs text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
value={cronForm.command}
|
||||||
|
onChange={(e) => setCronForm({ ...cronForm, command: e.target.value })}
|
||||||
|
placeholder="Command (runs via sh -c inside the container)"
|
||||||
|
className="w-full px-3 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] mono text-xs text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<input
|
||||||
|
value={cronForm.timezone}
|
||||||
|
onChange={(e) => setCronForm({ ...cronForm, timezone: e.target.value })}
|
||||||
|
placeholder="Timezone"
|
||||||
|
className="w-32 px-3 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-xs text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
|
||||||
|
/>
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
|
||||||
|
<input type="number" min={1} value={cronForm.retention}
|
||||||
|
onChange={(e) => setCronForm({ ...cronForm, retention: Number(e.target.value) || 30 })}
|
||||||
|
className="w-16 px-2 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-xs text-[var(--text-primary)]" />
|
||||||
|
runs kept
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
|
||||||
|
<input type="checkbox" checked={cronForm.enabled}
|
||||||
|
onChange={(e) => setCronForm({ ...cronForm, enabled: e.target.checked })} />
|
||||||
|
Enabled
|
||||||
|
</label>
|
||||||
|
<div className="ml-auto flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => { setCronForm(null); setEditingCronId(null); }}
|
||||||
|
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => saveCronMutation.mutate()}
|
||||||
|
disabled={!cronForm.name.trim() || !cronForm.schedule.trim() || !cronForm.command.trim() || saveCronMutation.isPending}
|
||||||
|
className="flex items-center gap-2 px-4 py-1.5 rounded-[var(--radius-md)] text-xs font-medium text-[var(--accent-on)] transition-all disabled:opacity-50"
|
||||||
|
style={{ background: 'var(--accent-primary)' }}
|
||||||
|
>
|
||||||
|
{saveCronMutation.isPending ? <Loader2 size={12} className="animate-spin" /> : <Save size={12} />}
|
||||||
|
{editingCronId ? 'Save job' : 'Create job'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{saveCronMutation.isError && (
|
||||||
|
<p className="text-xs text-[var(--error)]">
|
||||||
|
{saveCronMutation.error instanceof Error ? saveCronMutation.error.message : 'Failed to save job'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{cronJobsQuery.isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12 text-[var(--text-muted)]">
|
||||||
|
<Loader2 size={20} className="animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : (cronJobsQuery.data ?? []).length === 0 ? (
|
||||||
|
<div className="panel-soft p-8 text-center">
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">No cron jobs configured.</p>
|
||||||
|
<p className="mt-1 text-xs text-[var(--text-muted)]">Create a job to run commands on a schedule inside this service's container.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(cronJobsQuery.data ?? []).map((job) => (
|
||||||
|
<div key={job.id} className="panel-soft p-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleCronMutation.mutate(job)}
|
||||||
|
title={job.enabled ? 'Disable' : 'Enable'}
|
||||||
|
className={`w-8 h-5 rounded-full relative transition-colors shrink-0 ${job.enabled ? 'bg-[var(--accent-primary)]' : 'bg-[var(--border-default)]'}`}
|
||||||
|
>
|
||||||
|
<span className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-all ${job.enabled ? 'left-3.5' : 'left-0.5'}`} />
|
||||||
|
</button>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-[var(--text-primary)] truncate">{job.name}</span>
|
||||||
|
<span className="mono text-xs text-[var(--text-muted)]">{job.schedule}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mono text-xs text-[var(--text-tertiary)] truncate">{job.command}</p>
|
||||||
|
<p className="text-xs text-[var(--text-muted)]">
|
||||||
|
{job.next_run_at ? `next ${formatRelative(job.next_run_at)}` : 'not scheduled'}
|
||||||
|
{job.last_status ? ` · last ${job.last_status}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<button onClick={() => triggerCronMutation.mutate(job.id as string)} title="Run now"
|
||||||
|
disabled={triggerCronMutation.isPending}
|
||||||
|
className="p-1.5 rounded-[var(--radius-md)] text-[var(--text-secondary)] hover:text-[var(--accent-primary)] hover:bg-[var(--accent-primary-soft)] transition-colors disabled:opacity-50">
|
||||||
|
<Play size={13} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setHistoryCronId(historyCronId === job.id ? null : (job.id as string))} title="History"
|
||||||
|
className="p-1.5 rounded-[var(--radius-md)] text-[var(--text-secondary)] hover:text-[var(--accent-primary)] hover:bg-[var(--accent-primary-soft)] transition-colors">
|
||||||
|
<ChevronDown size={13} className={historyCronId === job.id ? 'rotate-180 transition-transform' : 'transition-transform'} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setEditingCronId(job.id as string); setCronForm({ name: job.name ?? '', schedule: job.schedule ?? '', command: job.command ?? '', timezone: job.timezone ?? 'UTC', enabled: job.enabled ?? true, retention: job.retention ?? 30 }); }}
|
||||||
|
title="Edit"
|
||||||
|
className="p-1.5 rounded-[var(--radius-md)] text-[var(--text-secondary)] hover:text-[var(--accent-primary)] hover:bg-[var(--accent-primary-soft)] transition-colors">
|
||||||
|
<Sliders size={13} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { if (window.confirm(`Delete cron job "${job.name}"?`)) deleteCronMutation.mutate(job.id as string); }}
|
||||||
|
title="Delete"
|
||||||
|
className="p-1.5 rounded-[var(--radius-md)] text-[var(--text-secondary)] hover:text-[var(--error)] hover:bg-[var(--error-soft)] transition-colors">
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{historyCronId === job.id && (
|
||||||
|
<div className="mt-3 border-t border-[var(--border-subtle)] pt-3">
|
||||||
|
{cronExecutionsQuery.isLoading ? (
|
||||||
|
<p className="text-xs text-[var(--text-muted)]">Loading history…</p>
|
||||||
|
) : (cronExecutionsQuery.data ?? []).length === 0 ? (
|
||||||
|
<p className="text-xs text-[var(--text-muted)]">No executions yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(cronExecutionsQuery.data ?? []).slice(0, 10).map((ex) => (
|
||||||
|
<div key={ex.id} className="text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`inline-block w-1.5 h-1.5 rounded-full ${ex.status === 'success' ? 'bg-[var(--success)]' : ex.status === 'running' ? 'bg-[var(--warning)]' : 'bg-[var(--error)]'}`} />
|
||||||
|
<span className="text-[var(--text-secondary)]">{formatRelative(ex.started_at)}</span>
|
||||||
|
<span className="text-[var(--text-muted)]">{ex.status}</span>
|
||||||
|
</div>
|
||||||
|
{(ex.output || ex.error) && (
|
||||||
|
<pre className="mono mt-1 ml-3.5 whitespace-pre-wrap break-all text-[var(--text-tertiary)]">{ex.output || ex.error}</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeSection === 'settings' && (
|
{activeSection === 'settings' && (
|
||||||
<div className="panel p-6">
|
<div className="panel p-6">
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
|||||||
@@ -1323,3 +1323,50 @@ export async function createGitWebhook(input: CreateGitWebhookInput): Promise<Gi
|
|||||||
}
|
}
|
||||||
return payload.webhook;
|
return payload.webhook;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Cron jobs ---
|
||||||
|
|
||||||
|
export type CronJobEntity = components['schemas']['CronJob'];
|
||||||
|
export type CronExecutionEntity = components['schemas']['CronExecution'];
|
||||||
|
export type CreateCronJobInput = components['schemas']['CreateCronJobRequest'];
|
||||||
|
export type UpdateCronJobInput = components['schemas']['UpdateCronJobRequest'];
|
||||||
|
|
||||||
|
export async function listCronJobs(serviceId: string): Promise<CronJobEntity[]> {
|
||||||
|
const payload = await requestJson<{ cron_jobs?: CronJobEntity[] }>(
|
||||||
|
`/cron-jobs?service_id=${encodeURIComponent(serviceId)}`,
|
||||||
|
);
|
||||||
|
return payload.cron_jobs ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCronJob(input: CreateCronJobInput): Promise<CronJobEntity> {
|
||||||
|
const payload = await requestJson<{ cron_job?: CronJobEntity }>('/cron-jobs', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
if (!payload.cron_job?.id) {
|
||||||
|
throw new ApiError('Create cron job response is invalid', 500);
|
||||||
|
}
|
||||||
|
return payload.cron_job;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCronJob(id: string, input: UpdateCronJobInput): Promise<void> {
|
||||||
|
await requestJson(`/cron-jobs/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCronJob(id: string): Promise<void> {
|
||||||
|
await requestJson(`/cron-jobs/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listCronExecutions(id: string): Promise<CronExecutionEntity[]> {
|
||||||
|
const payload = await requestJson<{ executions?: CronExecutionEntity[] }>(
|
||||||
|
`/cron-jobs/${encodeURIComponent(id)}/executions`,
|
||||||
|
);
|
||||||
|
return payload.executions ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function triggerCronJob(id: string): Promise<void> {
|
||||||
|
await requestJson(`/cron-jobs/${encodeURIComponent(id)}/trigger`, { method: 'POST' });
|
||||||
|
}
|
||||||
@@ -3551,6 +3551,11 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
format: uuid
|
format: uuid
|
||||||
|
- name: service_id
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
security:
|
security:
|
||||||
- bearerAuth: []
|
- bearerAuth: []
|
||||||
responses:
|
responses:
|
||||||
|
|||||||
Reference in new issue
Block a user