initial jobs dashboard

This commit is contained in:
allanice001
2025-09-23 05:33:20 +01:00
parent c50fc1540a
commit 4ee03d5409
27 changed files with 2218 additions and 205 deletions

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
postgres
.idea
docker-compose.yml
atlas.hcl
autoglue
config.yaml
.env

101
.github/workflows/docker-publish.yml vendored Normal file
View File

@@ -0,0 +1,101 @@
name: Docker Publish
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
on:
push:
branches: [ "main" ]
# Publish semver tags as releases.
tags: [ 'v*.*.*' ]
pull_request:
branches: [ "main" ]
env:
# Use docker.io for Docker Hub if empty
REGISTRY: ghcr.io
# github.repository as <account>/<repo>
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
# This is used to complete the identity challenge
# with sigstore/fulcio when running outside of PRs.
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
# Install the cosign tool except on PR
# https://github.com/sigstore/cosign-installer
- name: Install cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
with:
cosign-release: 'v2.2.4'
# Set up BuildKit Docker container builder to be able to build
# multi-platform images and export cache
# https://github.com/docker/setup-buildx-action
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
# Login against a Docker registry except on PR
# https://github.com/docker/login-action
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Extract metadata (tags, labels) for Docker
# https://github.com/docker/metadata-action
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=tag
type=ref,event=branch
type=raw,value=latest
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
platforms: linux/amd64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
# Sign the resulting Docker image digest except on PRs.
# This will only write to the public Rekor transparency log when the Docker
# repository is public to avoid leaking data. If you would like to publish
# transparency data even for private images, pass --force to cosign below.
# https://github.com/sigstore/cosign
- name: Sign the published Docker image
if: ${{ github.event_name != 'pull_request' }}
env:
# https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable
TAGS: ${{ steps.meta.outputs.tags }}
DIGEST: ${{ steps.build-and-push.outputs.digest }}
# This step uses the identity token to provision an ephemeral certificate
# against the sigstore community Fulcio instance.
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}

37
Dockerfile Normal file
View File

@@ -0,0 +1,37 @@
#################################
# Builder: Go + Node in one
#################################
FROM golang:1.25-alpine AS builder
RUN apk add --no-cache \
git ca-certificates tzdata \
build-base \
nodejs npm
RUN npm i -g yarn pnpm
WORKDIR /src
COPY . .
RUN make swagger && make build
#################################
# Runtime
#################################
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata \
&& addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --from=builder /src/autoglue /app/autoglue
ENV PORT=8080
EXPOSE 8080
USER app
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD wget -qO- "http://127.0.0.1:${PORT}/api/healthz" || exit 1
ENTRYPOINT ["/app/autoglue"]

BIN
autoglue

Binary file not shown.

View File

@@ -30,8 +30,9 @@ var serveCmd = &cobra.Command{
Long: "Start the server",
Run: func(cmd *cobra.Command, args []string) {
db.Connect()
gdb := db.DB
jobs, err := bg.NewJobs()
jobs, err := bg.NewJobs(gdb)
if err != nil {
log.Fatalf("failed to init background jobs: %v", err)
}
@@ -44,23 +45,27 @@ var serveCmd = &cobra.Command{
}()
defer jobs.Stop()
// Enqueue one job immediately
/*
id := uuid.NewString()
if _, err := jobs.Enqueue(context.Background(), id, "bootstrap_bastion", bg.BastionBootstrapArgs{}); err != nil {
log.Fatalf("[enqueue] failed (job_id=%s): %v", id, err)
{
// schedule next 03:30 local time
now := time.Now()
next := time.Date(now.Year(), now.Month(), now.Day(), 3, 30, 0, 0, now.Location())
if !next.After(now) {
next = next.Add(24 * time.Hour)
}
log.Printf("[enqueue] queued (job_id=%s)", id)
// Verify the row exists
if got, err := jobs.Client.Get(context.Background(), id); err != nil {
log.Fatalf("[verify] Get failed (job_id=%s): %v", id, err)
} else if j, ok := got.(*job.Job); ok {
log.Printf("[verify] Get ok (job_id=%s, status=%s)", j.ID, j.Status)
} else {
log.Printf("[verify] Get ok (job_id=%s) but unexpected type %T", id, got)
_, err := jobs.Enqueue(
context.Background(),
uuid.NewString(),
"archer_cleanup",
bg.CleanupArgs{RetainDays: 7, Table: "jobs"},
archer.WithScheduleTime(next),
archer.WithMaxRetries(1),
)
if err != nil {
log.Printf("failed to enqueue archer_cleanup: %v", err)
}
*/
}
// Periodic scheduler
schedCtx, schedCancel := context.WithCancel(context.Background())
defer schedCancel()

View File

@@ -1,19 +1,17 @@
services:
# autoglue:
# image: ghcr.io/glueops/autoglue:latest
# build:
# context: .
# dockerfile: Dockerfile
# ports:
# - 8080:8080
# expose:
# - 8080
# env_file: .env
# environment:
# AUTOGLUE_DATABASE_DSN: postgres://autoglue:autoglue@postgres:5432/autoglue
# AUTOGLUE_BIND_ADDRESS: 0.0.0.0
# depends_on:
# - postgres
autoglue:
# image: ghcr.io/glueops/autoglue:latest
build: .
ports:
- 8080:8080
expose:
- 8080
env_file: .env
environment:
AUTOGLUE_DATABASE_DSN: postgres://$DB_USER:$DB_PASSWORD@postgres:5432/$DB_NAME
AUTOGLUE_BIND_ADDRESS: 0.0.0.0
depends_on:
- postgres
postgres:
build:

View File

@@ -2223,6 +2223,360 @@ const docTemplate = `{
}
}
},
"/api/v1/jobs/active": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "Currently running jobs (limit default 100)",
"produces": [
"application/json"
],
"tags": [
"jobs"
],
"summary": "Active jobs",
"parameters": [
{
"type": "integer",
"default": 100,
"description": "Max rows",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/jobs.JobListItem"
}
}
},
"401": {
"description": "unauthorized",
"schema": {
"type": "string"
}
},
"500": {
"description": "internal error",
"schema": {
"type": "string"
}
}
}
}
},
"/api/v1/jobs/enqueue": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "Schedules a job on a queue with optional args/schedule",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"jobs"
],
"summary": "Manually enqueue a job",
"parameters": [
{
"description": "Enqueue request",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/jobs.EnqueueReq"
}
}
],
"responses": {
"202": {
"description": "Accepted",
"schema": {
"$ref": "#/definitions/jobs.EnqueueResp"
}
},
"400": {
"description": "bad request",
"schema": {
"type": "string"
}
},
"401": {
"description": "unauthorized",
"schema": {
"type": "string"
}
},
"500": {
"description": "internal error",
"schema": {
"type": "string"
}
}
}
}
},
"/api/v1/jobs/failures": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "Failed jobs ordered by most recent (limit default 100)",
"produces": [
"application/json"
],
"tags": [
"jobs"
],
"summary": "Recent failures",
"parameters": [
{
"type": "integer",
"default": 100,
"description": "Max rows",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/jobs.JobListItem"
}
}
},
"401": {
"description": "unauthorized",
"schema": {
"type": "string"
}
},
"500": {
"description": "internal error",
"schema": {
"type": "string"
}
}
}
}
},
"/api/v1/jobs/kpi": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "Aggregated counters across all queues",
"produces": [
"application/json"
],
"tags": [
"jobs"
],
"summary": "Jobs KPI",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/jobs.KPI"
}
},
"401": {
"description": "unauthorized",
"schema": {
"type": "string"
}
},
"500": {
"description": "internal error",
"schema": {
"type": "string"
}
}
}
}
},
"/api/v1/jobs/queues": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "Counts and avg duration per queue (last 24h)",
"produces": [
"application/json"
],
"tags": [
"jobs"
],
"summary": "Per-queue rollups",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/jobs.QueueRollup"
}
}
},
"401": {
"description": "unauthorized",
"schema": {
"type": "string"
}
},
"500": {
"description": "internal error",
"schema": {
"type": "string"
}
}
}
}
},
"/api/v1/jobs/{id}/cancel": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "Cancels running or scheduled jobs",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"jobs"
],
"summary": "Cancel a job",
"parameters": [
{
"type": "string",
"description": "Job ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "no content",
"schema": {
"type": "string"
}
},
"400": {
"description": "bad request",
"schema": {
"type": "string"
}
},
"401": {
"description": "unauthorized",
"schema": {
"type": "string"
}
},
"404": {
"description": "not found",
"schema": {
"type": "string"
}
},
"500": {
"description": "internal error",
"schema": {
"type": "string"
}
}
}
}
},
"/api/v1/jobs/{id}/retry": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "Calls Archer ScheduleNow on the job id",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"jobs"
],
"summary": "Retry a job immediately",
"parameters": [
{
"type": "string",
"description": "Job ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "no content",
"schema": {
"type": "string"
}
},
"400": {
"description": "bad request",
"schema": {
"type": "string"
}
},
"401": {
"description": "unauthorized",
"schema": {
"type": "string"
}
},
"404": {
"description": "not found",
"schema": {
"type": "string"
}
},
"500": {
"description": "internal error",
"schema": {
"type": "string"
}
}
}
}
},
"/api/v1/labels": {
"get": {
"security": [
@@ -6335,6 +6689,110 @@ const docTemplate = `{
}
}
},
"jobs.EnqueueReq": {
"type": "object"
},
"jobs.EnqueueResp": {
"type": "object",
"properties": {
"id": {
"type": "string"
}
}
},
"jobs.JobListItem": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"last_error": {
"type": "string"
},
"max_retry": {
"type": "integer"
},
"queue_name": {
"type": "string"
},
"retry_count": {
"type": "integer"
},
"scheduled_at": {
"type": "string"
},
"started_at": {
"type": "string"
},
"status": {
"type": "string"
},
"updated_at": {
"type": "string"
}
}
},
"jobs.KPI": {
"type": "object",
"properties": {
"dueNow": {
"type": "integer",
"format": "int64"
},
"failed24h": {
"type": "integer",
"format": "int64"
},
"retryable": {
"type": "integer",
"format": "int64"
},
"runningNow": {
"type": "integer",
"format": "int64"
},
"scheduledFuture": {
"type": "integer",
"format": "int64"
},
"succeeded24h": {
"type": "integer",
"format": "int64"
}
}
},
"jobs.QueueRollup": {
"type": "object",
"properties": {
"avgDurationSecs": {
"type": "number",
"format": "float64"
},
"failed24h": {
"type": "integer",
"format": "int64"
},
"queueName": {
"type": "string"
},
"queuedDue": {
"type": "integer",
"format": "int64"
},
"queuedFuture": {
"type": "integer",
"format": "int64"
},
"running": {
"type": "integer",
"format": "int64"
},
"success24h": {
"type": "integer",
"format": "int64"
}
}
},
"labels.addLabelToPoolRequest": {
"type": "object",
"properties": {

View File

@@ -2219,6 +2219,360 @@
}
}
},
"/api/v1/jobs/active": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "Currently running jobs (limit default 100)",
"produces": [
"application/json"
],
"tags": [
"jobs"
],
"summary": "Active jobs",
"parameters": [
{
"type": "integer",
"default": 100,
"description": "Max rows",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/jobs.JobListItem"
}
}
},
"401": {
"description": "unauthorized",
"schema": {
"type": "string"
}
},
"500": {
"description": "internal error",
"schema": {
"type": "string"
}
}
}
}
},
"/api/v1/jobs/enqueue": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "Schedules a job on a queue with optional args/schedule",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"jobs"
],
"summary": "Manually enqueue a job",
"parameters": [
{
"description": "Enqueue request",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/jobs.EnqueueReq"
}
}
],
"responses": {
"202": {
"description": "Accepted",
"schema": {
"$ref": "#/definitions/jobs.EnqueueResp"
}
},
"400": {
"description": "bad request",
"schema": {
"type": "string"
}
},
"401": {
"description": "unauthorized",
"schema": {
"type": "string"
}
},
"500": {
"description": "internal error",
"schema": {
"type": "string"
}
}
}
}
},
"/api/v1/jobs/failures": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "Failed jobs ordered by most recent (limit default 100)",
"produces": [
"application/json"
],
"tags": [
"jobs"
],
"summary": "Recent failures",
"parameters": [
{
"type": "integer",
"default": 100,
"description": "Max rows",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/jobs.JobListItem"
}
}
},
"401": {
"description": "unauthorized",
"schema": {
"type": "string"
}
},
"500": {
"description": "internal error",
"schema": {
"type": "string"
}
}
}
}
},
"/api/v1/jobs/kpi": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "Aggregated counters across all queues",
"produces": [
"application/json"
],
"tags": [
"jobs"
],
"summary": "Jobs KPI",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/jobs.KPI"
}
},
"401": {
"description": "unauthorized",
"schema": {
"type": "string"
}
},
"500": {
"description": "internal error",
"schema": {
"type": "string"
}
}
}
}
},
"/api/v1/jobs/queues": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "Counts and avg duration per queue (last 24h)",
"produces": [
"application/json"
],
"tags": [
"jobs"
],
"summary": "Per-queue rollups",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/jobs.QueueRollup"
}
}
},
"401": {
"description": "unauthorized",
"schema": {
"type": "string"
}
},
"500": {
"description": "internal error",
"schema": {
"type": "string"
}
}
}
}
},
"/api/v1/jobs/{id}/cancel": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "Cancels running or scheduled jobs",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"jobs"
],
"summary": "Cancel a job",
"parameters": [
{
"type": "string",
"description": "Job ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "no content",
"schema": {
"type": "string"
}
},
"400": {
"description": "bad request",
"schema": {
"type": "string"
}
},
"401": {
"description": "unauthorized",
"schema": {
"type": "string"
}
},
"404": {
"description": "not found",
"schema": {
"type": "string"
}
},
"500": {
"description": "internal error",
"schema": {
"type": "string"
}
}
}
}
},
"/api/v1/jobs/{id}/retry": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "Calls Archer ScheduleNow on the job id",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"jobs"
],
"summary": "Retry a job immediately",
"parameters": [
{
"type": "string",
"description": "Job ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "no content",
"schema": {
"type": "string"
}
},
"400": {
"description": "bad request",
"schema": {
"type": "string"
}
},
"401": {
"description": "unauthorized",
"schema": {
"type": "string"
}
},
"404": {
"description": "not found",
"schema": {
"type": "string"
}
},
"500": {
"description": "internal error",
"schema": {
"type": "string"
}
}
}
}
},
"/api/v1/labels": {
"get": {
"security": [
@@ -6331,6 +6685,110 @@
}
}
},
"jobs.EnqueueReq": {
"type": "object"
},
"jobs.EnqueueResp": {
"type": "object",
"properties": {
"id": {
"type": "string"
}
}
},
"jobs.JobListItem": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"last_error": {
"type": "string"
},
"max_retry": {
"type": "integer"
},
"queue_name": {
"type": "string"
},
"retry_count": {
"type": "integer"
},
"scheduled_at": {
"type": "string"
},
"started_at": {
"type": "string"
},
"status": {
"type": "string"
},
"updated_at": {
"type": "string"
}
}
},
"jobs.KPI": {
"type": "object",
"properties": {
"dueNow": {
"type": "integer",
"format": "int64"
},
"failed24h": {
"type": "integer",
"format": "int64"
},
"retryable": {
"type": "integer",
"format": "int64"
},
"runningNow": {
"type": "integer",
"format": "int64"
},
"scheduledFuture": {
"type": "integer",
"format": "int64"
},
"succeeded24h": {
"type": "integer",
"format": "int64"
}
}
},
"jobs.QueueRollup": {
"type": "object",
"properties": {
"avgDurationSecs": {
"type": "number",
"format": "float64"
},
"failed24h": {
"type": "integer",
"format": "int64"
},
"queueName": {
"type": "string"
},
"queuedDue": {
"type": "integer",
"format": "int64"
},
"queuedFuture": {
"type": "integer",
"format": "int64"
},
"running": {
"type": "integer",
"format": "int64"
},
"success24h": {
"type": "integer",
"format": "int64"
}
}
},
"labels.addLabelToPoolRequest": {
"type": "object",
"properties": {

View File

@@ -337,6 +337,78 @@ definitions:
status:
type: string
type: object
jobs.EnqueueReq:
type: object
jobs.EnqueueResp:
properties:
id:
type: string
type: object
jobs.JobListItem:
properties:
id:
type: string
last_error:
type: string
max_retry:
type: integer
queue_name:
type: string
retry_count:
type: integer
scheduled_at:
type: string
started_at:
type: string
status:
type: string
updated_at:
type: string
type: object
jobs.KPI:
properties:
dueNow:
format: int64
type: integer
failed24h:
format: int64
type: integer
retryable:
format: int64
type: integer
runningNow:
format: int64
type: integer
scheduledFuture:
format: int64
type: integer
succeeded24h:
format: int64
type: integer
type: object
jobs.QueueRollup:
properties:
avgDurationSecs:
format: float64
type: number
failed24h:
format: int64
type: integer
queueName:
type: string
queuedDue:
format: int64
type: integer
queuedFuture:
format: int64
type: integer
running:
format: int64
type: integer
success24h:
format: int64
type: integer
type: object
labels.addLabelToPoolRequest:
properties:
node_pool_ids:
@@ -2191,6 +2263,230 @@ paths:
summary: Detach one node pool from a cluster (org scoped)
tags:
- clusters
/api/v1/jobs/{id}/cancel:
post:
consumes:
- application/json
description: Cancels running or scheduled jobs
parameters:
- description: Job ID
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"204":
description: no content
schema:
type: string
"400":
description: bad request
schema:
type: string
"401":
description: unauthorized
schema:
type: string
"404":
description: not found
schema:
type: string
"500":
description: internal error
schema:
type: string
security:
- BearerAuth: []
summary: Cancel a job
tags:
- jobs
/api/v1/jobs/{id}/retry:
post:
consumes:
- application/json
description: Calls Archer ScheduleNow on the job id
parameters:
- description: Job ID
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"204":
description: no content
schema:
type: string
"400":
description: bad request
schema:
type: string
"401":
description: unauthorized
schema:
type: string
"404":
description: not found
schema:
type: string
"500":
description: internal error
schema:
type: string
security:
- BearerAuth: []
summary: Retry a job immediately
tags:
- jobs
/api/v1/jobs/active:
get:
description: Currently running jobs (limit default 100)
parameters:
- default: 100
description: Max rows
in: query
name: limit
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/jobs.JobListItem'
type: array
"401":
description: unauthorized
schema:
type: string
"500":
description: internal error
schema:
type: string
security:
- BearerAuth: []
summary: Active jobs
tags:
- jobs
/api/v1/jobs/enqueue:
post:
consumes:
- application/json
description: Schedules a job on a queue with optional args/schedule
parameters:
- description: Enqueue request
in: body
name: payload
required: true
schema:
$ref: '#/definitions/jobs.EnqueueReq'
produces:
- application/json
responses:
"202":
description: Accepted
schema:
$ref: '#/definitions/jobs.EnqueueResp'
"400":
description: bad request
schema:
type: string
"401":
description: unauthorized
schema:
type: string
"500":
description: internal error
schema:
type: string
security:
- BearerAuth: []
summary: Manually enqueue a job
tags:
- jobs
/api/v1/jobs/failures:
get:
description: Failed jobs ordered by most recent (limit default 100)
parameters:
- default: 100
description: Max rows
in: query
name: limit
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/jobs.JobListItem'
type: array
"401":
description: unauthorized
schema:
type: string
"500":
description: internal error
schema:
type: string
security:
- BearerAuth: []
summary: Recent failures
tags:
- jobs
/api/v1/jobs/kpi:
get:
description: Aggregated counters across all queues
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/jobs.KPI'
"401":
description: unauthorized
schema:
type: string
"500":
description: internal error
schema:
type: string
security:
- BearerAuth: []
summary: Jobs KPI
tags:
- jobs
/api/v1/jobs/queues:
get:
description: Counts and avg duration per queue (last 24h)
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/jobs.QueueRollup'
type: array
"401":
description: unauthorized
schema:
type: string
"500":
description: internal error
schema:
type: string
security:
- BearerAuth: []
summary: Per-queue rollups
tags:
- jobs
/api/v1/labels:
get:
consumes:

35
go.mod
View File

@@ -11,29 +11,29 @@ require (
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/spf13/cobra v1.10.1
github.com/spf13/viper v1.20.1
github.com/spf13/viper v1.21.0
github.com/swaggo/http-swagger/v2 v2.0.2
github.com/swaggo/swag v1.16.6
github.com/wneessen/go-mail v0.6.2
golang.org/x/crypto v0.41.0
golang.org/x/text v0.28.0
github.com/wneessen/go-mail v0.7.0
golang.org/x/crypto v0.42.0
golang.org/x/text v0.29.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/datatypes v1.2.6
gorm.io/datatypes v1.2.7
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.30.2
gorm.io/gorm v1.31.0
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/jsonreference v0.20.0 // indirect
github.com/go-openapi/spec v0.20.6 // indirect
github.com/go-openapi/swag v0.19.15 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
@@ -46,21 +46,20 @@ require (
github.com/josharian/intern v1.0.0 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/mailru/easyjson v0.7.6 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rogpeppe/go-internal v1.13.1 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.12.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/swaggo/files/v2 v2.0.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/mod v0.27.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/tools v0.36.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gorm.io/driver/mysql v1.5.6 // indirect

149
go.sum
View File

@@ -16,8 +16,8 @@ github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/
github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
@@ -35,8 +35,8 @@ github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
@@ -45,7 +45,6 @@ github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0kt
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -81,41 +80,42 @@ github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI=
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA=
github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw=
@@ -124,89 +124,24 @@ github.com/swaggo/http-swagger/v2 v2.0.2 h1:FKCdLsl+sFCx60KFsyM0rDarwiUSZ8DqbfSy
github.com/swaggo/http-swagger/v2 v2.0.2/go.mod h1:r7/GBkAWIfK6E/OLnE8fXnviHiDeAHmgIyooa4xm3AQ=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/wneessen/go-mail v0.6.2 h1:c6V7c8D2mz868z9WJ+8zDKtUyLfZ1++uAZmo2GRFji8=
github.com/wneessen/go-mail v0.6.2/go.mod h1:L/PYjPK3/2ZlNb2/FjEBIn9n1rUWjW+Toy531oVmeb4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
github.com/wneessen/go-mail v0.7.0 h1:/Wmgd5AVjp5PA+Ken5EFfr+QR83gmqHli9HcAhh0vnU=
github.com/wneessen/go-mail v0.7.0/go.mod h1:+TkW6QP3EVkgTEqHtVmnAE/1MRhmzb8Y9/W3pweuS+k=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -219,16 +154,16 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/datatypes v1.2.6 h1:KafLdXvFUhzNeL2ncm03Gl3eTLONQfNKZ+wJ+9Y4Nck=
gorm.io/datatypes v1.2.6/go.mod h1:M2iO+6S3hhi4nAyYe444Pcb0dcIiOMJ7QHaUXxyiNZY=
gorm.io/datatypes v1.2.7 h1:ww9GAhF1aGXZY3EB3cJPJ7//JiuQo7DlQA7NNlVaTdk=
gorm.io/datatypes v1.2.7/go.mod h1:M2iO+6S3hhi4nAyYe444Pcb0dcIiOMJ7QHaUXxyiNZY=
gorm.io/driver/mysql v1.5.6 h1:Ld4mkIickM+EliaQZQx3uOJDJHtrd70MxAUqWqlx3Y8=
gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/driver/sqlite v1.4.3 h1:HBBcZSDnWi5BW3B3rwvVTc510KGkBkexlOg0QrmLUuU=
gorm.io/driver/sqlite v1.4.3/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/driver/sqlserver v1.6.0 h1:VZOBQVsVhkHU/NzNhRJKoANt5pZGQAS1Bwc6m6dgfnc=
gorm.io/driver/sqlserver v1.6.0/go.mod h1:WQzt4IJo/WHKnckU9jXBLMJIVNMVeTu25dnOzehntWw=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.30.2 h1:f7bevlVoVe4Byu3pmbWPVHnPsLoWaMjEb7/clyr9Ivs=
gorm.io/gorm v1.30.2/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY=
gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=

View File

@@ -8,6 +8,7 @@ import (
"github.com/glueops/autoglue/internal/handlers/authn"
"github.com/glueops/autoglue/internal/handlers/clusters"
"github.com/glueops/autoglue/internal/handlers/health"
"github.com/glueops/autoglue/internal/handlers/jobs"
"github.com/glueops/autoglue/internal/handlers/labels"
"github.com/glueops/autoglue/internal/handlers/nodepools"
"github.com/glueops/autoglue/internal/handlers/orgs"
@@ -36,6 +37,17 @@ func RegisterRoutes(r chi.Router) {
ad.Delete("/users/{userId}", authn.AdminDeleteUser)
})
v1.Route("/jobs", func(j chi.Router) {
j.Use(authMW)
j.Get("/kpi", jobs.GetKPI)
j.Get("/queues", jobs.GetQueues)
j.Get("/active", jobs.GetActive)
j.Get("/failures", jobs.GetFailures)
j.Post("/{id}/retry", jobs.RetryNow)
j.Post("/{id}/cancel", jobs.Cancel)
j.Post("/{id}/enqueue", jobs.Enqueue)
})
v1.Route("/auth", func(a chi.Router) {
a.Post("/login", authn.Login)
a.Post("/register", authn.Register)

View File

@@ -1,4 +1,3 @@
// internal/bg/bg.go
package bg
import (
@@ -11,10 +10,13 @@ import (
"github.com/dyaksa/archer"
"github.com/spf13/viper"
"gorm.io/gorm"
)
type Jobs struct{ Client *archer.Client }
var BgJobs *Jobs
func archerOptionsFromDSN(dsn string) (*archer.Options, error) {
u, err := url.Parse(dsn)
if err != nil {
@@ -40,7 +42,7 @@ func archerOptionsFromDSN(dsn string) (*archer.Options, error) {
}, nil
}
func NewJobs() (*Jobs, error) {
func NewJobs(gdb *gorm.DB) (*Jobs, error) {
opts, err := archerOptionsFromDSN(viper.GetString("database.dsn"))
if err != nil {
return nil, err
@@ -54,6 +56,10 @@ func NewJobs() (*Jobs, error) {
if timeoutSec <= 0 {
timeoutSec = 60
}
retainDays := viper.GetInt("archer.cleanup_retain_days")
if retainDays <= 0 {
retainDays = 7
}
// LOG what were connecting to (sanitized) so you can confirm DB/host
log.Printf("[archer] addr=%s db=%s user=%s ssl=%s", opts.Addr, opts.DBName, opts.User, opts.SSL)
@@ -74,7 +80,15 @@ func NewJobs() (*Jobs, error) {
archer.WithTimeout(time.Duration(timeoutSec)*time.Second),
)
return &Jobs{Client: c}, nil
jobs := &Jobs{Client: c}
c.Register(
"archer_cleanup",
CleanupWorker(gdb, jobs, retainDays),
archer.WithInstances(1),
archer.WithTimeout(5*time.Minute),
)
return jobs, nil
}
func (j *Jobs) Start() error { return j.Client.Start() }

53
internal/bg/cleanup.go Normal file
View File

@@ -0,0 +1,53 @@
package bg
import (
"context"
"time"
"github.com/dyaksa/archer"
"github.com/dyaksa/archer/job"
"github.com/google/uuid"
"gorm.io/gorm"
)
type CleanupArgs struct {
RetainDays int `json:"retain_days"`
Table string `json:"table"`
}
type JobRow struct {
ID string `gorm:"primaryKey"`
Status string
UpdatedAt time.Time
}
func (JobRow) TableName() string { return "jobs" }
func CleanupWorker(gdb *gorm.DB, jobs *Jobs, retainDays int) archer.WorkerFn {
return func(ctx context.Context, j job.Job) (any, error) {
if err := CleanupJobs(gdb, retainDays); err != nil {
return nil, err
}
// schedule tomorrow 03:30
next := time.Now().Truncate(24 * time.Hour).Add(24 * time.Hour).Add(3*time.Hour + 30*time.Minute)
_, _ = jobs.Enqueue(
ctx,
uuid.NewString(),
"archer_cleanup",
CleanupArgs{RetainDays: retainDays, Table: "jobs"},
archer.WithScheduleTime(next),
archer.WithMaxRetries(1),
)
return nil, nil
}
}
func CleanupJobs(db *gorm.DB, retainDays int) error {
cutoff := time.Now().AddDate(0, 0, -retainDays)
return db.
Where("status IN ?", []string{"success", "failed", "cancelled"}).
Where("updated_at < ?", cutoff).
Delete(&JobRow{}).Error
}

View File

@@ -0,0 +1,293 @@
package jobs
import (
"encoding/json"
"net/http"
"strconv"
"time"
"github.com/dyaksa/archer"
"github.com/glueops/autoglue/internal/bg"
"github.com/glueops/autoglue/internal/db"
"github.com/glueops/autoglue/internal/db/models"
"github.com/glueops/autoglue/internal/middleware"
"github.com/glueops/autoglue/internal/response"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
type JobListItem struct {
ID string `json:"id"`
QueueName string `json:"queue_name"`
Status string `json:"status"`
RetryCount int `json:"retry_count"`
MaxRetry int `json:"max_retry"`
ScheduledAt time.Time `json:"scheduled_at"`
StartedAt *time.Time `json:"started_at,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
LastError *string `json:"last_error,omitempty"`
}
type EnqueueReq struct {
Queue string `json:"queue"`
Args json.RawMessage `json:"args"` // keep raw and pass through to Archer
MaxRetries *int `json:"max_retries,omitempty"`
ScheduleAt *time.Time `json:"schedule_at,omitempty"`
}
type EnqueueResp struct {
ID string `json:"id"`
}
func parseLimit(r *http.Request, def int) int {
if s := r.URL.Query().Get("limit"); s != "" {
if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 1000 {
return n
}
}
return def
}
func isNotFoundErr(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return msg == "job not found" || msg == "no rows in result set"
}
// ---------------------- READ ENDPOINTS ----------------------
// GetKPI godoc
// @Summary Jobs KPI
// @Description Aggregated counters across all queues
// @Tags jobs
// @Security BearerAuth
// @Produce json
// @Success 200 {object} jobs.KPI
// @Failure 401 {string} string "unauthorized"
// @Failure 500 {string} string "internal error"
// @Router /api/v1/jobs/kpi [get]
func GetKPI(w http.ResponseWriter, r *http.Request) {
if middleware.GetAuthContext(r) == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
k, err := LoadKPI(db.DB)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_ = response.JSON(w, http.StatusOK, k)
}
// GetQueues godoc
// @Summary Per-queue rollups
// @Description Counts and avg duration per queue (last 24h)
// @Tags jobs
// @Security BearerAuth
// @Produce json
// @Success 200 {array} jobs.QueueRollup
// @Failure 401 {string} string "unauthorized"
// @Failure 500 {string} string "internal error"
// @Router /api/v1/jobs/queues [get]
func GetQueues(w http.ResponseWriter, r *http.Request) {
if middleware.GetAuthContext(r) == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
rows, err := LoadPerQueue(db.DB)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_ = response.JSON(w, http.StatusOK, rows)
}
// GetActive godoc
// @Summary Active jobs
// @Description Currently running jobs (limit default 100)
// @Tags jobs
// @Security BearerAuth
// @Produce json
// @Param limit query int false "Max rows" default(100)
// @Success 200 {array} jobs.JobListItem
// @Failure 401 {string} string "unauthorized"
// @Failure 500 {string} string "internal error"
// @Router /api/v1/jobs/active [get]
func GetActive(w http.ResponseWriter, r *http.Request) {
if middleware.GetAuthContext(r) == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
limit := parseLimit(r, 100)
var rows []JobListItem
err := db.DB.Model(&models.Job{}).
Select("id, queue_name, status, retry_count, max_retry, scheduled_at, started_at, updated_at, last_error").
Where("status = ?", "running").
Order("started_at DESC NULLS LAST, updated_at DESC").
Limit(limit).
Scan(&rows).Error
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_ = response.JSON(w, http.StatusOK, rows)
}
// GetFailures godoc
// @Summary Recent failures
// @Description Failed jobs ordered by most recent (limit default 100)
// @Tags jobs
// @Security BearerAuth
// @Produce json
// @Param limit query int false "Max rows" default(100)
// @Success 200 {array} jobs.JobListItem
// @Failure 401 {string} string "unauthorized"
// @Failure 500 {string} string "internal error"
// @Router /api/v1/jobs/failures [get]
func GetFailures(w http.ResponseWriter, r *http.Request) {
if middleware.GetAuthContext(r) == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
limit := parseLimit(r, 100)
var rows []JobListItem
err := db.DB.Model(&models.Job{}).
Select("id, queue_name, status, retry_count, max_retry, scheduled_at, started_at, updated_at, last_error").
Where("status = ?", "failed").
Order("updated_at DESC").
Limit(limit).
Scan(&rows).Error
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_ = response.JSON(w, http.StatusOK, rows)
}
// ---------------------- MUTATION ENDPOINTS ----------------------
// RetryNow godoc
// @Summary Retry a job immediately
// @Description Calls Archer ScheduleNow on the job id
// @Tags jobs
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param id path string true "Job ID"
// @Success 204 {string} string "no content"
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "unauthorized"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "internal error"
// @Router /api/v1/jobs/{id}/retry [post]
func RetryNow(w http.ResponseWriter, r *http.Request) {
if middleware.GetAuthContext(r) == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
id := chi.URLParam(r, "id")
if id == "" {
http.Error(w, "missing id", http.StatusBadRequest)
return
}
// archer.ScheduleNow returns (any, error); if the id is unknown, expect an error you can surface as 404
if _, err := bg.BgJobs.Client.ScheduleNow(r.Context(), id); err != nil {
status := http.StatusInternalServerError
// (Optional) map error text if Archer returns a recognizable "not found"
if isNotFoundErr(err) {
status = http.StatusNotFound
}
http.Error(w, err.Error(), status)
return
}
response.NoContent(w)
}
// Cancel godoc
// @Summary Cancel a job
// @Description Cancels running or scheduled jobs
// @Tags jobs
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param id path string true "Job ID"
// @Success 204 {string} string "no content"
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "unauthorized"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "internal error"
// @Router /api/v1/jobs/{id}/cancel [post]
func Cancel(w http.ResponseWriter, r *http.Request) {
if middleware.GetAuthContext(r) == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
id := chi.URLParam(r, "id")
if id == "" {
http.Error(w, "missing id", http.StatusBadRequest)
return
}
if _, err := bg.BgJobs.Client.Cancel(r.Context(), id); err != nil {
status := http.StatusInternalServerError
if isNotFoundErr(err) {
status = http.StatusNotFound
}
http.Error(w, err.Error(), status)
return
}
response.NoContent(w)
}
// Enqueue godoc
// @Summary Manually enqueue a job
// @Description Schedules a job on a queue with optional args/schedule
// @Tags jobs
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param payload body jobs.EnqueueReq true "Enqueue request"
// @Success 202 {object} jobs.EnqueueResp
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "unauthorized"
// @Failure 500 {string} string "internal error"
// @Router /api/v1/jobs/enqueue [post]
func Enqueue(w http.ResponseWriter, r *http.Request) {
if middleware.GetAuthContext(r) == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req EnqueueReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid json: "+err.Error(), http.StatusBadRequest)
return
}
if req.Queue == "" {
http.Error(w, "queue is required", http.StatusBadRequest)
return
}
id := uuid.NewString()
opts := []archer.FnOptions{}
if req.MaxRetries != nil {
opts = append(opts, archer.WithMaxRetries(*req.MaxRetries))
}
if req.ScheduleAt != nil {
opts = append(opts, archer.WithScheduleTime(*req.ScheduleAt))
}
if _, err := bg.BgJobs.Client.Schedule(r.Context(), id, req.Queue, req.Args, opts...); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_ = response.JSON(w, http.StatusAccepted, EnqueueResp{ID: id})
}

View File

@@ -0,0 +1,66 @@
package jobs
import (
"time"
"github.com/glueops/autoglue/internal/db/models"
"gorm.io/gorm"
)
type KPI struct {
RunningNow int64
DueNow int64
ScheduledFuture int64
Succeeded24h int64
Failed24h int64
Retryable int64
}
func LoadKPI(db *gorm.DB) (KPI, error) {
var k KPI
now := time.Now()
dayAgo := now.Add(-24 * time.Hour)
if err := db.Model(&models.Job{}).
Where("status = ?", "running").
Count(&k.RunningNow).Error; err != nil {
return k, err
}
if err := db.Model(&models.Job{}).
Where("status IN ?", []string{"queued", "scheduled", "pending"}).
Where("scheduled_at > ?", now).
Count(&k.ScheduledFuture).Error; err != nil {
return k, err
}
if err := db.Model(&models.Job{}).
Where("status IN ?", []string{"queued", "scheduled", "pending"}).
Where("scheduled_at <= ?", now).
Count(&k.DueNow).Error; err != nil {
return k, err
}
if err := db.Model(&models.Job{}).
Where("status = ?", "success").
Where("updated_at >= ?", dayAgo).
Count(&k.Succeeded24h).Error; err != nil {
return k, err
}
if err := db.Model(&models.Job{}).
Where("status = ?", "failed").
Where("updated_at >= ?", dayAgo).
Count(&k.Failed24h).Error; err != nil {
return k, err
}
if err := db.Model(&models.Job{}).
Where("status = ?", "failed").
Where("retry_count < max_retry").
Count(&k.Retryable).Error; err != nil {
return k, err
}
return k, nil
}

View File

@@ -0,0 +1,64 @@
package jobs
import (
"time"
"github.com/glueops/autoglue/internal/db/models"
"gorm.io/gorm"
)
type QueueRollup struct {
QueueName string
Running int64
QueuedDue int64
QueuedFuture int64
Success24h int64
Failed24h int64
AvgDurationSecs float64
}
func LoadPerQueue(db *gorm.DB) ([]QueueRollup, error) {
var queues []string
if err := db.Model(&models.Job{}).Distinct().Pluck("queue_name", &queues).Error; err != nil {
return nil, err
}
now := time.Now()
dayAgo := now.Add(-24 * time.Hour)
out := make([]QueueRollup, 0, len(queues))
for _, q := range queues {
var rr, qd, qf, s24, f24 int64
var avgDur *float64
_ = db.Model(&models.Job{}).Where("queue_name = ? AND status = 'running'", q).Count(&rr).Error
_ = db.Model(&models.Job{}).Where("queue_name = ? AND status IN ('queued','scheduled','pending') AND scheduled_at <= ?", q, now).Count(&qd).Error
_ = db.Model(&models.Job{}).Where("queue_name = ? AND status IN ('queued','scheduled','pending') AND scheduled_at > ?", q, now).Count(&qf).Error
_ = db.Model(&models.Job{}).Where("queue_name = ? AND status = 'success' AND updated_at >= ?", q, dayAgo).Count(&s24).Error
_ = db.Model(&models.Job{}).Where("queue_name = ? AND status = 'failed' AND updated_at >= ?", q, dayAgo).Count(&f24).Error
_ = db.
Model(&models.Job{}).
Select("AVG(EXTRACT(EPOCH FROM (updated_at - started_at)))").
Where("queue_name = ? AND status = 'success' AND started_at IS NOT NULL AND updated_at >= ?", q, dayAgo).
Scan(&avgDur).Error
out = append(out, QueueRollup{
QueueName: q,
Running: rr,
QueuedDue: qd,
QueuedFuture: qf,
Success24h: s24,
Failed24h: f24,
AvgDurationSecs: coalesceF64(avgDur, 0),
})
}
return out, nil
}
func coalesceF64(p *float64, d float64) float64 {
if p == nil {
return d
}
return *p
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -5,12 +5,12 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AutoGlue</title>
<script type="module" crossorigin src="/assets/index-CmZFDWt2.js"></script>
<script type="module" crossorigin src="/assets/index-DSxuk_EI.js"></script>
<link rel="modulepreload" crossorigin href="/assets/router-CANfZtzM.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-DvippHRz.js">
<link rel="modulepreload" crossorigin href="/assets/radix-DRmH1vcw.js">
<link rel="modulepreload" crossorigin href="/assets/icons-DQ1I1M7X.js">
<link rel="stylesheet" crossorigin href="/assets/index--a4aJrTK.css">
<link rel="modulepreload" crossorigin href="/assets/icons-B5E6SSBo.js">
<link rel="stylesheet" crossorigin href="/assets/index-CHoyJPs-.css">
</head>
<body>
<div id="root"></div>

View File

@@ -21,6 +21,7 @@ import { NotFoundPage } from "@/pages/error/not-found.tsx"
import { SshKeysPage } from "@/pages/security/ssh.tsx"
import { MemberManagement } from "@/pages/settings/members.tsx"
import { OrgManagement } from "@/pages/settings/orgs.tsx"
import JobsDashboard from "@/pages/settings/jobs.tsx";
function App() {
return (
@@ -57,6 +58,7 @@ function App() {
</Route>
<Route path="/settings">
<Route path="jobs" element={<JobsDashboard />} />
<Route path="orgs" element={<OrgManagement />} />
<Route path="members" element={<MemberManagement />} />
<Route path="me" element={<Me />} />

View File

@@ -18,6 +18,7 @@ import {
UsersIcon,
} from "lucide-react"
import { AiOutlineCluster } from "react-icons/ai"
import {GrUserWorker} from "react-icons/gr";
export type NavItem = {
label: string
@@ -95,6 +96,11 @@ export const items = [
label: "Settings",
icon: SettingsIcon,
items: [
{
label: "Jobs",
icon: GrUserWorker,
to: '/settings/jobs',
},
{
label: "Organizations",
to: "/settings/orgs",

View File

@@ -74,8 +74,8 @@ const CreateClusterSchema = z.object({
name: z.string().trim().min(2, "Name is too short"),
provider: z.string().trim().min(2, "Provider is too short"),
region: z.string().trim().min(1, "Region is required"),
node_pool_ids: z.array(z.string().uuid()).optional().default([]),
bastion_server_id: z.string().uuid().optional(),
node_pool_ids: z.array(z.uuid()).default([]).optional(),
bastion_server_id: z.uuid().optional(),
cluster_load_balancer: z.string().optional(),
control_load_balancer: z.string().optional(),
kubeconfig: z.string().optional(),
@@ -88,7 +88,7 @@ const UpdateClusterSchema = z
provider: z.string().trim().min(2, "Provider is too short").optional(),
region: z.string().trim().min(1, "Region is required").optional(),
status: z.string().trim().min(1, "Status is required").optional(),
bastion_server_id: z.string().uuid().or(z.literal("")).optional(),
bastion_server_id: z.uuid().or(z.literal("")).optional(),
cluster_load_balancer: z.string().optional(),
control_load_balancer: z.string().optional(),
kubeconfig: z.string().optional(),
@@ -113,7 +113,7 @@ const AttachPoolsSchema = z.object({
export type AttachPoolsValues = z.infer<typeof AttachPoolsSchema>
const SetBastionSchema = z.object({
server_id: z.string().uuid({ message: "Enter a valid Server UUID" }),
server_id: z.uuid({ message: "Enter a valid Server UUID" }),
})
export type SetBastionValues = z.infer<typeof SetBastionSchema>

View File

@@ -0,0 +1,189 @@
import {useCallback, useEffect, useMemo, useState} from "react";
import {api} from "@/lib/api.ts";
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card.tsx";
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table.tsx";
export interface KPI {
RunningNow: number;
DueNow: number;
ScheduledFuture: number;
Succeeded24h: number;
Failed24h: number;
Retryable: number;
}
export interface QueueRollup {
QueueName: string;
Running: number;
QueuedDue: number;
QueuedFuture: number;
Success24h: number;
Failed24h: number;
AvgDurationSecs: number;
}
export interface JobListItem {
id: string;
queue_name: string;
status: string;
retry_count: number;
max_retry: number;
scheduled_at: string; // ISO
started_at?: string; // ISO
updated_at: string; // ISO
last_error?: string;
}
const fmtNumber = (n: number | undefined) => (n ?? 0).toLocaleString();
const fmtSeconds = (secs: number) => {
if (!isFinite(secs) || secs <= 0) return "";
if (secs < 60) return `${secs.toFixed(0)}s`;
if (secs < 3600) return `${Math.floor(secs / 60)}m ${Math.floor(secs % 60)}s`;
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
return `${h}h ${m}m`;
};
export default function JobsDashboard() {
const [kpi, setKpi] = useState<KPI | null>(null);
const [queues, setQueues] = useState<QueueRollup[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [autoRefresh, setAutoRefresh] = useState(true);
const [refreshMs, setRefreshMs] = useState(5000);
const loadAll = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [k, q] = await Promise.all([
api.get<KPI>("/api/v1/jobs/kpi"),
api.get<QueueRollup[]>("/api/v1/jobs/queues"),
])
setKpi(k)
setQueues(q)
} catch (e: any) {
setError(e.message || String(e));
} finally {
setLoading(false);
}
}, [])
useEffect(() => { void loadAll(); }, [loadAll]);
useEffect(() => {
if (!autoRefresh) return;
const id = setInterval(loadAll, refreshMs);
return () => clearInterval(id);
}, [autoRefresh, refreshMs, loadAll]);
const totals = useMemo(() => ({
queues: queues.length,
running: queues.reduce((s, q) => s + q.Running, 0),
due: queues.reduce((s, q) => s + q.QueuedDue, 0),
future: queues.reduce((s, q) => s + q.QueuedFuture, 0),
}), [queues]);
return (
<div className="p-6 space-y-6">
<header className="flex items-center justify-between gap-3">
<h1 className="text-2xl font-semibold tracking-tight">Jobs Dashboard</h1>
<div className="flex items-center gap-3">
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" className="h-4 w-4" checked={autoRefresh} onChange={(e)=>setAutoRefresh(e.target.checked)} />
Auto refresh
</label>
<select
className="border rounded px-2 py-1 text-sm"
value={refreshMs}
onChange={(e)=>setRefreshMs(parseInt(e.target.value))}
>
<option value={3000}>3s</option>
<option value={5000}>5s</option>
<option value={10000}>10s</option>
<option value={30000}>30s</option>
</select>
<button
className="px-3 py-1.5 rounded bg-slate-900 text-white text-sm hover:opacity-90"
onClick={loadAll}
disabled={loading}
>{loading ? "Refreshing…" : "Refresh"}</button>
</div>
</header>
{error && (
<div className="rounded border border-red-300 bg-red-50 text-red-800 p-3 text-sm">
{error}
</div>
)}
{/* KPI cards */}
<section className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-6">
<KpiCard label="Running" value={fmtNumber(kpi?.RunningNow)} />
<KpiCard label="Due now" value={fmtNumber(kpi?.DueNow)} />
<KpiCard label="Scheduled" value={fmtNumber(kpi?.ScheduledFuture)} />
<KpiCard label="Succeeded (24h)" value={fmtNumber(kpi?.Succeeded24h)} />
<KpiCard label="Failed (24h)" value={fmtNumber(kpi?.Failed24h)} />
<KpiCard label="Retryable" value={fmtNumber(kpi?.Retryable)} />
</section>
{/* Per-queue table */}
<section className="space-y-2">
<div className="flex items-center justify-between">
<h2 className="text-lg font-medium">Queues <span className="text-slate-500 text-sm">({totals.queues})</span></h2>
</div>
<div className="overflow-x-auto rounded border">
<Table className="min-w-full text-sm">
<TableHeader>
<TableRow>
<TableHead>Queue</TableHead>
<TableHead className='text-right'>Running</TableHead>
<TableHead className='text-right'>Due</TableHead>
<TableHead className='text-right'>Future</TableHead>
<TableHead className='text-right'>Success 24h</TableHead>
<TableHead className='text-right'>Failed 24h</TableHead>
<TableHead className='text-right'>Avg Duration</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{queues.map((q) => (
<TableRow key={q.QueueName} className="border-t">
<TableCell>{q.QueueName}</TableCell>
<TableCell className='text-right'>{q.Running}</TableCell>
<TableCell className='text-right'>{q.QueuedDue}</TableCell>
<TableCell className='text-right'>{q.QueuedFuture}</TableCell>
<TableCell className='text-right'>{q.Success24h}</TableCell>
<TableCell className='text-right'>{q.Failed24h}</TableCell>
<TableCell className='text-right'>{fmtSeconds(q.AvgDurationSecs)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</section>
{/* <ManualEnqueue onSubmitted={loadAll} /> */}
</div>
)
}
function KpiCard({ label, value }: { label: string; value: string }) {
return (
<Card>
<CardHeader>
<CardTitle>{label}</CardTitle>
</CardHeader>
<CardContent>
<div className="mt-1 text-2xl font-semibold">{value}</div>
</CardContent>
</Card>
);
}