diff --git a/ROADMAP.md b/ROADMAP.md index b8e83c7..627fa19 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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), backup create/list/restore. Verify against real Postgres/Redis/MySQL images. -- [ ] **Cron jobs** (`/cron-jobs/*`): list/create/edit per project, execution - history with logs, manual trigger. Add "Cron" to Add Service. +- [x] **Cron jobs** (`/cron-jobs/*`): real engine + UI. Backend was a stub + (`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--*` 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 creation, promote-to-production action, expiry + cleanup job. Decide first whether this stays Phase 2 or moves with env support (§2.4). diff --git a/app/backend/go.mod b/app/backend/go.mod index ca785d9..81f273b 100644 --- a/app/backend/go.mod +++ b/app/backend/go.mod @@ -61,6 +61,7 @@ require ( github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pkg/errors v0.9.1 // 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/sethvargo/go-retry v0.3.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect diff --git a/app/backend/go.sum b/app/backend/go.sum index c009c2a..32f8caf 100644 --- a/app/backend/go.sum +++ b/app/backend/go.sum @@ -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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 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/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/cors v1.10.1 h1:L0uuZVXIKlI1SShY2nhFfo44TYvDPQ1w4oFkUJNfhyo= diff --git a/app/backend/internal/api/cron.go b/app/backend/internal/api/cron.go index 43afe45..097a150 100644 --- a/app/backend/internal/api/cron.go +++ b/app/backend/internal/api/cron.go @@ -2,12 +2,17 @@ package api import ( "containr/internal/database" - "encoding/json" + "containr/internal/docker" + "context" + "fmt" + "log" "net/http" + "strings" "time" "github.com/gin-gonic/gin" "github.com/google/uuid" + "github.com/robfig/cron/v3" ) type CronJob struct { @@ -62,6 +67,7 @@ func handleGetCronJobs(c *gin.Context) { db := c.MustGet("db").(*database.DB) userID := c.MustGet("user_id").(string) 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, 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} if projectID != "" { - query += " AND cj.project_id = $2" 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" @@ -130,7 +140,11 @@ func handleCreateCronJob(c *gin.Context) { 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{ ID: uuid.New().String(), @@ -227,8 +241,13 @@ func handleUpdateCronJob(c *gin.Context) { updates["name"] = req.Name } 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["next_run_at"] = calculateNextRun(req.Schedule, "UTC") + updates["next_run_at"] = next } if req.Command != "" { updates["command"] = req.Command @@ -352,11 +371,12 @@ func handleTriggerCronJob(c *gin.Context) { var job CronJob var ownerCheck string 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 WHERE cj.id = $1`, jobID, - ).Scan(&job.Command, &ownerCheck) + ).Scan(&job.ServiceID, &job.Command, &job.Schedule, &job.Timezone, &job.Retention, &ownerCheck) if err != nil || ownerCheck != userID { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) @@ -372,7 +392,9 @@ func handleTriggerCronJob(c *gin.Context) { 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{}{ "execution_id": execID, @@ -384,33 +406,140 @@ func handleTriggerCronJob(c *gin.Context) { }) } -func calculateNextRun(schedule, timezone string) *time.Time { - now := time.Now() - next := now.Add(1 * time.Hour) - return &next +// cronScheduleParser parses standard 5-field cron expressions plus +// descriptors (@daily etc.) and CRON_TZ= prefixes. +var cronScheduleParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor) + +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) { - db := auditDB - if db == nil { +// StartCronScheduler ticks every minute and executes enabled cron jobs whose +// next_run_at is due. Each run is recorded in cron_executions and output is +// 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 } + 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--*. +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() db.Exec( - `UPDATE cron_executions SET finished_at = $1, status = $2, output = $3 WHERE id = $4`, - now, "success", "Job completed successfully", execID, + `UPDATE cron_executions SET finished_at = $1, status = $2, output = $3, error = $4 WHERE id = $5`, + now, status, output, errText, execID, ) + next, _ := calculateNextRun(schedule, timezone) db.Exec( - `UPDATE cron_jobs SET last_run_at = $1, last_status = $2, next_run_at = $3 WHERE id = $4`, - now, "success", time.Now().Add(1*time.Hour), jobID, + `UPDATE cron_jobs SET last_run_at = $1, last_status = $2, last_output = $3, next_run_at = $4 WHERE id = $5`, + now, status, output, next, jobID, ) -} -func init() { - cronJobsData, _ := json.Marshal([]CronJob{}) - _ = cronJobsData + if retention > 0 { + db.Exec( + `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, + ) + } } diff --git a/app/backend/internal/api/routes.go b/app/backend/internal/api/routes.go index 43883a3..41083f3 100644 --- a/app/backend/internal/api/routes.go +++ b/app/backend/internal/api/routes.go @@ -57,6 +57,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg // Initialize agent handler (sqlc-backed) agentHandler := NewNodeAgentHandler(db) + // Start the cron scheduler — executes due cron_jobs via docker exec. + StartCronScheduler(context.Background(), db, dockerClient) + // Initialize database handler databaseHandler := NewDatabaseHandler(db.DB, dockerClient) diff --git a/app/backend/internal/docker/client.go b/app/backend/internal/docker/client.go index 15616b0..080f072 100644 --- a/app/backend/internal/docker/client.go +++ b/app/backend/internal/docker/client.go @@ -1,6 +1,7 @@ package docker import ( + "bytes" "context" "fmt" "io" @@ -15,6 +16,7 @@ import ( "github.com/docker/docker/api/types/system" "github.com/docker/docker/api/types/volume" "github.com/docker/docker/client" + "github.com/docker/docker/pkg/stdcopy" ) // 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) } +// 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 func (c *Client) GetImageInfo(ctx context.Context, imageName string) (*ImageInfo, error) { images, err := c.cli.ImageList(ctx, image.ListOptions{}) diff --git a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx index 4428d56..b10da9f 100644 --- a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx +++ b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx @@ -11,8 +11,15 @@ import { listDeployments, listServiceLogs, listServiceVariables, + listCronJobs, + createCronJob, + updateCronJob, + deleteCronJob, + listCronExecutions, + triggerCronJob, rollbackDeployment, updateServiceVariables, + type CronJobEntity, } from '@/lib/api-client'; import { getDemoProjectById, getDemoServiceById } from '@/lib/demo-data'; import { parseDotenv, validateVariableRows, type VariableDraft } from '../variable-utils'; @@ -40,15 +47,18 @@ import { Sparkles, RefreshCw, Box, + Play, + ChevronDown, } 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 }> = [ { key: 'metrics', label: 'Metrics', icon: Activity }, { key: 'logs', label: 'Logs', icon: FileText }, { key: 'config', label: 'Config', icon: Sliders }, { key: 'variables', label: 'Variables', icon: KeyRound }, + { key: 'cron', label: 'Cron', icon: Clock }, { 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(null); + const [historyCronId, setHistoryCronId] = useState(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({ queryKey: ['service-logs', serviceId, logTail], queryFn: () => listServiceLogs(serviceId, { tail: logTail }), @@ -1041,6 +1111,180 @@ export function ServiceDetailPage() { )} + {activeSection === 'cron' && ( +
+
+
+
+ +
+
+

Cron Jobs

+

Scheduled commands executed inside the service container

+
+
+ +
+ + {cronForm && ( +
+
+ 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" + /> + 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" + /> +
+ 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" + /> +
+ 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" + /> + + +
+ + +
+
+ {saveCronMutation.isError && ( +

+ {saveCronMutation.error instanceof Error ? saveCronMutation.error.message : 'Failed to save job'} +

+ )} +
+ )} + + {cronJobsQuery.isLoading ? ( +
+ +
+ ) : (cronJobsQuery.data ?? []).length === 0 ? ( +
+

No cron jobs configured.

+

Create a job to run commands on a schedule inside this service's container.

+
+ ) : ( +
+ {(cronJobsQuery.data ?? []).map((job) => ( +
+
+ +
+
+ {job.name} + {job.schedule} +
+

{job.command}

+

+ {job.next_run_at ? `next ${formatRelative(job.next_run_at)}` : 'not scheduled'} + {job.last_status ? ` · last ${job.last_status}` : ''} +

+
+
+ + + + +
+
+ {historyCronId === job.id && ( +
+ {cronExecutionsQuery.isLoading ? ( +

Loading history…

+ ) : (cronExecutionsQuery.data ?? []).length === 0 ? ( +

No executions yet.

+ ) : ( +
+ {(cronExecutionsQuery.data ?? []).slice(0, 10).map((ex) => ( +
+
+ + {formatRelative(ex.started_at)} + {ex.status} +
+ {(ex.output || ex.error) && ( +
{ex.output || ex.error}
+ )} +
+ ))} +
+ )} +
+ )} +
+ ))} +
+ )} +
+ )} + {activeSection === 'settings' && (
diff --git a/app/frontend/src/lib/api-client.ts b/app/frontend/src/lib/api-client.ts index d63b209..2aca6dc 100644 --- a/app/frontend/src/lib/api-client.ts +++ b/app/frontend/src/lib/api-client.ts @@ -1323,3 +1323,50 @@ export async function createGitWebhook(input: CreateGitWebhookInput): Promise { + const payload = await requestJson<{ cron_jobs?: CronJobEntity[] }>( + `/cron-jobs?service_id=${encodeURIComponent(serviceId)}`, + ); + return payload.cron_jobs ?? []; +} + +export async function createCronJob(input: CreateCronJobInput): Promise { + 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 { + await requestJson(`/cron-jobs/${encodeURIComponent(id)}`, { + method: 'PUT', + body: JSON.stringify(input), + }); +} + +export async function deleteCronJob(id: string): Promise { + await requestJson(`/cron-jobs/${encodeURIComponent(id)}`, { method: 'DELETE' }); +} + +export async function listCronExecutions(id: string): Promise { + const payload = await requestJson<{ executions?: CronExecutionEntity[] }>( + `/cron-jobs/${encodeURIComponent(id)}/executions`, + ); + return payload.executions ?? []; +} + +export async function triggerCronJob(id: string): Promise { + await requestJson(`/cron-jobs/${encodeURIComponent(id)}/trigger`, { method: 'POST' }); +} diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 1c7696c..2ab6e3e 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -3551,6 +3551,11 @@ paths: schema: type: string format: uuid + - name: service_id + in: query + schema: + type: string + format: uuid security: - bearerAuth: [] responses: