diff --git a/.air.toml b/.air.toml
new file mode 100644
index 0000000..6b15714
--- /dev/null
+++ b/.air.toml
@@ -0,0 +1,13 @@
+root = "."
+tmp_dir = "tmp"
+
+[build]
+cmd = "go build -o ./tmp/runright ./cmd/runright"
+bin = "./tmp/runright serve"
+include_ext = ["go", "json", "yaml", "yml", "toml"]
+exclude_dir = ["tmp", "bin", "web/node_modules", ".git"]
+delay = 200
+stop_on_error = true
+
+[log]
+time = true
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 1f79254..05c01a2 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -7,6 +7,7 @@ on:
permissions:
contents: write
+ id-token: write # required for cosign keyless signing
jobs:
goreleaser:
@@ -22,6 +23,12 @@ jobs:
go-version-file: go.mod
cache: true
+ - name: Install cosign
+ uses: sigstore/cosign-installer@v3
+
+ - name: Install syft
+ uses: anchore/sbom-action/download-syft@v0
+
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index b434c76..0fd6303 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -16,6 +16,20 @@ on:
jobs:
test:
runs-on: ubuntu-latest
+ services:
+ postgres:
+ image: postgres:16-alpine
+ env:
+ POSTGRES_USER: runright
+ POSTGRES_PASSWORD: runright
+ POSTGRES_DB: runright
+ ports:
+ - 5435:5432
+ options: >-
+ --health-cmd "pg_isready -U runright -d runright"
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 10
steps:
- uses: actions/checkout@v4
@@ -28,4 +42,6 @@ jobs:
run: go vet ./cmd/... ./internal/...
- name: go test
+ env:
+ DATABASE_URL: postgres://runright:runright@127.0.0.1:5435/runright?sslmode=disable
run: go test -race -count=1 ./cmd/... ./internal/...
diff --git a/.gitignore b/.gitignore
index f063ae0..bbcb0e6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -55,3 +55,7 @@ logs/
# Test artifacts
.runright/
metrics-summary.json
+
+# Local benchmark and temp artifacts
+tmp/
+scripts/bench-agent.sh
diff --git a/.goreleaser.yml b/.goreleaser.yml
index 95c947e..3e68528 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -42,6 +42,27 @@ archives:
checksum:
name_template: "checksums.txt"
+# Generate a Software Bill of Materials (SBOM) for each archive using syft.
+sboms:
+ - artifacts: archive
+ args: ["$artifact", "--output", "spdx-json=$document"]
+ documents:
+ - "{{ .ArtifactName }}.spdx.json"
+
+# Sign the checksums file with cosign (keyless OIDC — no long-lived key needed).
+signs:
+ - cmd: cosign
+ env:
+ - COSIGN_EXPERIMENTAL=1
+ artifacts: checksum
+ output: true
+ args:
+ - sign-blob
+ - --yes
+ - "--output-certificate=${certificate}"
+ - "--output-signature=${signature}"
+ - "${artifact}"
+
release:
github:
owner: gbudjeakp
diff --git a/Dockerfile.backend.dev b/Dockerfile.backend.dev
new file mode 100644
index 0000000..46a671d
--- /dev/null
+++ b/Dockerfile.backend.dev
@@ -0,0 +1,14 @@
+FROM golang:1.26-alpine
+
+RUN apk add --no-cache git
+
+WORKDIR /app
+
+COPY go.mod go.sum ./
+RUN go mod download
+
+RUN go install github.com/air-verse/air@v1.61.7
+
+EXPOSE 8080
+
+CMD ["air", "-c", ".air.toml"]
diff --git a/Makefile b/Makefile
index 069fff1..c4bcf8f 100644
--- a/Makefile
+++ b/Makefile
@@ -10,7 +10,7 @@ PLATFORMS := \
windows/amd64
.PHONY: build build-all build-linux build-linux-amd64 build-linux-arm64 \
- test lint clean run-server docker-up docker-down catalog-update seed deploy
+ test lint clean run-server docker-up docker-down catalog-update seed deploy bench-agent
build:
go build $(BUILD_FLAGS) -o bin/$(BINARY) $(CMD_DIR)
@@ -91,3 +91,6 @@ web-build:
tidy:
go mod tidy
+
+bench-agent:
+ ./scripts/bench-agent.sh
diff --git a/README.md b/README.md
index 66f2b4a..f8479ae 100644
--- a/README.md
+++ b/README.md
@@ -177,6 +177,24 @@ go test ./internal/...
cd web && pnpm dev
```
+### Backend Hot Reload (Docker)
+
+Use the dev profile to run the backend with live reload via Air:
+
+```bash
+docker compose up -d postgres dashboard
+docker compose --profile dev up -d backend-dev
+```
+
+Now backend code changes reload automatically. You do not need `docker compose down` each time.
+
+If `backend` is already running, stop only that service first:
+
+```bash
+docker compose stop backend
+docker compose rm -f backend
+```
+
---
Elastic License 2.0 (ELv2). See [LICENSE](LICENSE)
diff --git a/action.yml b/action.yml
index e635b14..a042081 100644
--- a/action.yml
+++ b/action.yml
@@ -60,6 +60,26 @@ inputs:
description: "Filter recommendations by cloud provider: aws, gcp, or github (default: all)"
required: false
default: ""
+ max-cost-per-hour:
+ description: "Budget guard: fail the step if the detected machine's on-demand price exceeds this value (e.g. 0.50). Empty = disabled."
+ required: false
+ default: ""
+ notify-slack:
+ description: "Slack incoming webhook URL. When set, posts a per-job saving alert on completion."
+ required: false
+ default: ""
+ notify-teams:
+ description: "Microsoft Teams incoming webhook URL. When set, posts a per-job saving alert on completion."
+ required: false
+ default: ""
+ dry-run:
+ description: "Observation-only mode: collect metrics and log recommendations but skip PR comments and artifact uploads."
+ required: false
+ default: "false"
+ rollup:
+ description: "Combine all RunRight PR comments for this workflow into a single comment table (one row per job)."
+ required: false
+ default: "false"
outputs:
recommendation-json:
@@ -96,6 +116,8 @@ runs:
RUNRIGHT_OUTPUT_DIR: ${{ inputs.output-dir }}
RUNRIGHT_JOB_ID: ${{ inputs.job-id }}
RUNRIGHT_HTTP_URL: ${{ inputs.http-url }}
+ RUNRIGHT_SLACK_WEBHOOK: ${{ inputs.notify-slack }}
+ RUNRIGHT_TEAMS_WEBHOOK: ${{ inputs.notify-teams }}
run: bash "$GITHUB_ACTION_PATH/scripts/monitor-start.sh"
- name: Run wrapped command
@@ -118,6 +140,8 @@ runs:
RUNRIGHT_OUTPUT_DIR: ${{ inputs.output-dir }}
RUNRIGHT_JOB_ID: ${{ inputs.job-id }}
RUNRIGHT_HTTP_URL: ${{ inputs.http-url }}
+ RUNRIGHT_SLACK_WEBHOOK: ${{ inputs.notify-slack }}
+ RUNRIGHT_TEAMS_WEBHOOK: ${{ inputs.notify-teams }}
run: bash "$GITHUB_ACTION_PATH/scripts/monitor-start.sh"
# ── Standalone: stop step ─────────────────────────────────────────────────
@@ -135,6 +159,9 @@ runs:
RUNRIGHT_OUTPUT_DIR: ${{ inputs.output-dir }}
RUNRIGHT_JOB_ID: ${{ inputs.job-id }}
RUNRIGHT_PROVIDER: ${{ inputs.provider }}
+ RUNRIGHT_HTTP_URL: ${{ inputs.http-url }}
+ RUNRIGHT_MAX_COST_PER_HOUR: ${{ inputs.max-cost-per-hour }}
+ RUNRIGHT_DRY_RUN: ${{ inputs.dry-run }}
run: bash "$GITHUB_ACTION_PATH/scripts/recommend.sh"
# ── PR comment ────────────────────────────────────────────────────────────
@@ -143,11 +170,13 @@ runs:
if: |
(inputs.step == 'stop' || inputs.run != '') &&
inputs.pr-comment == 'true' &&
+ inputs.dry-run != 'true' &&
github.event_name == 'pull_request'
uses: actions/github-script@v7
env:
COMMENT_FILE: /tmp/runright-pr-comment.md
JOB_LABEL: ${{ inputs.job-id }}
+ ROLLUP_MODE: ${{ inputs.rollup }}
with:
github-token: ${{ inputs.github-token }}
script: |
@@ -156,10 +185,13 @@ runs:
if (!fs.existsSync(commentPath)) { return }
const body = fs.readFileSync(commentPath, 'utf8')
- const marker = ``
+ const rollup = process.env.ROLLUP_MODE === 'true'
+ const jobLabel = process.env.JOB_LABEL
+
+ // In rollup mode all jobs in the same workflow share a single comment.
+ // In per-job mode each job has its own comment keyed by job-id.
+ const marker = rollup ? `` : ``
- // Find an existing RunRight comment for this job so we update instead
- // of creating a new comment on every push to the PR branch.
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
@@ -167,26 +199,45 @@ runs:
})
const existing = comments.find(c => c.body.includes(marker))
+ let newBody = body
+ if (rollup && existing) {
+ // Each job upserts its own fenced block inside the shared comment.
+ const jobMarker = ``
+ const endMarker = ``
+ const jobBlock = `${jobMarker}\n${body}\n${endMarker}`
+ if (existing.body.includes(jobMarker)) {
+ newBody = existing.body.replace(
+ new RegExp(`${jobMarker}[\\s\\S]*?${endMarker}`),
+ jobBlock
+ )
+ } else {
+ newBody = existing.body + '\n\n---\n\n' + jobBlock
+ }
+ }
+
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
- body,
+ body: newBody,
})
} else {
+ const postBody = rollup
+ ? `${marker}\n\n\n${body}\n`
+ : body
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
- body,
+ body: postBody,
})
}
# ── Upload artifact ───────────────────────────────────────────────────────
- name: Upload metrics artifact
id: upload
- if: (inputs.step == 'stop' || inputs.run != '') && inputs.upload-artifact == 'true'
+ if: (inputs.step == 'stop' || inputs.run != '') && inputs.upload-artifact == 'true' && inputs.dry-run != 'true'
uses: actions/upload-artifact@v4
with:
name: runright-metrics-${{ inputs.job-id }}
diff --git a/catalog/updater/aws/main.go b/catalog/updater/aws/main.go
index c59cbda..dbf03b4 100644
--- a/catalog/updater/aws/main.go
+++ b/catalog/updater/aws/main.go
@@ -74,7 +74,10 @@ func convertAWS(it ec2types.InstanceTypeInfo) *types.MachineType {
if it.VCpuInfo.DefaultVCpus != nil {
vcpus = int(*it.VCpuInfo.DefaultVCpus)
}
- memGiB := float64(it.MemoryInfo.SizeInMiB) / 1024.0
+ if it.MemoryInfo.SizeInMiB == nil {
+ return nil
+ }
+ memGiB := float64(*it.MemoryInfo.SizeInMiB) / 1024.0
arch := "x86_64"
if len(it.ProcessorInfo.SupportedArchitectures) > 0 {
diff --git a/ci-examples/azure-pipelines.yml b/ci-examples/azure-pipelines.yml
new file mode 100644
index 0000000..5d17144
--- /dev/null
+++ b/ci-examples/azure-pipelines.yml
@@ -0,0 +1,92 @@
+# RunRight for Azure Pipelines
+#
+# Add this template to your azure-pipelines.yml or include it as a template.
+# RunRight detects the Azure Pipelines platform automatically via the
+# TF_BUILD and AGENT_* env vars exposed by every Azure DevOps agent.
+#
+# Requirements:
+# - An ubuntu-* or self-hosted Linux agent
+# - curl available (standard on Ubuntu-hosted agents)
+#
+# Optional: set RUNRIGHT_HTTP_URL as a pipeline or library variable to push
+# metrics to your self-hosted RunRight backend.
+
+trigger:
+ - main
+
+pool:
+ vmImage: ubuntu-latest
+
+variables:
+ RUNRIGHT_OUTPUT_DIR: $(Build.ArtifactStagingDirectory)/.runright
+ RUNRIGHT_INTERVAL: 5s
+ RUNRIGHT_EXPORT: file
+ # RUNRIGHT_HTTP_URL: https://your-runright-backend.example.com
+ # RUNRIGHT_SLACK_WEBHOOK: $(SLACK_WEBHOOK) # set as secret variable
+ # RUNRIGHT_TEAMS_WEBHOOK: $(TEAMS_WEBHOOK) # set as secret variable
+
+steps:
+ # ── Install RunRight ───────────────────────────────────────────────────────
+ - bash: |
+ ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')
+ curl -fsSL \
+ "https://github.com/gbudjeakp/run-right/releases/latest/download/runright_linux_${ARCH}.tar.gz" \
+ | tar -xz
+ sudo mv runright /usr/local/bin/runright
+ echo "RunRight installed: $(runright --version 2>&1 || true)"
+ displayName: Install RunRight
+
+ # ── Start monitor ──────────────────────────────────────────────────────────
+ - bash: |
+ mkdir -p "$(RUNRIGHT_OUTPUT_DIR)"
+ runright monitor \
+ --interval "$(RUNRIGHT_INTERVAL)" \
+ --export "$(RUNRIGHT_EXPORT)" \
+ --output-dir "$(RUNRIGHT_OUTPUT_DIR)" \
+ --job-id "$(System.JobDisplayName)" \
+ --http-url "$(RUNRIGHT_HTTP_URL)" \
+ --slack-webhook "$(RUNRIGHT_SLACK_WEBHOOK)" \
+ --teams-webhook "$(RUNRIGHT_TEAMS_WEBHOOK)" &
+ echo $! > /tmp/runright-monitor.pid
+ echo "RunRight monitor started (PID $(cat /tmp/runright-monitor.pid))"
+ displayName: Start RunRight monitor
+ env:
+ RUNRIGHT_SLACK_WEBHOOK: $(RUNRIGHT_SLACK_WEBHOOK)
+ RUNRIGHT_TEAMS_WEBHOOK: $(RUNRIGHT_TEAMS_WEBHOOK)
+
+ # ── Your build / test steps ────────────────────────────────────────────────
+ - bash: |
+ echo "Your build commands here"
+ # make build
+ # dotnet build
+ # mvn package
+ displayName: Build
+
+ # ── Stop monitor and recommend ─────────────────────────────────────────────
+ - bash: |
+ if [ -f /tmp/runright-monitor.pid ]; then
+ kill -TERM "$(cat /tmp/runright-monitor.pid)" 2>/dev/null || true
+ for i in $(seq 1 40); do
+ kill -0 "$(cat /tmp/runright-monitor.pid)" 2>/dev/null || break
+ sleep 0.25
+ done
+ rm -f /tmp/runright-monitor.pid
+ fi
+
+ SUMMARY="$(RUNRIGHT_OUTPUT_DIR)/metrics-summary.json"
+ if [ -f "$SUMMARY" ]; then
+ echo "=== RunRight Recommendations ==="
+ runright recommend --metrics "$SUMMARY" --format table || true
+ else
+ echo "RunRight: no metrics file found, skipping recommendations."
+ fi
+ displayName: Stop monitor & generate recommendations
+ condition: always()
+
+ # ── Publish metrics artifact ───────────────────────────────────────────────
+ - task: PublishBuildArtifacts@1
+ inputs:
+ pathToPublish: $(RUNRIGHT_OUTPUT_DIR)
+ artifactName: runright-metrics
+ displayName: Upload RunRight metrics
+ condition: always()
diff --git a/ci-examples/bitbucket-pipelines.yml b/ci-examples/bitbucket-pipelines.yml
new file mode 100644
index 0000000..aac0d9a
--- /dev/null
+++ b/ci-examples/bitbucket-pipelines.yml
@@ -0,0 +1,93 @@
+# RunRight for Bitbucket Pipelines
+#
+# Merge this into your bitbucket-pipelines.yml.
+# RunRight detects the Bitbucket platform automatically via the BITBUCKET_BUILD_NUMBER env var.
+#
+# Requirements:
+# - A Linux runner (Bitbucket Cloud default or self-hosted)
+# - curl available in the pipeline image
+#
+# Optional: set RUNRIGHT_HTTP_URL as a repository or workspace variable to push
+# metrics to your self-hosted RunRight backend.
+
+image: ubuntu:24.04
+
+definitions:
+ # ── Reusable step scripts ──────────────────────────────────────────────────
+ scripts:
+ - script: &install-runright |
+ apt-get update -qq && apt-get install -y -qq curl
+ ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')
+ curl -fsSL "https://github.com/gbudjeakp/run-right/releases/latest/download/runright_linux_${ARCH}.tar.gz" | tar -xz
+ mv runright /usr/local/bin/runright
+
+ - script: &start-monitor |
+ mkdir -p "${RUNRIGHT_OUTPUT_DIR:-.runright}"
+ runright monitor \
+ --interval "${RUNRIGHT_INTERVAL:-5s}" \
+ --export "${RUNRIGHT_EXPORT:-file}" \
+ --output-dir "${RUNRIGHT_OUTPUT_DIR:-.runright}" \
+ --job-id "${BITBUCKET_STEP_TRIGGERER_UUID:-${BITBUCKET_BUILD_NUMBER}}" \
+ --http-url "${RUNRIGHT_HTTP_URL:-}" \
+ --slack-webhook "${RUNRIGHT_SLACK_WEBHOOK:-}" \
+ --teams-webhook "${RUNRIGHT_TEAMS_WEBHOOK:-}" &
+ echo $! > /tmp/runright-monitor.pid
+ echo "runright: monitor started (PID $(cat /tmp/runright-monitor.pid))"
+
+ - script: &stop-and-recommend |
+ if [ -f /tmp/runright-monitor.pid ]; then
+ kill -TERM "$(cat /tmp/runright-monitor.pid)" 2>/dev/null || true
+ for i in $(seq 1 40); do
+ kill -0 "$(cat /tmp/runright-monitor.pid)" 2>/dev/null || break
+ sleep 0.25
+ done
+ rm -f /tmp/runright-monitor.pid
+ fi
+ SUMMARY="${RUNRIGHT_OUTPUT_DIR:-.runright}/metrics-summary.json"
+ if [ -f "$SUMMARY" ]; then
+ echo "=== RunRight Recommendations ==="
+ runright recommend --metrics "$SUMMARY" --format table || true
+ else
+ echo "RunRight: no metrics file found."
+ fi
+
+pipelines:
+ default:
+ - step:
+ name: Build
+ script:
+ - *install-runright
+ - *start-monitor
+ # ── Your build commands ──
+ - echo "Your build commands here"
+ # - make build
+ # ── Stop monitor and get recommendations ──
+ - *stop-and-recommend
+ artifacts:
+ - .runright/**
+
+ - step:
+ name: Test
+ script:
+ - *install-runright
+ - *start-monitor
+ # ── Your test commands ──
+ - echo "Your test commands here"
+ # - make test
+ # ── Stop monitor and get recommendations ──
+ - *stop-and-recommend
+ artifacts:
+ - .runright/**
+
+ # ── Branch-specific example ──────────────────────────────────────────────
+ branches:
+ main:
+ - step:
+ name: Build & Test (monitored)
+ script:
+ - *install-runright
+ - *start-monitor
+ - make build test
+ - *stop-and-recommend
+ artifacts:
+ - .runright/**
diff --git a/ci-examples/circleci.yml b/ci-examples/circleci.yml
new file mode 100644
index 0000000..9eaad87
--- /dev/null
+++ b/ci-examples/circleci.yml
@@ -0,0 +1,116 @@
+# RunRight for CircleCI
+#
+# Paste this into your .circleci/config.yml (or merge into your existing config).
+# RunRight detects the CircleCI platform automatically via the CIRCLECI env var.
+#
+# Requirements:
+# - A Linux executor (machine or docker)
+# - curl available in the executor
+#
+# Optional: set RUNRIGHT_HTTP_URL as a project or context env var to push metrics
+# to your self-hosted RunRight backend.
+
+version: 2.1
+
+# ── Reusable commands ────────────────────────────────────────────────────────
+
+commands:
+ runright-install:
+ description: "Download the RunRight binary for the current executor"
+ parameters:
+ version:
+ type: string
+ default: "latest"
+ steps:
+ - run:
+ name: Install RunRight
+ command: |
+ ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')
+ VERSION="<< parameters.version >>"
+ if [ "$VERSION" = "latest" ]; then
+ URL="https://github.com/gbudjeakp/run-right/releases/latest/download/runright_linux_${ARCH}.tar.gz"
+ else
+ URL="https://github.com/gbudjeakp/run-right/releases/download/${VERSION}/runright_linux_${ARCH}.tar.gz"
+ fi
+ curl -fsSL "$URL" | tar -xz
+ sudo mv runright /usr/local/bin/runright
+ runright --version || true
+
+ runright-start:
+ description: "Start the RunRight monitor in the background"
+ steps:
+ - run:
+ name: Start RunRight monitor
+ background: true
+ command: |
+ mkdir -p "${RUNRIGHT_OUTPUT_DIR:-.runright}"
+ runright monitor \
+ --interval "${RUNRIGHT_INTERVAL:-5s}" \
+ --export "${RUNRIGHT_EXPORT:-file}" \
+ --output-dir "${RUNRIGHT_OUTPUT_DIR:-.runright}" \
+ --job-id "${CIRCLE_JOB}" \
+ --http-url "${RUNRIGHT_HTTP_URL:-}" \
+ --slack-webhook "${RUNRIGHT_SLACK_WEBHOOK:-}" \
+ --teams-webhook "${RUNRIGHT_TEAMS_WEBHOOK:-}"
+
+ runright-stop-and-recommend:
+ description: "Stop the RunRight monitor and print recommendations"
+ steps:
+ - run:
+ name: Stop RunRight monitor
+ when: always
+ command: |
+ pkill -TERM -x runright 2>/dev/null || true
+ # Give it a moment to flush
+ sleep 1
+ - run:
+ name: RunRight recommendations
+ when: always
+ command: |
+ SUMMARY="${RUNRIGHT_OUTPUT_DIR:-.runright}/metrics-summary.json"
+ if [ -f "$SUMMARY" ]; then
+ echo "=== RunRight Recommendations ==="
+ runright recommend --metrics "$SUMMARY" --format table || true
+ else
+ echo "RunRight: no metrics file found, skipping recommendations."
+ fi
+ - store_artifacts:
+ path: .runright
+ destination: runright-metrics
+
+# ── Example job ───────────────────────────────────────────────────────────────
+
+jobs:
+ build:
+ machine:
+ image: ubuntu-2204:current
+ steps:
+ - checkout
+ - runright-install
+ - runright-start
+ - run:
+ name: Build
+ command: |
+ echo "Your build commands here"
+ # make build
+ - runright-stop-and-recommend
+
+ test:
+ docker:
+ - image: cimg/base:current
+ steps:
+ - checkout
+ - runright-install
+ - runright-start
+ - run:
+ name: Test
+ command: |
+ echo "Your test commands here"
+ # make test
+ - runright-stop-and-recommend
+
+workflows:
+ ci:
+ jobs:
+ - build
+ - test
diff --git a/ci-examples/gitlab-ci.yml b/ci-examples/gitlab-ci.yml
new file mode 100644
index 0000000..0aeb094
--- /dev/null
+++ b/ci-examples/gitlab-ci.yml
@@ -0,0 +1,94 @@
+# RunRight for GitLab CI
+#
+# Add this snippet to your .gitlab-ci.yml.
+# RunRight detects the GitLab CI platform automatically via the CI_SERVER_URL
+# and GITLAB_CI env vars exposed by every GitLab runner.
+#
+# Requirements:
+# - A Linux runner (shell or Docker executor)
+# - curl available in the job image
+#
+# Optional: set RUNRIGHT_HTTP_URL as a CI/CD variable to push metrics to your
+# self-hosted RunRight backend.
+
+# ── Reusable helper scripts ──────────────────────────────────────────────────
+
+.runright_install: &runright_install |
+ RUNRIGHT_VERSION="${RUNRIGHT_VERSION:-latest}"
+ OS="linux"
+ ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')
+ if [ "$RUNRIGHT_VERSION" = "latest" ]; then
+ DOWNLOAD_URL="https://github.com/gbudjeakp/run-right/releases/latest/download/runright_${OS}_${ARCH}.tar.gz"
+ else
+ DOWNLOAD_URL="https://github.com/gbudjeakp/run-right/releases/download/${RUNRIGHT_VERSION}/runright_${OS}_${ARCH}.tar.gz"
+ fi
+ curl -fsSL "$DOWNLOAD_URL" | tar -xz
+ chmod +x runright
+ export PATH="$PWD:$PATH"
+
+.runright_start: &runright_start |
+ mkdir -p "${RUNRIGHT_OUTPUT_DIR:-.runright}"
+ runright monitor \
+ --interval "${RUNRIGHT_INTERVAL:-5s}" \
+ --export "${RUNRIGHT_EXPORT:-file}" \
+ --output-dir "${RUNRIGHT_OUTPUT_DIR:-.runright}" \
+ --job-id "${CI_JOB_NAME}" \
+ --http-url "${RUNRIGHT_HTTP_URL:-}" \
+ --slack-webhook "${RUNRIGHT_SLACK_WEBHOOK:-}" \
+ --teams-webhook "${RUNRIGHT_TEAMS_WEBHOOK:-}" &
+ echo $! > /tmp/runright-monitor.pid
+ echo "runright: monitor started (PID $(cat /tmp/runright-monitor.pid))"
+
+.runright_stop: &runright_stop |
+ if [ -f /tmp/runright-monitor.pid ]; then
+ kill -TERM "$(cat /tmp/runright-monitor.pid)" 2>/dev/null || true
+ for i in $(seq 1 40); do
+ kill -0 "$(cat /tmp/runright-monitor.pid)" 2>/dev/null || break
+ sleep 0.25
+ done
+ rm -f /tmp/runright-monitor.pid
+ fi
+
+.runright_recommend: &runright_recommend |
+ SUMMARY="${RUNRIGHT_OUTPUT_DIR:-.runright}/metrics-summary.json"
+ if [ -f "$SUMMARY" ]; then
+ echo "=== RunRight Recommendations ==="
+ runright recommend --metrics "$SUMMARY" --format table || true
+ runright recommend --metrics "$SUMMARY" --format json > "${RUNRIGHT_OUTPUT_DIR:-.runright}/recommendations.json" || true
+ fi
+
+# ── Example job using standalone mode ────────────────────────────────────────
+
+build:
+ stage: build
+ image: ubuntu:24.04
+ before_script:
+ - apt-get update -qq && apt-get install -y -qq curl
+ - *runright_install
+ - *runright_start
+ script:
+ - echo "Your build commands here"
+ # - make build
+ # - npm ci && npm run build
+ after_script:
+ # after_script always runs, even on failure — good for cleanup.
+ - *runright_stop
+ - *runright_recommend
+ artifacts:
+ paths:
+ - .runright/
+ expire_in: 30 days
+ when: always
+
+# ── Minimal example (wrapper-style using before/after_script) ────────────────
+
+# test:
+# stage: test
+# before_script:
+# - *runright_install
+# - *runright_start
+# script:
+# - make test
+# after_script:
+# - *runright_stop
+# - *runright_recommend
diff --git a/cmd/runright/main.go b/cmd/runright/main.go
index b6180b7..94548da 100644
--- a/cmd/runright/main.go
+++ b/cmd/runright/main.go
@@ -30,7 +30,7 @@ the right AWS or GCP machine type so you stop guessing and start saving.`,
}
func main() {
- rootCmd.AddCommand(monitorCmd, recommendCmd, catalogCmd, serveCmd)
+ rootCmd.AddCommand(monitorCmd, recommendCmd, catalogCmd, serveCmd, verifyCmd)
cobra.OnInitialize(initConfig)
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
@@ -65,34 +65,41 @@ Examples:
var (
monitorDuration time.Duration
monitorInterval time.Duration
+ monitorExpensiveSampleEvery int
monitorExport string
monitorOutputDir string
monitorJobID string
monitorPromPort int
monitorHTTPURL string
monitorSlackURL string
+ monitorTeamsURL string
monitorDryRun bool
)
func init() {
monitorCmd.Flags().DurationVar(&monitorDuration, "duration", 0, "Stop after this duration (0 = run until SIGTERM/SIGINT)")
monitorCmd.Flags().DurationVar(&monitorInterval, "interval", 5*time.Second, "Sampling interval")
- monitorCmd.Flags().StringVar(&monitorExport, "export", "file", "Comma-separated export backends: file,otlp,prometheus,http,slack")
+ monitorCmd.Flags().IntVar(&monitorExpensiveSampleEvery, "expensive-sample-every", 6, "Run expensive host probes (process/thread + disk/net) every N ticks")
+ monitorCmd.Flags().StringVar(&monitorExport, "export", "file", "Comma-separated export backends: file,otlp,prometheus,http,slack,teams")
monitorCmd.Flags().StringVar(&monitorOutputDir, "output-dir", ".", "Directory for file-based output")
monitorCmd.Flags().StringVar(&monitorJobID, "job-id", "", "Job identifier (defaults to a timestamp-based ID)")
monitorCmd.Flags().IntVar(&monitorPromPort, "prometheus-port", 9090, "Port for Prometheus /metrics endpoint")
monitorCmd.Flags().StringVar(&monitorHTTPURL, "http-url", "", "Base URL of runright backend for http export")
monitorCmd.Flags().StringVar(&monitorSlackURL, "slack-webhook", "", "Slack incoming webhook URL for slack export (or set RUNRIGHT_SLACK_WEBHOOK)")
+ monitorCmd.Flags().StringVar(&monitorTeamsURL, "teams-webhook", "", "Microsoft Teams incoming webhook URL for teams export (or set RUNRIGHT_TEAMS_WEBHOOK)")
monitorCmd.Flags().BoolVar(&monitorDryRun, "dry-run", false, "Print recommendation and exit non-zero if machine is not right-sized")
}
func runMonitor(_ *cobra.Command, _ []string) error {
backends := exporter.ParseBackends(monitorExport)
- // Allow slack webhook via env var as fallback.
+ // Allow slack/teams webhook via env var as fallback.
if monitorSlackURL == "" {
monitorSlackURL = os.Getenv("RUNRIGHT_SLACK_WEBHOOK")
}
+ if monitorTeamsURL == "" {
+ monitorTeamsURL = os.Getenv("RUNRIGHT_TEAMS_WEBHOOK")
+ }
ctx, cancel := context.WithCancel(context.Background())
if monitorDuration > 0 {
@@ -108,7 +115,7 @@ func runMonitor(_ *cobra.Command, _ []string) error {
cancel()
}()
- mgr, err := exporter.New(ctx, backends, monitorPromPort, monitorHTTPURL, monitorSlackURL)
+ mgr, err := exporter.New(ctx, backends, monitorPromPort, monitorHTTPURL, monitorSlackURL, monitorTeamsURL)
if err != nil {
return fmt.Errorf("exporter: %w", err)
}
@@ -116,6 +123,7 @@ func runMonitor(_ *cobra.Command, _ []string) error {
col := agent.NewCollector(agent.Config{
Interval: monitorInterval,
+ ExpensiveSampleEvery: monitorExpensiveSampleEvery,
OutputDir: monitorOutputDir,
JobID: monitorJobID,
HeartbeatFilePath: filepath.Join(monitorOutputDir, "metrics-heartbeat.json"),
@@ -223,13 +231,38 @@ func printMarkdown(recs []types.Recommendation, s types.MetricsSummary) {
fmt.Printf("## RunRight Recommendations\n\n")
fmt.Printf("**Job:** `%s` | **CPU p95:** %.1f%% | **Mem p95:** %.2f GiB | **Duration:** %.0fs\n\n",
s.JobID, s.CPUPercentP95, s.MemUsedGiBP95, s.DurationSeconds)
- fmt.Printf("| Tier | Machine | Provider | vCPUs | Memory | $/hr | $/month | Delta |\n")
- fmt.Printf("|------|---------|----------|-------|--------|------|---------|-------|\n")
+ fmt.Printf("| Tier | Machine | Provider | vCPUs | Memory | $/hr | $/month | $/year | Delta | Spot Risk |\n")
+ fmt.Printf("|------|---------|----------|-------|--------|------|---------|--------|-------|-----------|\n")
for _, r := range recs {
- fmt.Printf("| %s | `%s` | %s | %d | %.1f GiB | $%.4f | $%.2f | %+.1f%% |\n",
+ annualCost := r.EstimatedMonthly * 12
+ spotRisk := r.SpotRisk
+ if spotRisk == "" {
+ spotRisk = "—"
+ }
+ fmt.Printf("| %s | `%s` | %s | %d | %.1f GiB | $%.4f | $%.2f | $%.0f | %+.1f%% | %s |\n",
r.Tier, r.Machine.ID, r.Machine.Provider,
r.Machine.VCPUs, r.Machine.MemoryGiB,
- r.Machine.OnDemandPricePerHour, r.EstimatedMonthly, r.CostDeltaPercent)
+ r.Machine.OnDemandPricePerHour, r.EstimatedMonthly, annualCost, r.CostDeltaPercent, spotRisk)
+ }
+
+ // Annual savings projection for the top cheaper recommendation.
+ for _, r := range recs {
+ if r.CostDeltaPercent < -0.5 && r.CurrentMonthly > 0 {
+ annualSaving := (r.CurrentMonthly - r.EstimatedMonthly) * 12
+ if annualSaving > 0 {
+ fmt.Printf("\n> Switching to `%s` could save **~$%.0f/year** ($%.2f/month).\n",
+ r.Machine.ID, annualSaving, r.CurrentMonthly-r.EstimatedMonthly)
+ }
+ break
+ }
+ }
+
+ // Duration risk notes.
+ for _, r := range recs {
+ if r.DurationRiskNote != "" {
+ fmt.Printf("\n> WARNING: **Duration risk** (`%s`): %s\n", r.Machine.ID, r.DurationRiskNote)
+ break
+ }
}
}
@@ -301,3 +334,95 @@ func runServe(_ *cobra.Command, _ []string) error {
}
return srv.Run(servePort)
}
+
+// ── verify ────────────────────────────────────────────────────────────────────
+
+var verifyCmd = &cobra.Command{
+ Use: "verify",
+ Short: "Compare a previous recommendation against actual job metrics",
+ Long: `verify loads a recommendation JSON (produced by a prior run) and a new
+metrics-summary.json from a job that ran on the recommended machine. It checks
+whether the recommendation was accurate and reports a pass/fail result.
+
+Examples:
+ runright verify --previous-recommendation recs.json --actual-metrics metrics-summary.json`,
+ RunE: runVerify,
+}
+
+var (
+ verifyPreviousRec string
+ verifyActualMetrics string
+)
+
+func init() {
+ verifyCmd.Flags().StringVar(&verifyPreviousRec, "previous-recommendation", "", "Path to recommendations JSON from a prior run (required)")
+ verifyCmd.Flags().StringVar(&verifyActualMetrics, "actual-metrics", "", "Path to metrics-summary.json from the run on the recommended machine (required)")
+ _ = verifyCmd.MarkFlagRequired("previous-recommendation")
+ _ = verifyCmd.MarkFlagRequired("actual-metrics")
+}
+
+func runVerify(_ *cobra.Command, _ []string) error {
+ recData, err := os.ReadFile(verifyPreviousRec)
+ if err != nil {
+ return fmt.Errorf("reading %s: %w", verifyPreviousRec, err)
+ }
+ var recs []types.Recommendation
+ if err := json.Unmarshal(recData, &recs); err != nil {
+ return fmt.Errorf("parsing recommendations: %w", err)
+ }
+ if len(recs) == 0 {
+ return fmt.Errorf("no recommendations found in %s", verifyPreviousRec)
+ }
+
+ metricData, err := os.ReadFile(verifyActualMetrics)
+ if err != nil {
+ return fmt.Errorf("reading %s: %w", verifyActualMetrics, err)
+ }
+ var actual types.MetricsSummary
+ if err := json.Unmarshal(metricData, &actual); err != nil {
+ return fmt.Errorf("parsing metrics summary: %w", err)
+ }
+
+ top := recs[0]
+ w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
+ fmt.Printf("\nRunRight Verify — was the recommendation accurate?\n")
+ fmt.Printf("Recommended machine: %s (%d vCPUs, %.1f GiB)\n\n",
+ top.Machine.ID, top.Machine.VCPUs, top.Machine.MemoryGiB)
+
+ cpuOK := actual.CPUPercentP95 <= float64(top.Machine.VCPUs)/float64(top.RequiredVCPUs)*float64(top.RequiredVCPUs)
+ memOK := actual.MemUsedGiBP95 <= top.Machine.MemoryGiB
+
+ // Headroom checks: p95 usage should be within the recommended machine's capacity.
+ cpuFit := actual.CPUPercentP95 <= 80.0
+ memFit := actual.MemUsedGiBP95 <= top.Machine.MemoryGiB*0.85
+
+ fmt.Fprintln(w, "CHECK\tRESULT\tACTUAL\tRECOMMENDED")
+ fmt.Fprintln(w, "-----\t------\t------\t-----------")
+
+ cpuResult := "PASS"
+ if !cpuFit {
+ cpuResult = "FAIL - machine is CPU-saturated, consider larger"
+ } else if !cpuOK {
+ cpuResult = "WARN"
+ }
+ fmt.Fprintf(w, "CPU p95\t%s\t%.1f%%\t%.1f%% headroom target\n",
+ cpuResult, actual.CPUPercentP95, 80.0)
+
+ memResult := "PASS"
+ if !memFit {
+ memResult = "FAIL - machine is memory-saturated, consider larger"
+ } else if !memOK {
+ memResult = "WARN"
+ }
+ fmt.Fprintf(w, "Memory p95\t%s\t%.2f GiB\t%.1f GiB available\n",
+ memResult, actual.MemUsedGiBP95, top.Machine.MemoryGiB)
+
+ _ = w.Flush()
+
+ if !cpuFit || !memFit {
+ fmt.Fprintf(os.Stderr, "\nrunright verify: recommendation was too aggressive — the machine was saturated.\n")
+ os.Exit(2)
+ }
+ fmt.Println("\nrunright verify: recommendation validated")
+ return nil
+}
diff --git a/cmd/seed/main.go b/cmd/seed/main.go
index 5829614..7002151 100644
--- a/cmd/seed/main.go
+++ b/cmd/seed/main.go
@@ -30,7 +30,7 @@ func main() {
log.Printf("FAIL %s: %v", j.Summary.JobID, err)
fail++
} else {
- fmt.Printf(" ✓ %s (%.0fs, cpu p95 %.1f%%, mem p95 %.2f GiB)\n",
+ fmt.Printf(" OK %s (%.0fs, cpu p95 %.1f%%, mem p95 %.2f GiB)\n",
j.Summary.JobID,
j.Summary.DurationSeconds,
j.Summary.CPUPercentP95,
@@ -125,6 +125,38 @@ var gcpN2Standard2 = types.MachineType{
Tags: []string{"latest-gen"},
}
+var awsC7g2XLarge = types.MachineType{
+ ID: "c7g.2xlarge", Provider: types.ProviderAWS,
+ Family: "compute-optimized", Series: "c7g",
+ VCPUs: 8, MemoryGiB: 16, NetworkGbps: 12.5, StorageType: "ebs",
+ Architecture: "arm64", OnDemandPricePerHour: 0.58,
+ Tags: []string{"graviton", "arm64", "compute-optimized"},
+}
+
+var awsP32XLarge = types.MachineType{
+ ID: "p3.2xlarge", Provider: types.ProviderAWS,
+ Family: "gpu-accelerated", Series: "p3",
+ VCPUs: 8, MemoryGiB: 61, NetworkGbps: 10, StorageType: "ebs",
+ Architecture: "x86_64", OnDemandPricePerHour: 3.06,
+ Tags: []string{"gpu", "ml-training", "expensive"},
+}
+
+var awsG4dn12XLarge = types.MachineType{
+ ID: "g4dn.12xlarge", Provider: types.ProviderAWS,
+ Family: "gpu-accelerated", Series: "g4dn",
+ VCPUs: 48, MemoryGiB: 192, NetworkGbps: 100, StorageType: "ebs",
+ Architecture: "x86_64", OnDemandPricePerHour: 7.48,
+ Tags: []string{"gpu", "inference", "very-expensive"},
+}
+
+var awsP4d24XLarge = types.MachineType{
+ ID: "p4d.24xlarge", Provider: types.ProviderAWS,
+ Family: "gpu-accelerated", Series: "p4d",
+ VCPUs: 96, MemoryGiB: 1152, NetworkGbps: 400, StorageType: "ebs",
+ Architecture: "x86_64", OnDemandPricePerHour: 32.77,
+ Tags: []string{"gpu", "ml-training", "most-expensive"},
+}
+
// rng is a seeded random source for reproducible jitter.
var rng = rand.New(rand.NewSource(42))
@@ -220,6 +252,41 @@ func buildJobs() []payload {
jitter(22, 4), jitter(0.8, 0.08), jitter(68, 12)))
}
+ // ── "ml-training" job: Heavy compute-optimized, runs weekly ──────────────
+ // Uses c7g.2xlarge but only at 25% CPU most of the time.
+ // Huge opportunity to downsize to m6i.xlarge or c7g.xlarge.
+ for i := 27; i >= 0; i -= 7 {
+ t := now.AddDate(0, 0, -i).Add(4 * time.Hour)
+ jobs = append(jobs, makeJob("ml-training", "jenkins", t, awsC7g2XLarge,
+ jitter(23, 5), jitter(2.5, 0.3), jitter(14400, 1200))) // 4 hours = 14400 seconds
+ }
+
+ // ── "gpu-inference" job: p4d.24xlarge (most expensive) but only 15% util ──
+ // Runs every day, 4 hours each. Massive downsize opportunity.
+ for i := 29; i >= 0; i-- {
+ t := now.AddDate(0, 0, -i).Add(6 * time.Hour)
+ jobs = append(jobs, makeJob("gpu-inference", "jenkins", t, awsP4d24XLarge,
+ jitter(14, 3), jitter(120, 15), jitter(14400, 1500))) // 4 hours, low GPU util
+ }
+
+ // ── "ml-batch" job: p3.2xlarge but only 25% GPU util, every 2 days ───────
+ // 6-hour training runs with plenty of headroom.
+ for i := 28; i >= 0; i -= 2 {
+ t := now.AddDate(0, 0, -i).Add(2 * time.Hour)
+ jobs = append(jobs, makeJob("ml-batch", "jenkins", t, awsP32XLarge,
+ jitter(24, 4), jitter(15, 2), jitter(21600, 1800))) // 6 hours
+ }
+
+ // ── "data-transform" job: g4dn.12xlarge but only 20% GPU util, 3x/week ────
+ // Long-running data pipeline with spare capacity.
+ for i := 28; i >= 0; i -= 2 {
+ if i%3 == 0 { // 3x per week (~10 times per month)
+ t := now.AddDate(0, 0, -i).Add(14 * time.Hour)
+ jobs = append(jobs, makeJob("data-transform", "jenkins", t, awsG4dn12XLarge,
+ jitter(19, 4), jitter(40, 8), jitter(28800, 2400))) // 8 hours
+ }
+ }
+
// ── Interrupted runs ─────────────────────────────────────────────────────
// Status="heartbeat" simulates jobs where the agent never sent a final
// "completed" flush — either OOM-killed (SIGKILL) or runner disconnected.
@@ -273,6 +340,7 @@ func makeJob(
summary := types.MetricsSummary{
JobID: jobID,
CIPlatform: ciPlatform,
+ Repository: seededRepository(jobID),
StartTime: start,
EndTime: end,
DurationSeconds: durationSec,
@@ -302,6 +370,19 @@ func makeJob(
return payload{Summary: summary, Recommendations: recs}
}
+func seededRepository(jobID string) string {
+ switch jobID {
+ case "build", "unit-tests", "integration-tests", "docker-build", "lint", "security-scan":
+ return "runrightio/app-core"
+ case "e2e-tests", "deploy-staging":
+ return "runrightio/web-ui"
+ case "benchmark", "gcp-build", "python-tests", "ml-training", "gpu-inference", "ml-batch", "data-transform":
+ return "runrightio/data-pipeline"
+ default:
+ return ""
+ }
+}
+
// recommend generates plausible recommendations based on the summary.
func recommend(s types.MetricsSummary, detected types.MachineType, currentMonthly float64) []types.Recommendation {
cpuPct := s.CPUPercentP95
@@ -311,6 +392,31 @@ func recommend(s types.MetricsSummary, detected types.MachineType, currentMonthl
// Right-sized: best fit for the workload
switch {
+ case detected.ID == "p4d.24xlarge" && cpuPct < 20:
+ // Way over-provisioned GPU: downsize dramatically
+ recs = append(recs, rec(awsP32XLarge, types.TierCheaper, currentMonthly)) // $3.06/hr vs $32.77/hr
+ recs = append(recs, rec(awsG4dn12XLarge, types.TierRightSized, currentMonthly)) // $7.48/hr
+ recs = append(recs, rec(awsP4d24XLarge, types.TierMoreHeadroom, currentMonthly))
+
+ case detected.ID == "p3.2xlarge" && cpuPct < 30:
+ // Over-provisioned GPU: downsize to smaller GPU or compute
+ recs = append(recs, rec(awsC7gLarge, types.TierCheaper, currentMonthly))
+ recs = append(recs, rec(awsM6iLarge, types.TierRightSized, currentMonthly))
+ recs = append(recs, rec(awsP32XLarge, types.TierMoreHeadroom, currentMonthly))
+
+ case detected.ID == "g4dn.12xlarge" && cpuPct < 25:
+ // Over-provisioned GPU: downsize to smaller GPU
+ gdn4XL := types.MachineType{
+ ID: "g4dn.xlarge", Provider: types.ProviderAWS,
+ Family: "gpu-accelerated", Series: "g4dn",
+ VCPUs: 4, MemoryGiB: 16, NetworkGbps: 25, StorageType: "ebs",
+ Architecture: "x86_64", OnDemandPricePerHour: 0.526,
+ Tags: []string{"gpu", "inference"},
+ }
+ recs = append(recs, rec(gdn4XL, types.TierCheaper, currentMonthly))
+ recs = append(recs, rec(awsM6iLarge, types.TierRightSized, currentMonthly))
+ recs = append(recs, rec(awsG4dn12XLarge, types.TierMoreHeadroom, currentMonthly))
+
case cpuPct < 20 && memGiB < 0.8:
// Very light — suggest t3.nano → not in our set, use t3.small equivalent
m := types.MachineType{
@@ -328,6 +434,12 @@ func recommend(s types.MetricsSummary, detected types.MachineType, currentMonthl
recs = append(recs, rec(awsC7gLarge, types.TierCheaper, currentMonthly))
recs = append(recs, rec(awsT3Large, types.TierMoreHeadroom, currentMonthly))
+ case detected.ID == "c7g.2xlarge" && cpuPct < 30:
+ // Over-provisioned compute: downsize significantly
+ recs = append(recs, rec(awsC7gLarge, types.TierCheaper, currentMonthly))
+ recs = append(recs, rec(awsM6iLarge, types.TierRightSized, currentMonthly))
+ recs = append(recs, rec(awsC7g2XLarge, types.TierMoreHeadroom, currentMonthly))
+
default:
// Moderate — current is okay but cheaper options exist
recs = append(recs, rec(awsT3Medium, types.TierRightSized, currentMonthly))
diff --git a/docker-compose.yml b/docker-compose.yml
index 297528c..f2046d3 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -32,6 +32,29 @@ services:
condition: service_healthy
restart: unless-stopped
+ backend-dev:
+ profiles: ["dev"]
+ build:
+ context: .
+ dockerfile: Dockerfile.backend.dev
+ working_dir: /app
+ command: ["air", "-c", ".air.toml"]
+ volumes:
+ - ./:/app
+ - go_mod_cache:/go/pkg/mod
+ - go_build_cache:/root/.cache/go-build
+ ports:
+ - "8080:8080"
+ environment:
+ DATABASE_URL: postgres://runright:runright@postgres:5432/runright?sslmode=disable
+ RUNRIGHT_API_KEY: ${RUNRIGHT_API_KEY:-}
+ RUNRIGHT_DISABLE_AUTH: "true"
+ GIN_MODE: release
+ depends_on:
+ postgres:
+ condition: service_healthy
+ restart: unless-stopped
+
dashboard:
build:
context: web
@@ -46,3 +69,5 @@ services:
volumes:
postgres_data:
+ go_mod_cache:
+ go_build_cache:
diff --git a/go.mod b/go.mod
index c467479..31bb8db 100644
--- a/go.mod
+++ b/go.mod
@@ -3,6 +3,9 @@ module github.com/sgbudje/runright
go 1.26.4
require (
+ cloud.google.com/go/compute v1.64.0
+ github.com/aws/aws-sdk-go-v2/config v1.32.25
+ github.com/aws/aws-sdk-go-v2/service/ec2 v1.308.0
github.com/gin-gonic/gin v1.12.0
github.com/lib/pq v1.12.3
github.com/prometheus/client_golang v1.23.2
@@ -15,9 +18,26 @@ require (
go.opentelemetry.io/otel/metric v1.44.0
go.opentelemetry.io/otel/sdk v1.44.0
go.opentelemetry.io/otel/sdk/metric v1.44.0
+ google.golang.org/api v0.286.0
)
require (
+ cloud.google.com/go/auth v0.20.0 // indirect
+ cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
+ cloud.google.com/go/compute/metadata v0.9.0 // indirect
+ github.com/aws/aws-sdk-go-v2 v1.42.0 // indirect
+ github.com/aws/aws-sdk-go-v2/credentials v1.19.24 // indirect
+ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect
+ github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 // indirect
+ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 // indirect
+ github.com/aws/smithy-go v1.27.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
@@ -26,6 +46,7 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
+ github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
@@ -38,7 +59,10 @@ require (
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
+ github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
+ github.com/googleapis/enterprise-certificate-proxy v0.3.16 // indirect
+ github.com/googleapis/gax-go/v2 v2.22.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
@@ -70,17 +94,20 @@ require (
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
+ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.22.0 // indirect
- golang.org/x/crypto v0.51.0 // indirect
- golang.org/x/net v0.55.0 // indirect
- golang.org/x/sys v0.45.0 // indirect
- golang.org/x/text v0.37.0 // indirect
+ golang.org/x/crypto v0.53.0 // indirect
+ golang.org/x/net v0.56.0 // indirect
+ golang.org/x/oauth2 v0.36.0 // indirect
+ golang.org/x/sys v0.46.0 // indirect
+ golang.org/x/text v0.38.0 // indirect
+ google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
- google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad // indirect
google.golang.org/grpc v1.81.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
diff --git a/go.sum b/go.sum
index 8ceee75..4b4bbec 100644
--- a/go.sum
+++ b/go.sum
@@ -1,3 +1,43 @@
+cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
+cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
+cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA=
+cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q=
+cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
+cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
+cloud.google.com/go/compute v1.64.0 h1:7MmuzeAxlG5MOG5PQD2NLtyYR6bWjkvGljRu7pByoRU=
+cloud.google.com/go/compute v1.64.0/go.mod h1:eHhcRZ6vf70fQCS3VEsiWSh+nQ+tLvSMb7mwLQskgN0=
+cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
+cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
+github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA=
+github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg=
+github.com/aws/aws-sdk-go-v2/config v1.32.25 h1:ACCejvStYoilgwrfegSt5ZntCbPrk52qfwyNcnl3omM=
+github.com/aws/aws-sdk-go-v2/config v1.32.25/go.mod h1:LJyU8sDRbXUxFn8xMJIGP+v9QYYwveNLI8a/giAOiAs=
+github.com/aws/aws-sdk-go-v2/credentials v1.19.24 h1:2hQqYCV9yqyePQ9o6dCrZc/zO8U3TwPr9mIKlZnPu/I=
+github.com/aws/aws-sdk-go-v2/credentials v1.19.24/go.mod h1:IDwpACtwqHLISdzfwUUNq4P9DsB/h5BLg4FwJPNfqFY=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 h1:r6qZHbT+wxgWO/e9vYNUEtg7lv5+UN3pRqKhLXvnArg=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29/go.mod h1:QRnaRcTVGKPGRy8w78HMQtKUGRYcnMZAANATkeVA6Mo=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 h1:f3vKqSo13fhTYb+JEcXwXefZQE26I1FB5eTSniU67ko=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29/go.mod h1:MzoLFUArKGpGD+ukmPiTPG1X5x4o6M2kq4v2dr1FiEc=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 h1:RdwIf/CuUsvJX3RgJagbOyotl/cxoLY4xviKuE7p2GY=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29/go.mod h1:71wt8W2EgswdZy9Mf9KNnzxZ3TiZlv4caKghPktDOkA=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 h1:VTGy885W5DKBxWRUJbym9hytNaYzsyaPkCHGRRMAOhU=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30/go.mod h1:AS0HycUvJRFvTt613AYDOgO2jzw+00cVSMny8XB3yMY=
+github.com/aws/aws-sdk-go-v2/service/ec2 v1.308.0 h1:xBP+yWpveXD/PxK7HRMcoG6yj1vdOjSahAg4qPomF+0=
+github.com/aws/aws-sdk-go-v2/service/ec2 v1.308.0/go.mod h1:8mrDF7OtbuL0QpwP4YCvLuoOE4/5lL7D33MXgp069/Y=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 h1:ZD2+BSw9vFsNlKYIasSNt3uDbjqqXIBcM13UJv/Lx2k=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12/go.mod h1:Ms4zlcVBbXbiP7EVLhl+lgjvA/a7YphqQ3Ih3174EmI=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 h1:DRebniUGZ2MqiiIVmQJ04vIXr918hubdHMnarSLEWyU=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29/go.mod h1:LfRkPCD8YHDM2E5eTkos2UpwYeZnBcVarTa8L59bJHA=
+github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 h1:3nXpRcFwRCW8n7HgO2QGy0Dc20eQNfBuUemGQhpF8m8=
+github.com/aws/aws-sdk-go-v2/service/signin v1.2.0/go.mod h1:LxYujSTLPRlp2vTtcUO/+1ilrew8ytt6SvQyOgejzFQ=
+github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 h1:ey1XLTYXb9PcLt4535632o5kCGXNXEhNb620Dqwuylo=
+github.com/aws/aws-sdk-go-v2/service/sso v1.31.3/go.mod h1:Lk7PlmoTYryQmyBG0EXqj5BcUbj3whXdU2s3yGI3EAc=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 h1:yLr03zQE/5Eu5l3QU0Si+xMbLMbSDF2YXsigqXngs6g=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6/go.mod h1:Q5N6icH+KJZDLh+ESNwzdv6cZ6vLFF/egy3IOxWhmz4=
+github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 h1:VrIhKRCSK1umelSgB9RghvA9RTUYeQffyAS5ApXehNI=
+github.com/aws/aws-sdk-go-v2/service/sts v1.43.3/go.mod h1:r8wkDOuLaaMFqFiYAb8dGY2A3gJCOujMc6CFOVC4Zhc=
+github.com/aws/smithy-go v1.27.1 h1:4T340VFndXtADGF52gYa1POyL7s9E4Z1OeZ1hCscIw8=
+github.com/aws/smithy-go v1.27.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
@@ -18,6 +58,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
+github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
+github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
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.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
@@ -55,8 +97,14 @@ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
+github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/googleapis/enterprise-certificate-proxy v0.3.16 h1:F/VPrx0YPBdksZJQdCAp0WUsqnNmZpUZszzfYt0M5Dw=
+github.com/googleapis/enterprise-certificate-proxy v0.3.16/go.mod h1:9Yb0eAkH/Xqhvv3zbeKf/+wMJqCeocWc6KIhDvEAuYE=
+github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4=
+github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -155,6 +203,8 @@ go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 h1:SUplec5dp06reu1zaXmOXdvqH398taqrDXqUl99jxSc=
@@ -183,24 +233,32 @@ 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/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
-golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
-golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
-golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
-golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
+golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
+golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
+golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
+golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
+golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
+golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
+golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
+golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
-golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
-golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
-golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
+golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
+golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
+golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
+google.golang.org/api v0.286.0 h1:TdTXMvzYKnWV1/lPbCdbXRqBrkDqjPto22H2xeZZ8LI=
+google.golang.org/api v0.286.0/go.mod h1:NlOlUIr8MPoIhT9Bb/oUnRuHbJOLwxb6JSYJM8Yz+jQ=
+google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0=
+google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad h1:45WmJvIV6C2+O/jjLkPUH+F3aOj/1miDoU2DD0+NWbg=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
diff --git a/internal/agent/collector.go b/internal/agent/collector.go
index c2b9bbb..cc4d840 100644
--- a/internal/agent/collector.go
+++ b/internal/agent/collector.go
@@ -27,6 +27,9 @@ import (
// Config controls how the collector behaves.
type Config struct {
Interval time.Duration
+ // ExpensiveSampleEvery controls how often expensive host-wide probes run.
+ // 1 = every tick, 6 = every 6 ticks (default, ~30s at 5s interval).
+ ExpensiveSampleEvery int
HeartbeatInterval time.Duration // how often to send partial data; 0 = 30s when FlushFn or HeartbeatFilePath is set
OutputDir string
JobID string
@@ -56,7 +59,8 @@ type Collector struct {
// baseline net/disk counters captured at first tick
baseNetIO []net.IOCountersStat
baseDiskIO map[string]disk.IOCountersStat
- prevTime time.Time
+ prevIOTime time.Time
+ tickCount int
}
// NewCollector creates a ready-to-run Collector.
@@ -64,6 +68,9 @@ func NewCollector(cfg Config) *Collector {
if cfg.Interval <= 0 {
cfg.Interval = 5 * time.Second
}
+ if cfg.ExpensiveSampleEvery <= 0 {
+ cfg.ExpensiveSampleEvery = 6
+ }
if cfg.OutputDir == "" {
cfg.OutputDir = "."
}
@@ -89,7 +96,7 @@ func (c *Collector) Run(ctx context.Context) error {
diskIO, _ := disk.IOCounters()
c.baseNetIO = netIO
c.baseDiskIO = diskIO
- c.prevTime = c.startTime
+ c.prevIOTime = c.startTime
// Heartbeat: periodically send partial data so the backend has a record even
// if the agent is killed (OOM, runner disconnect) before the final Flush.
@@ -164,6 +171,8 @@ func (c *Collector) sendHeartbeat() {
func (c *Collector) collect(t time.Time) (types.MetricSnapshot, error) {
snap := types.MetricSnapshot{Timestamp: t}
+ c.tickCount++
+ runExpensive := c.tickCount == 1 || c.tickCount%c.cfg.ExpensiveSampleEvery == 0
// CPU
pcts, err := cpu.Percent(0, false)
@@ -178,41 +187,46 @@ func (c *Collector) collect(t time.Time) (types.MetricSnapshot, error) {
snap.MemTotalGiB = float64(vm.Total) / (1 << 30)
}
- // Processes + threads
- procs, err := process.Processes()
- if err == nil {
- snap.ProcessCount = len(procs)
- for _, p := range procs {
- threads, _ := p.NumThreads()
- snap.ThreadCount += int(threads)
+ if runExpensive {
+ // Processes + threads are expensive on large runners; sample less frequently.
+ procs, err := process.Processes()
+ if err == nil {
+ snap.ProcessCount = len(procs)
+ for _, p := range procs {
+ threads, _ := p.NumThreads()
+ snap.ThreadCount += int(threads)
+ }
}
- }
- // Network I/O (delta since last tick)
- elapsed := t.Sub(c.prevTime).Seconds()
- netIO, err := net.IOCounters(false)
- if err == nil && len(netIO) > 0 && len(c.baseNetIO) > 0 {
- snap.NetRxMBs = float64(netIO[0].BytesRecv-c.baseNetIO[0].BytesRecv) / (1 << 20) / elapsed
- snap.NetTxMBs = float64(netIO[0].BytesSent-c.baseNetIO[0].BytesSent) / (1 << 20) / elapsed
- c.baseNetIO = netIO
- }
+ // Network I/O (delta since previous expensive sample)
+ elapsed := t.Sub(c.prevIOTime).Seconds()
+ if elapsed <= 0 {
+ elapsed = c.cfg.Interval.Seconds()
+ }
+ netIO, err := net.IOCounters(false)
+ if err == nil && len(netIO) > 0 && len(c.baseNetIO) > 0 {
+ snap.NetRxMBs = float64(netIO[0].BytesRecv-c.baseNetIO[0].BytesRecv) / (1 << 20) / elapsed
+ snap.NetTxMBs = float64(netIO[0].BytesSent-c.baseNetIO[0].BytesSent) / (1 << 20) / elapsed
+ c.baseNetIO = netIO
+ }
- // Disk I/O (delta since last tick)
- diskIO, err := disk.IOCounters()
- if err == nil && c.baseDiskIO != nil {
- var readBytes, writeBytes uint64
- for name, stat := range diskIO {
- if base, ok := c.baseDiskIO[name]; ok {
- readBytes += stat.ReadBytes - base.ReadBytes
- writeBytes += stat.WriteBytes - base.WriteBytes
+ // Disk I/O (delta since previous expensive sample)
+ diskIO, err := disk.IOCounters()
+ if err == nil && c.baseDiskIO != nil {
+ var readBytes, writeBytes uint64
+ for name, stat := range diskIO {
+ if base, ok := c.baseDiskIO[name]; ok {
+ readBytes += stat.ReadBytes - base.ReadBytes
+ writeBytes += stat.WriteBytes - base.WriteBytes
+ }
}
+ snap.DiskReadMBs = float64(readBytes) / (1 << 20) / elapsed
+ snap.DiskWriteMBs = float64(writeBytes) / (1 << 20) / elapsed
+ c.baseDiskIO = diskIO
}
- snap.DiskReadMBs = float64(readBytes) / (1 << 20) / elapsed
- snap.DiskWriteMBs = float64(writeBytes) / (1 << 20) / elapsed
- c.baseDiskIO = diskIO
+ c.prevIOTime = t
}
- c.prevTime = t
return snap, nil
}
diff --git a/internal/agent/collector_test.go b/internal/agent/collector_test.go
index 6d32bd5..641969d 100644
--- a/internal/agent/collector_test.go
+++ b/internal/agent/collector_test.go
@@ -115,6 +115,9 @@ func TestNewCollector_Defaults(t *testing.T) {
if c.cfg.Interval != 5*time.Second {
t.Errorf("default Interval: got %v, want 5s", c.cfg.Interval)
}
+ if c.cfg.ExpensiveSampleEvery != 6 {
+ t.Errorf("default ExpensiveSampleEvery: got %d, want 6", c.cfg.ExpensiveSampleEvery)
+ }
if c.cfg.OutputDir != "." {
t.Errorf("default OutputDir: got %q, want .", c.cfg.OutputDir)
}
diff --git a/internal/currency/currency.go b/internal/currency/currency.go
new file mode 100644
index 0000000..c6085d3
--- /dev/null
+++ b/internal/currency/currency.go
@@ -0,0 +1,112 @@
+// Package currency provides utilities for multi-currency pricing and conversion.
+package currency
+
+import (
+ "fmt"
+)
+
+// Code represents an ISO 4217 currency code.
+type Code string
+
+const (
+ USD Code = "USD" // US Dollar
+ EUR Code = "EUR" // Euro
+ GBP Code = "GBP" // British Pound
+ JPY Code = "JPY" // Japanese Yen
+ CAD Code = "CAD" // Canadian Dollar
+ AUD Code = "AUD" // Australian Dollar
+ CNY Code = "CNY" // Chinese Yuan
+ INR Code = "INR" // Indian Rupee
+)
+
+// ExchangeRates contains conversion rates from USD to other currencies.
+// These are representative mid-market rates as of 2026-06-23.
+// In production, these should be fetched from an external API (e.g., fixer.io, exchangerate-api.com).
+var ExchangeRates = map[Code]float64{
+ USD: 1.0,
+ EUR: 0.92, // 1 USD = 0.92 EUR
+ GBP: 0.79, // 1 USD = 0.79 GBP
+ JPY: 154.5, // 1 USD = 154.5 JPY
+ CAD: 1.37, // 1 USD = 1.37 CAD
+ AUD: 1.52, // 1 USD = 1.52 AUD
+ CNY: 7.24, // 1 USD = 7.24 CNY
+ INR: 83.12, // 1 USD = 83.12 INR
+}
+
+// ConvertUSD converts an amount from USD to the target currency.
+func ConvertUSD(amountUSD float64, targetCurrency Code) (float64, error) {
+ rate, ok := ExchangeRates[targetCurrency]
+ if !ok {
+ return 0, fmt.Errorf("unknown currency code: %s", targetCurrency)
+ }
+ return amountUSD * rate, nil
+}
+
+// ConvertFrom converts an amount from one currency to another.
+func ConvertFrom(amount float64, fromCurrency, toCurrency Code) (float64, error) {
+ if fromCurrency == toCurrency {
+ return amount, nil
+ }
+ // Convert to USD first, then to target
+ fromRate, ok := ExchangeRates[fromCurrency]
+ if !ok {
+ return 0, fmt.Errorf("unknown source currency code: %s", fromCurrency)
+ }
+ toRate, ok := ExchangeRates[toCurrency]
+ if !ok {
+ return 0, fmt.Errorf("unknown target currency code: %s", toCurrency)
+ }
+ amountUSD := amount / fromRate
+ return amountUSD * toRate, nil
+}
+
+// FormatSymbol returns the currency symbol for display.
+func FormatSymbol(code Code) string {
+ switch code {
+ case USD:
+ return "$"
+ case EUR:
+ return "€"
+ case GBP:
+ return "£"
+ case JPY:
+ return "¥"
+ case CAD:
+ return "C$"
+ case AUD:
+ return "A$"
+ case CNY:
+ return "¥"
+ case INR:
+ return "₹"
+ default:
+ return string(code)
+ }
+}
+
+// Format returns a human-readable formatted price.
+// For JPY and other zero-decimal currencies, precision is 0; otherwise 2.
+func Format(amount float64, code Code) string {
+ symbol := FormatSymbol(code)
+ precision := 2
+ if code == JPY {
+ precision = 0
+ }
+ format := fmt.Sprintf("%%.%df", precision)
+ return symbol + fmt.Sprintf(format, amount)
+}
+
+// IsValidCode returns true if the code is a recognized currency.
+func IsValidCode(code Code) bool {
+ _, ok := ExchangeRates[code]
+ return ok
+}
+
+// AllCodes returns a list of all supported currency codes.
+func AllCodes() []Code {
+ codes := make([]Code, 0, len(ExchangeRates))
+ for code := range ExchangeRates {
+ codes = append(codes, code)
+ }
+ return codes
+}
diff --git a/internal/currency/currency_test.go b/internal/currency/currency_test.go
new file mode 100644
index 0000000..356099d
--- /dev/null
+++ b/internal/currency/currency_test.go
@@ -0,0 +1,185 @@
+package currency
+
+import (
+ "math"
+ "testing"
+)
+
+func TestConvertUSD(t *testing.T) {
+ tests := []struct {
+ amount float64
+ currency Code
+ expected float64
+ epsilon float64 // allow for floating-point rounding
+ }{
+ {100, USD, 100.0, 0.01},
+ {100, EUR, 92.0, 0.01},
+ {100, GBP, 79.0, 0.01},
+ {100, JPY, 15450.0, 1.0},
+ {1000, CAD, 1370.0, 0.01},
+ {50, AUD, 76.0, 0.01},
+ }
+
+ for _, tc := range tests {
+ t.Run(string(tc.currency), func(t *testing.T) {
+ result, err := ConvertUSD(tc.amount, tc.currency)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if math.Abs(result-tc.expected) > tc.epsilon {
+ t.Errorf("expected %f, got %f (epsilon=%f)", tc.expected, result, tc.epsilon)
+ }
+ })
+ }
+}
+
+func TestConvertUSD_InvalidCurrency(t *testing.T) {
+ _, err := ConvertUSD(100, Code("INVALID"))
+ if err == nil {
+ t.Fatal("expected error for invalid currency")
+ }
+}
+
+func TestConvertFrom(t *testing.T) {
+ tests := []struct {
+ amount float64
+ fromCurrency Code
+ toCurrency Code
+ expected float64
+ epsilon float64
+ }{
+ {100, USD, USD, 100.0, 0.01}, // same currency
+ {100, EUR, USD, 108.7, 0.5}, // EUR -> USD
+ {1000, JPY, USD, 6.48, 0.1}, // JPY -> USD (approx)
+ {100, USD, EUR, 92.0, 0.1}, // USD -> EUR
+ {100, EUR, GBP, 85.87, 1.0}, // EUR -> GBP (via USD)
+ }
+
+ for _, tc := range tests {
+ t.Run(string(tc.fromCurrency)+"_to_"+string(tc.toCurrency), func(t *testing.T) {
+ result, err := ConvertFrom(tc.amount, tc.fromCurrency, tc.toCurrency)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if math.Abs(result-tc.expected) > tc.epsilon {
+ t.Errorf("expected %f, got %f (tolerance=%f)", tc.expected, result, tc.epsilon)
+ }
+ })
+ }
+}
+
+func TestConvertFrom_InvalidSource(t *testing.T) {
+ _, err := ConvertFrom(100, Code("INVALID"), USD)
+ if err == nil {
+ t.Fatal("expected error for invalid source currency")
+ }
+}
+
+func TestConvertFrom_InvalidTarget(t *testing.T) {
+ _, err := ConvertFrom(100, USD, Code("INVALID"))
+ if err == nil {
+ t.Fatal("expected error for invalid target currency")
+ }
+}
+
+func TestFormatSymbol(t *testing.T) {
+ tests := []struct {
+ code Code
+ expected string
+ }{
+ {USD, "$"},
+ {EUR, "€"},
+ {GBP, "£"},
+ {JPY, "¥"},
+ {CAD, "C$"},
+ {AUD, "A$"},
+ {Code("UNKNOWN"), "UNKNOWN"},
+ }
+
+ for _, tc := range tests {
+ t.Run(string(tc.code), func(t *testing.T) {
+ result := FormatSymbol(tc.code)
+ if result != tc.expected {
+ t.Errorf("expected %q, got %q", tc.expected, result)
+ }
+ })
+ }
+}
+
+func TestFormat(t *testing.T) {
+ tests := []struct {
+ amount float64
+ currency Code
+ expected string
+ }{
+ {1255.46, USD, "$1255.46"},
+ {1255.46, EUR, "€1255.46"},
+ {15450.4, JPY, "¥15450"}, // rounds to nearest integer
+ {1255.46, GBP, "£1255.46"},
+ }
+
+ for _, tc := range tests {
+ t.Run(string(tc.currency), func(t *testing.T) {
+ result := Format(tc.amount, tc.currency)
+ if result != tc.expected {
+ t.Errorf("expected %q, got %q", tc.expected, result)
+ }
+ })
+ }
+}
+
+func TestIsValidCode(t *testing.T) {
+ if !IsValidCode(USD) {
+ t.Error("USD should be valid")
+ }
+ if !IsValidCode(EUR) {
+ t.Error("EUR should be valid")
+ }
+ if IsValidCode(Code("NOTREAL")) {
+ t.Error("NOTREAL should not be valid")
+ }
+}
+
+func TestAllCodes(t *testing.T) {
+ codes := AllCodes()
+ if len(codes) == 0 {
+ t.Fatal("expected at least one currency code")
+ }
+ // Verify USD is in the list
+ found := false
+ for _, code := range codes {
+ if code == USD {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Error("USD not found in AllCodes()")
+ }
+}
+
+func TestExchangeRates_Consistency(t *testing.T) {
+ // Verify that USD rate is always 1.0
+ if ExchangeRates[USD] != 1.0 {
+ t.Errorf("USD rate should be 1.0, got %f", ExchangeRates[USD])
+ }
+
+ // Verify all rates are positive
+ for code, rate := range ExchangeRates {
+ if rate <= 0 {
+ t.Errorf("currency %s has non-positive rate: %f", code, rate)
+ }
+ }
+}
+
+func BenchmarkConvertUSD(b *testing.B) {
+ for i := 0; i < b.N; i++ {
+ ConvertUSD(1000, EUR)
+ }
+}
+
+func BenchmarkConvertFrom(b *testing.B) {
+ for i := 0; i < b.N; i++ {
+ ConvertFrom(1000, EUR, GBP)
+ }
+}
diff --git a/internal/engine/recommender.go b/internal/engine/recommender.go
index daec6ca..290cd1a 100644
--- a/internal/engine/recommender.go
+++ b/internal/engine/recommender.go
@@ -13,7 +13,18 @@ const (
memHeadroomFactor = 1.30 // 30% headroom above p95 memory — OOM kills are more disruptive than slowdown
hoursPerMonth = 720.0
)
-
+func spotRiskFromInterruptionRate(ratePct float64) string {
+ if ratePct <= 0 {
+ return ""
+ }
+ if ratePct < 5 {
+ return "low"
+ }
+ if ratePct <= 15 {
+ return "medium"
+ }
+ return "high"
+}
// Recommend returns a ranked list of machine recommendations based on observed
// job metrics and the provided machine catalog.
func Recommend(summary types.MetricsSummary, catalog []types.MachineType) []types.Recommendation {
@@ -55,13 +66,21 @@ func Recommend(summary types.MetricsSummary, catalog []types.MachineType) []type
}
tier := classifyTier(m, detected, requiredVCPUs, requiredMemGiB)
estimatedMonthly := m.OnDemandPricePerHour * hoursPerMonth
- spotMonthly := estimatedMonthly * spotDiscount(m.Provider)
+ spotMonthly := 0.0
deltaPercent := 0.0
spotDeltaPercent := 0.0
if currentMonthly > 0 {
deltaPercent = ((estimatedMonthly - currentMonthly) / currentMonthly) * 100
- spotDeltaPercent = ((spotMonthly - currentMonthly) / currentMonthly) * 100
+ if m.SpotPricePerHour > 0 {
+ spotMonthly = m.SpotPricePerHour * hoursPerMonth
+ spotDeltaPercent = ((spotMonthly - currentMonthly) / currentMonthly) * 100
+ }
}
+ spotRisk := m.SpotRisk
+ if spotRisk == "" {
+ spotRisk = spotRiskFromInterruptionRate(m.SpotInterruptionRatePct)
+ }
+ durationRiskNote := buildDurationRiskNote(m, detected, requiredVCPUs)
results = append(results, types.Recommendation{
Machine: m,
Tier: tier,
@@ -73,6 +92,8 @@ func Recommend(summary types.MetricsSummary, catalog []types.MachineType) []type
RequiredVCPUs: requiredVCPUs,
RequiredMemoryGiB: requiredMemGiB,
Reasoning: buildReasoning(m, summary, requiredVCPUs, requiredMemGiB),
+ DurationRiskNote: durationRiskNote,
+ SpotRisk: spotRisk,
KubernetesResources: buildK8sResources(summary, requiredVCPUs, requiredMemGiB),
})
}
@@ -184,13 +205,22 @@ func cap(results []types.Recommendation) []types.Recommendation {
return out
}
-// spotDiscount returns the approximate fraction of on-demand price for spot/preemptible
-// instances. AWS spot is ~30% of on-demand; GCP preemptible is ~20% of on-demand.
-func spotDiscount(provider types.Provider) float64 {
- switch provider {
- case types.ProviderGCP:
- return 0.20
- default: // AWS and others
- return 0.30
+// buildDurationRiskNote returns a non-empty note when the recommended machine has
+// significantly fewer vCPUs than the current one, flagging a possible build slowdown.
+func buildDurationRiskNote(m types.MachineType, detected *types.MachineType, reqVCPUs int) string {
+ if detected == nil || detected.VCPUs <= 0 {
+ return ""
+ }
+ // Only warn when the candidate machine has <50% of the current vCPU count.
+ if float64(m.VCPUs)/float64(detected.VCPUs) >= 0.5 {
+ return ""
}
+ return fmt.Sprintf(
+ "Recommended %d vCPUs vs current %d vCPUs (%.0f%% reduction). "+
+ "CPU-bound steps (compilation, test parallelism) may run slower. "+
+ "Validate with a benchmark run before committing to this machine type.",
+ m.VCPUs, detected.VCPUs,
+ (1-float64(m.VCPUs)/float64(detected.VCPUs))*100,
+ )
}
+
diff --git a/internal/engine/recommender_test.go b/internal/engine/recommender_test.go
index a89c880..a7fb5c3 100644
--- a/internal/engine/recommender_test.go
+++ b/internal/engine/recommender_test.go
@@ -107,3 +107,47 @@ func TestRecommend_TierOrdering(t *testing.T) {
}
}
}
+
+func TestRecommend_DoesNotGuessSpotValuesWithoutCatalogData(t *testing.T) {
+ machines := []types.MachineType{
+ {
+ ID: "m5.large",
+ Provider: types.ProviderAWS,
+ Family: "general-purpose",
+ Series: "m5",
+ VCPUs: 2,
+ MemoryGiB: 8,
+ OnDemandPricePerHour: 0.096,
+ // Intentionally no spot_price_per_hour / spot_interruption_rate_pct / spot_risk.
+ },
+ }
+
+ summary := types.MetricsSummary{
+ JobID: "no-spot-data",
+ CPUPercentP95: 45,
+ MemUsedGiBP95: 2,
+ SampleCount: 8,
+ DetectedMachine: &types.MachineType{
+ ID: "m5.large",
+ Provider: types.ProviderAWS,
+ VCPUs: 2,
+ MemoryGiB: 8,
+ OnDemandPricePerHour: 0.096,
+ },
+ }
+
+ recs := Recommend(summary, machines)
+ if len(recs) == 0 {
+ t.Fatal("expected recommendation")
+ }
+
+ if recs[0].SpotMonthly != 0 {
+ t.Fatalf("expected spot monthly to be 0 when no spot data exists, got %.4f", recs[0].SpotMonthly)
+ }
+ if recs[0].SpotDeltaPercent != 0 {
+ t.Fatalf("expected spot delta to be 0 when no spot data exists, got %.4f", recs[0].SpotDeltaPercent)
+ }
+ if recs[0].SpotRisk != "" {
+ t.Fatalf("expected empty spot risk when no spot data exists, got %q", recs[0].SpotRisk)
+ }
+}
diff --git a/internal/exporter/exporter.go b/internal/exporter/exporter.go
index 1f7eea2..cf16fdd 100644
--- a/internal/exporter/exporter.go
+++ b/internal/exporter/exporter.go
@@ -33,6 +33,7 @@ const (
BackendPrometheus Backend = "prometheus"
BackendHTTP Backend = "http"
BackendSlack Backend = "slack"
+ BackendTeams Backend = "teams"
)
// ParseBackends parses a comma-separated list of backend names.
@@ -41,7 +42,7 @@ func ParseBackends(s string) []Backend {
for _, part := range strings.Split(s, ",") {
part = strings.TrimSpace(strings.ToLower(part))
switch Backend(part) {
- case BackendFile, BackendOTLP, BackendPrometheus, BackendHTTP, BackendSlack:
+ case BackendFile, BackendOTLP, BackendPrometheus, BackendHTTP, BackendSlack, BackendTeams:
out = append(out, Backend(part))
}
}
@@ -59,16 +60,18 @@ type Manager struct {
promPort int
httpURL string
slackWebhook string
+ teamsWebhook string
promLn net.Listener
}
// New initialises the export manager. Call Shutdown when done.
-func New(ctx context.Context, backends []Backend, promPort int, httpURL string, slackWebhook string) (*Manager, error) {
+func New(ctx context.Context, backends []Backend, promPort int, httpURL string, slackWebhook string, teamsWebhook string) (*Manager, error) {
m := &Manager{
backends: backends,
promPort: promPort,
httpURL: httpURL,
slackWebhook: slackWebhook,
+ teamsWebhook: teamsWebhook,
}
var readers []sdkmetric.Reader
@@ -161,6 +164,10 @@ func (m *Manager) PublishSummary(ctx context.Context, summary types.MetricsSumma
if err := m.postSlack(ctx, summary, recs); err != nil {
return err
}
+ case BackendTeams:
+ if err := m.postTeams(ctx, summary, recs); err != nil {
+ return err
+ }
}
}
return nil
@@ -237,7 +244,7 @@ func (m *Manager) postSlack(ctx context.Context, summary types.MetricsSummary, r
monthlyDelta := best.CurrentMonthly - best.EstimatedMonthly
text := fmt.Sprintf(
- ":chart_with_downwards_trend: *RunRight — `%s`*\n"+
+ "RunRight savings report for `%s`\n"+
">*Current:* `%s` *Recommended:* `%s`\n"+
">*Savings:* ~$%.2f/mo (%.0f%% cheaper)\n"+
">*Required:* %d vCPU · %.1f GiB RAM (p95 with headroom)",
@@ -277,3 +284,72 @@ func (m *Manager) postSlack(ctx context.Context, summary types.MetricsSummary, r
}
return nil
}
+
+// postTeams sends a concise recommendation card to a Microsoft Teams Incoming Webhook.
+// Only fires for completed summaries with at least one cheaper recommendation.
+func (m *Manager) postTeams(ctx context.Context, summary types.MetricsSummary, recs []types.Recommendation) error {
+ if m.teamsWebhook == "" {
+ return fmt.Errorf("--teams-webhook is required for the teams exporter")
+ }
+ if summary.Status != "completed" {
+ return nil
+ }
+
+ var best *types.Recommendation
+ for i := range recs {
+ if recs[i].CostDeltaPercent < 0 {
+ if best == nil || recs[i].CostDeltaPercent < best.CostDeltaPercent {
+ best = &recs[i]
+ }
+ }
+ }
+ if best == nil {
+ return nil
+ }
+
+ currentID := "unknown"
+ if summary.DetectedMachine != nil {
+ currentID = summary.DetectedMachine.ID
+ }
+ savingsPct := -best.CostDeltaPercent
+ monthlyDelta := best.CurrentMonthly - best.EstimatedMonthly
+
+ // Teams Incoming Webhook payload (simple MessageCard format — universally supported).
+ payload := map[string]interface{}{
+ "@type": "MessageCard",
+ "@context": "http://schema.org/extensions",
+ "themeColor": "0078D4",
+ "summary": fmt.Sprintf("RunRight: %s can save ~$%.2f/mo", summary.JobID, monthlyDelta),
+ "sections": []map[string]interface{}{
+ {
+ "activityTitle": fmt.Sprintf("RunRight - `%s`", summary.JobID),
+ "activitySubtitle": fmt.Sprintf("Current: **%s** to Recommended: **%s**", currentID, best.Machine.ID),
+ "facts": []map[string]string{
+ {"name": "Monthly saving", "value": fmt.Sprintf("~$%.2f (%.0f%% cheaper)", monthlyDelta, savingsPct)},
+ {"name": "Required", "value": fmt.Sprintf("%d vCPU · %.1f GiB RAM (p95 + headroom)", best.RequiredVCPUs, best.RequiredMemoryGiB)},
+ {"name": "Spot risk", "value": best.SpotRisk},
+ },
+ "markdown": true,
+ },
+ },
+ }
+ body, err := json.Marshal(payload)
+ if err != nil {
+ return err
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, m.teamsWebhook, bytes.NewReader(body))
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ client := &http.Client{Timeout: 10 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return err
+ }
+ _ = resp.Body.Close()
+ if resp.StatusCode >= 300 {
+ return fmt.Errorf("teams webhook returned %d", resp.StatusCode)
+ }
+ return nil
+}
diff --git a/internal/notification/adapters.go b/internal/notification/adapters.go
new file mode 100644
index 0000000..3c09d4c
--- /dev/null
+++ b/internal/notification/adapters.go
@@ -0,0 +1,102 @@
+package notification
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "time"
+)
+
+// SlackChannel delivers messages to a Slack Incoming Webhook.
+type SlackChannel struct {
+ WebhookURL string
+ Mention string // optional @mention prepended to every message
+}
+
+func (c *SlackChannel) Kind() string { return "slack" }
+
+func (c *SlackChannel) Send(ctx context.Context, msg Message) error {
+ text := msg.Body
+ if c.Mention != "" {
+ text = c.Mention + " " + text
+ }
+ payload := map[string]string{"text": text}
+ return postJSON(ctx, c.WebhookURL, payload, nil)
+}
+
+// TeamsChannel delivers messages to a Microsoft Teams Incoming Webhook
+// using the MessageCard format (universally supported).
+type TeamsChannel struct {
+ WebhookURL string
+}
+
+func (c *TeamsChannel) Kind() string { return "teams" }
+
+func (c *TeamsChannel) Send(ctx context.Context, msg Message) error {
+ payload := map[string]interface{}{
+ "@type": "MessageCard",
+ "@context": "http://schema.org/extensions",
+ "themeColor": "C23B22",
+ "summary": msg.Title,
+ "sections": []map[string]interface{}{
+ {
+ "activityTitle": msg.Title,
+ "activityText": msg.Body,
+ "markdown": true,
+ },
+ },
+ }
+ return postJSON(ctx, c.WebhookURL, payload, nil)
+}
+
+// WebhookChannel delivers structured JSON payloads to any HTTP endpoint.
+// It is the escape hatch for custom integrations (PagerDuty, custom pipelines, etc.).
+type WebhookChannel struct {
+ URL string
+ Headers map[string]string // e.g. Authorization, X-Custom-Header
+}
+
+func (c *WebhookChannel) Kind() string { return "webhook" }
+
+func (c *WebhookChannel) Send(ctx context.Context, msg Message) error {
+ payload := map[string]interface{}{
+ "rule_id": msg.RuleID,
+ "rule_name": msg.RuleName,
+ "event": msg.Event,
+ "title": msg.Title,
+ "body": msg.Body,
+ "job_id": msg.JobID,
+ "repository": msg.Repository,
+ "meta": msg.Meta,
+ "sent_at": msg.SentAt.Format(time.RFC3339),
+ }
+ return postJSON(ctx, c.URL, payload, c.Headers)
+}
+
+// postJSON marshals payload and POSTs it, applying optional extra headers.
+func postJSON(ctx context.Context, url string, payload interface{}, headers map[string]string) error {
+ body, err := json.Marshal(payload)
+ if err != nil {
+ return fmt.Errorf("marshal: %w", err)
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
+ if err != nil {
+ return fmt.Errorf("build request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+ for k, v := range headers {
+ req.Header.Set(k, v)
+ }
+ client := &http.Client{Timeout: 10 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return fmt.Errorf("http: %w", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return fmt.Errorf("server returned %d", resp.StatusCode)
+ }
+ return nil
+}
diff --git a/internal/notification/channel.go b/internal/notification/channel.go
new file mode 100644
index 0000000..381b926
--- /dev/null
+++ b/internal/notification/channel.go
@@ -0,0 +1,70 @@
+// Package notification provides a channel-agnostic notification system.
+// Adding a new notification channel requires implementing the Channel interface
+// and registering it in the Dispatcher's destination index.
+package notification
+
+import (
+ "context"
+ "fmt"
+ "time"
+)
+
+// Channel is the single interface all notification adapters must satisfy.
+// Implementations are responsible only for delivery; formatting is handled
+// by the Dispatcher before calling Send.
+type Channel interface {
+ // Kind returns the canonical channel identifier (e.g. "slack", "teams", "webhook").
+ Kind() string
+ // Send delivers message to the destination. Returns an error if delivery fails.
+ Send(ctx context.Context, message Message) error
+}
+
+// Message is the provider-agnostic payload passed to every Channel.
+// Channels can use whichever fields are relevant to their format.
+type Message struct {
+ // Title is a short one-line summary (Teams/Webhook subject line).
+ Title string
+ // Body is the human-readable notification text (Slack-style markdown supported).
+ Body string
+ // Event is the originating event kind (e.g. "policy_violation").
+ Event string
+ // RuleID and RuleName identify which alert rule triggered this send.
+ RuleID string
+ RuleName string
+ // Scope metadata for context in messages.
+ JobID string
+ Repository string
+ // Extra key/value pairs adapters can pull for custom formatting.
+ Meta map[string]string
+ // SentAt is when the message was created (set by Dispatcher).
+ SentAt time.Time
+}
+
+// DeliveryResult records one delivery attempt.
+type DeliveryResult struct {
+ DestinationID string
+ Channel string
+ Status string // "delivered" | "failed"
+ Error string
+ SentAt time.Time
+}
+
+// Destination pairs a Channel implementation with its identity and config.
+type Destination struct {
+ ID string
+ Name string
+ Channel Channel
+}
+
+// ErrDelivery wraps a delivery failure with context.
+type ErrDelivery struct {
+ DestinationID string
+ Kind string
+ Cause error
+}
+
+func (e *ErrDelivery) Error() string {
+ return fmt.Sprintf("deliver to %s (%s): %v", e.DestinationID, e.Kind, e.Cause)
+}
+
+func (e *ErrDelivery) Unwrap() error { return e.Cause }
diff --git a/internal/notification/dispatcher.go b/internal/notification/dispatcher.go
new file mode 100644
index 0000000..44c2310
--- /dev/null
+++ b/internal/notification/dispatcher.go
@@ -0,0 +1,266 @@
+package notification
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "time"
+)
+
+// EventKind is a typed event identifier.
+type EventKind string
+
+const (
+ EventPolicyViolation EventKind = "policy_violation"
+ EventHighWaste EventKind = "high_waste"
+ EventDailySummary EventKind = "daily_summary"
+ EventThreshold EventKind = "threshold"
+)
+
+// RuleType distinguishes event-triggered from threshold-triggered rules.
+type RuleType string
+
+const (
+ RuleTypeEvent RuleType = "event"
+ RuleTypeThreshold RuleType = "threshold"
+)
+
+// RuleScope controls which jobs a rule applies to.
+type RuleScope string
+
+const (
+ ScopeGlobal RuleScope = "global"
+ ScopeRepository RuleScope = "repository"
+ ScopeJob RuleScope = "job"
+)
+
+// Rule is the provider-agnostic representation of an alert rule.
+type Rule struct {
+ ID string
+ Name string
+ Type RuleType
+ Event EventKind
+ Scope RuleScope
+ Repository string
+ JobID string
+ Metric string
+ Threshold float64
+ DestinationIDs []string
+ Enabled bool
+}
+
+// RuntimeMetrics carries the computed metrics for a completed job needed
+// to evaluate whether any rule should fire.
+type RuntimeMetrics struct {
+ Repository string
+ JobID string
+ DetectedPricePerHour float64
+ WastePercent float64
+ MonthlySavingsDropPercent float64
+ PolicyViolated bool
+ PolicyScope string
+}
+
+// GlobalEvents holds the event-level toggles (mirrors notificationEvents in server).
+type GlobalEvents struct {
+ PolicyViolation bool
+ HighWaste bool
+ DailySummary bool
+}
+
+// DailySummaryStats carries aggregated stats for a daily summary message.
+type DailySummaryStats struct {
+ Runs int
+ AvgCPUP95 float64
+ AvgMemGiBP95 float64
+ AvgDurationSec float64
+ SavingsRunCount int
+}
+
+// Dispatcher evaluates rules against runtime metrics and delivers messages
+// to the appropriate channels using the provided destination index.
+type Dispatcher struct {
+ // Destinations maps destination ID → ready-to-use Destination.
+ Destinations map[string]Destination
+ // OnDelivery is called after each delivery attempt (success or failure).
+ // Use it to record delivery logs. May be nil.
+ OnDelivery func(result DeliveryResult)
+}
+
+// Dispatch evaluates every rule in rules against metrics, then sends to
+// all matching destinations. Rules are evaluated independently; a single
+// job completing can trigger multiple rules to multiple channels.
+func (d *Dispatcher) Dispatch(ctx context.Context, rules []Rule, events GlobalEvents, metrics RuntimeMetrics) {
+ for _, rule := range rules {
+ if !rule.Enabled {
+ continue
+ }
+ if !scopeMatches(rule, metrics.Repository, metrics.JobID) {
+ continue
+ }
+ shouldSend, value, threshold := d.shouldTrigger(rule, events, metrics)
+ if !shouldSend {
+ continue
+ }
+ msg := d.formatMessage(rule, metrics, value, threshold)
+ d.send(ctx, rule, msg)
+ }
+}
+
+// DispatchDailySummary sends daily summary messages for rules that match
+// the event=daily_summary type. It is called by the scheduler, not per-job.
+func (d *Dispatcher) DispatchDailySummary(ctx context.Context, rules []Rule, events GlobalEvents, stats DailySummaryStats, scope RuleScope, repository, jobID string, windowEnd time.Time) {
+ if !events.DailySummary {
+ return
+ }
+ for _, rule := range rules {
+ if !rule.Enabled || rule.Type != RuleTypeEvent || rule.Event != EventDailySummary {
+ continue
+ }
+ if rule.Scope != scope {
+ continue
+ }
+ if scope == ScopeRepository && rule.Repository != repository {
+ continue
+ }
+ if scope == ScopeJob && (rule.Repository != repository || rule.JobID != jobID) {
+ continue
+ }
+ msg := d.formatDailySummaryMessage(rule, stats, windowEnd)
+ d.send(ctx, rule, msg)
+ }
+}
+
+func (d *Dispatcher) send(ctx context.Context, rule Rule, msg Message) {
+ for _, destID := range rule.DestinationIDs {
+ dest, ok := d.Destinations[destID]
+ if !ok {
+ continue
+ }
+ err := dest.Channel.Send(ctx, msg)
+ if d.OnDelivery != nil {
+ result := DeliveryResult{
+ DestinationID: dest.ID,
+ Channel: dest.Channel.Kind(),
+ SentAt: time.Now().UTC(),
+ }
+ if err != nil {
+ result.Status = "failed"
+ result.Error = err.Error()
+ } else {
+ result.Status = "delivered"
+ }
+ d.OnDelivery(result)
+ }
+ }
+}
+
+func (d *Dispatcher) shouldTrigger(rule Rule, events GlobalEvents, metrics RuntimeMetrics) (bool, float64, float64) {
+ switch rule.Type {
+ case RuleTypeEvent:
+ switch rule.Event {
+ case EventPolicyViolation:
+ return events.PolicyViolation && metrics.PolicyViolated, metrics.DetectedPricePerHour, 0
+ case EventHighWaste:
+ return events.HighWaste && metrics.WastePercent >= 80, metrics.WastePercent, 80
+ case EventDailySummary:
+ return false, 0, 0 // handled separately by DispatchDailySummary
+ }
+ case RuleTypeThreshold:
+ var value float64
+ switch rule.Metric {
+ case "max_cost_per_hour":
+ value = metrics.DetectedPricePerHour
+ case "waste_percent":
+ value = metrics.WastePercent
+ case "monthly_savings_drop_percent":
+ value = metrics.MonthlySavingsDropPercent
+ }
+ return value >= rule.Threshold, value, rule.Threshold
+ }
+ return false, 0, 0
+}
+
+func scopeMatches(rule Rule, repository, jobID string) bool {
+ switch rule.Scope {
+ case ScopeGlobal:
+ return true
+ case ScopeRepository:
+ return rule.Repository == repository
+ case ScopeJob:
+ return rule.Repository == repository && rule.JobID == jobID
+ }
+ return false
+}
+
+func (d *Dispatcher) formatMessage(rule Rule, metrics RuntimeMetrics, value, threshold float64) Message {
+ repo := metrics.Repository
+ if repo == "" {
+ repo = "local"
+ }
+ jobID := metrics.JobID
+ if jobID == "" {
+ jobID = "unknown"
+ }
+
+ var title, body string
+ switch rule.Type {
+ case RuleTypeEvent:
+ switch rule.Event {
+ case EventPolicyViolation:
+ scope := metrics.PolicyScope
+ if scope == "" || scope == "none" {
+ scope = "configured"
+ }
+ title = fmt.Sprintf("RunRight policy violation — %s", rule.Name)
+ body = fmt.Sprintf(":rotating_light: *Policy violation* (%s scope)\n• Rule: %s\n• Repository: `%s`\n• Job: `%s`\n• Detected cost/hour: $%.4f", scope, rule.Name, repo, jobID, value)
+ case EventHighWaste:
+ title = fmt.Sprintf("RunRight high waste — %s", rule.Name)
+ body = fmt.Sprintf(":warning: *High waste detected*\n• Rule: %s\n• Repository: `%s`\n• Job: `%s`\n• Waste: %.1f%%", rule.Name, repo, jobID, value)
+ default:
+ title = fmt.Sprintf("RunRight alert — %s", rule.Name)
+ body = fmt.Sprintf(":information_source: %s\n• Repository: `%s`\n• Job: `%s`", rule.Name, repo, jobID)
+ }
+ case RuleTypeThreshold:
+ metricLabel := strings.ReplaceAll(rule.Metric, "_", " ")
+ title = fmt.Sprintf("RunRight threshold alert — %s", rule.Name)
+ body = fmt.Sprintf(":bell: *Threshold exceeded*\n• Rule: %s\n• Metric: %s\n• Value: %.2f (threshold: %.2f)\n• Repository: `%s`\n• Job: `%s`", rule.Name, metricLabel, value, threshold, repo, jobID)
+ }
+
+ return Message{
+ Title: title,
+ Body: body,
+ Event: string(rule.Event),
+ RuleID: rule.ID,
+ RuleName: rule.Name,
+ JobID: jobID,
+ Repository: repo,
+ SentAt: time.Now().UTC(),
+ }
+}
+
+func (d *Dispatcher) formatDailySummaryMessage(rule Rule, stats DailySummaryStats, windowEnd time.Time) Message {
+ scope := "Global"
+ switch rule.Scope {
+ case ScopeRepository:
+ scope = fmt.Sprintf("Repository `%s`", rule.Repository)
+ case ScopeJob:
+ scope = fmt.Sprintf("Job `%s` in `%s`", rule.JobID, rule.Repository)
+ }
+
+ title := fmt.Sprintf("RunRight daily summary — %s", rule.Name)
+ body := fmt.Sprintf(
+ ":calendar: *Daily summary* (%s)\n• Rule: %s\n• Window: last 24h ending %s UTC\n• Completed runs: %d\n• Avg p95 CPU: %.1f%%\n• Avg p95 memory: %.2f GiB\n• Avg duration: %.0fs\n• Runs with cheaper option: %d",
+ scope, rule.Name, windowEnd.Format("2006-01-02 15:04"),
+ stats.Runs, stats.AvgCPUP95, stats.AvgMemGiBP95, stats.AvgDurationSec, stats.SavingsRunCount,
+ )
+
+ return Message{
+ Title: title,
+ Body: body,
+ Event: string(EventDailySummary),
+ RuleID: rule.ID,
+ RuleName: rule.Name,
+ SentAt: time.Now().UTC(),
+ }
+}
diff --git a/internal/server/migrations.go b/internal/server/migrations.go
new file mode 100644
index 0000000..2772960
--- /dev/null
+++ b/internal/server/migrations.go
@@ -0,0 +1,89 @@
+package server
+
+import (
+ "database/sql"
+ "embed"
+ "fmt"
+ "io/fs"
+ "path/filepath"
+ "sort"
+ "strings"
+)
+
+//go:embed migrations/*.up.sql
+var migrationFS embed.FS
+
+func runEmbeddedMigrations(db *sql.DB) error {
+ if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
+ version TEXT PRIMARY KEY,
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+ )`); err != nil {
+ return fmt.Errorf("create schema_migrations: %w", err)
+ }
+
+ applied := map[string]struct{}{}
+ rows, err := db.Query(`SELECT version FROM schema_migrations`)
+ if err != nil {
+ return fmt.Errorf("load schema_migrations: %w", err)
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var version string
+ if err := rows.Scan(&version); err != nil {
+ return fmt.Errorf("scan schema_migrations: %w", err)
+ }
+ applied[version] = struct{}{}
+ }
+ if err := rows.Err(); err != nil {
+ return fmt.Errorf("read schema_migrations: %w", err)
+ }
+
+ files, err := fs.Glob(migrationFS, "migrations/*.up.sql")
+ if err != nil {
+ return fmt.Errorf("list migrations: %w", err)
+ }
+ sort.Strings(files)
+
+ for _, file := range files {
+ version := strings.TrimSuffix(filepath.Base(file), ".up.sql")
+ if _, ok := applied[version]; ok {
+ continue
+ }
+
+ script, err := migrationFS.ReadFile(file)
+ if err != nil {
+ return fmt.Errorf("read migration %s: %w", file, err)
+ }
+
+ tx, err := db.Begin()
+ if err != nil {
+ return fmt.Errorf("begin migration %s: %w", file, err)
+ }
+ if err := execSQLScript(tx, string(script)); err != nil {
+ _ = tx.Rollback()
+ return fmt.Errorf("apply migration %s: %w", file, err)
+ }
+ if _, err := tx.Exec(`INSERT INTO schema_migrations(version) VALUES ($1)`, version); err != nil {
+ _ = tx.Rollback()
+ return fmt.Errorf("record migration %s: %w", file, err)
+ }
+ if err := tx.Commit(); err != nil {
+ return fmt.Errorf("commit migration %s: %w", file, err)
+ }
+ }
+
+ return nil
+}
+
+func execSQLScript(tx *sql.Tx, script string) error {
+ for _, stmt := range strings.Split(script, ";") {
+ stmt = strings.TrimSpace(stmt)
+ if stmt == "" {
+ continue
+ }
+ if _, err := tx.Exec(stmt); err != nil {
+ return err
+ }
+ }
+ return nil
+}
\ No newline at end of file
diff --git a/internal/server/migrations/0001_init.up.sql b/internal/server/migrations/0001_init.up.sql
new file mode 100644
index 0000000..6592502
--- /dev/null
+++ b/internal/server/migrations/0001_init.up.sql
@@ -0,0 +1,43 @@
+CREATE TABLE IF NOT EXISTS jobs (
+ id SERIAL PRIMARY KEY,
+ job_id TEXT NOT NULL,
+ run_id TEXT,
+ start_time TIMESTAMPTZ NOT NULL,
+ end_time TIMESTAMPTZ NOT NULL,
+ duration_seconds DOUBLE PRECISION NOT NULL DEFAULT 0,
+ summary JSONB NOT NULL DEFAULT '{}',
+ recommendations JSONB NOT NULL DEFAULT '[]',
+ status TEXT NOT NULL DEFAULT 'completed',
+ repository TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+ALTER TABLE jobs ADD COLUMN IF NOT EXISTS run_id TEXT;
+ALTER TABLE jobs ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'completed';
+ALTER TABLE jobs ADD COLUMN IF NOT EXISTS repository TEXT;
+
+CREATE TABLE IF NOT EXISTS job_metadata (
+ job_id TEXT NOT NULL,
+ repository TEXT NOT NULL DEFAULT '',
+ snoozed_until TIMESTAMPTZ,
+ snooze_reason TEXT NOT NULL DEFAULT '',
+ archived BOOLEAN NOT NULL DEFAULT false,
+ archived_at TIMESTAMPTZ,
+ stale_days INT NOT NULL DEFAULT 30,
+ PRIMARY KEY (job_id, repository)
+);
+
+CREATE TABLE IF NOT EXISTS policy_rules (
+ repository TEXT NOT NULL DEFAULT '',
+ job_id TEXT NOT NULL DEFAULT '',
+ max_cost_per_hour DOUBLE PRECISION NOT NULL,
+ enabled BOOLEAN NOT NULL DEFAULT true,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ PRIMARY KEY (repository, job_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_jobs_job_id ON jobs(job_id);
+CREATE INDEX IF NOT EXISTS idx_jobs_created_at ON jobs(created_at);
+CREATE INDEX IF NOT EXISTS idx_jobs_repository ON jobs(repository) WHERE repository IS NOT NULL;
+CREATE UNIQUE INDEX IF NOT EXISTS idx_jobs_run_id ON jobs(run_id) WHERE run_id IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_policy_rules_repository ON policy_rules(repository);
\ No newline at end of file
diff --git a/internal/server/migrations/0002_notifications_settings.up.sql b/internal/server/migrations/0002_notifications_settings.up.sql
new file mode 100644
index 0000000..96456f8
--- /dev/null
+++ b/internal/server/migrations/0002_notifications_settings.up.sql
@@ -0,0 +1,5 @@
+CREATE TABLE IF NOT EXISTS notification_settings (
+ id INT PRIMARY KEY CHECK (id = 1),
+ settings JSONB NOT NULL DEFAULT '{}'::jsonb,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
diff --git a/internal/server/migrations/0003_notification_destination_secrets.up.sql b/internal/server/migrations/0003_notification_destination_secrets.up.sql
new file mode 100644
index 0000000..6722f99
--- /dev/null
+++ b/internal/server/migrations/0003_notification_destination_secrets.up.sql
@@ -0,0 +1,5 @@
+CREATE TABLE IF NOT EXISTS notification_destination_secrets (
+ destination_id TEXT PRIMARY KEY,
+ webhook_url TEXT NOT NULL,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
diff --git a/internal/server/migrations/0004_notification_daily_summary_dispatches.up.sql b/internal/server/migrations/0004_notification_daily_summary_dispatches.up.sql
new file mode 100644
index 0000000..bd4b606
--- /dev/null
+++ b/internal/server/migrations/0004_notification_daily_summary_dispatches.up.sql
@@ -0,0 +1,7 @@
+CREATE TABLE IF NOT EXISTS notification_daily_summary_dispatches (
+ rule_id TEXT NOT NULL,
+ scope_key TEXT NOT NULL,
+ summary_date DATE NOT NULL,
+ sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ PRIMARY KEY (rule_id, scope_key, summary_date)
+);
diff --git a/internal/server/migrations/0005_notification_delivery_logs.up.sql b/internal/server/migrations/0005_notification_delivery_logs.up.sql
new file mode 100644
index 0000000..876b76f
--- /dev/null
+++ b/internal/server/migrations/0005_notification_delivery_logs.up.sql
@@ -0,0 +1,14 @@
+CREATE TABLE IF NOT EXISTS notification_delivery_logs (
+ id SERIAL PRIMARY KEY,
+ rule_id TEXT NOT NULL,
+ destination_id TEXT NOT NULL,
+ channel TEXT NOT NULL, -- 'slack' | 'teams' | 'webhook'
+ job_id TEXT NOT NULL DEFAULT '',
+ repository TEXT NOT NULL DEFAULT '',
+ status TEXT NOT NULL, -- 'delivered' | 'failed'
+ error_message TEXT NOT NULL DEFAULT '',
+ sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_ndl_rule_id ON notification_delivery_logs(rule_id);
+CREATE INDEX IF NOT EXISTS idx_ndl_sent_at ON notification_delivery_logs(sent_at);
diff --git a/internal/server/migrations/0006_notification_retry_queue.up.sql b/internal/server/migrations/0006_notification_retry_queue.up.sql
new file mode 100644
index 0000000..a64987d
--- /dev/null
+++ b/internal/server/migrations/0006_notification_retry_queue.up.sql
@@ -0,0 +1,5 @@
+ALTER TABLE notification_delivery_logs ADD COLUMN IF NOT EXISTS attempts INT NOT NULL DEFAULT 1;
+ALTER TABLE notification_delivery_logs ADD COLUMN IF NOT EXISTS max_attempts INT NOT NULL DEFAULT 4;
+ALTER TABLE notification_delivery_logs ADD COLUMN IF NOT EXISTS next_retry_at TIMESTAMPTZ;
+ALTER TABLE notification_delivery_logs ADD COLUMN IF NOT EXISTS payload TEXT NOT NULL DEFAULT '';
+CREATE INDEX IF NOT EXISTS idx_ndl_retry ON notification_delivery_logs (next_retry_at) WHERE status = 'failed' AND next_retry_at IS NOT NULL;
diff --git a/internal/server/migrations/0007_repository_ownership.up.sql b/internal/server/migrations/0007_repository_ownership.up.sql
new file mode 100644
index 0000000..91d6af8
--- /dev/null
+++ b/internal/server/migrations/0007_repository_ownership.up.sql
@@ -0,0 +1,15 @@
+-- Repository ownership: maps a repository to a named team and the
+-- destination IDs that team should receive alerts on.
+-- destination_ids is a JSONB array of notification destination IDs
+-- (matching IDs in notification_settings.slack/teams/webhooks.destinations).
+
+CREATE TABLE IF NOT EXISTS repository_ownership (
+ repository TEXT NOT NULL,
+ team_name TEXT NOT NULL,
+ destination_ids JSONB NOT NULL DEFAULT '[]',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ PRIMARY KEY (repository, team_name)
+);
+
+CREATE INDEX IF NOT EXISTS idx_repo_ownership_repo ON repository_ownership(repository);
diff --git a/internal/server/notification_daily_summary_test.go b/internal/server/notification_daily_summary_test.go
new file mode 100644
index 0000000..ce903b2
--- /dev/null
+++ b/internal/server/notification_daily_summary_test.go
@@ -0,0 +1,101 @@
+package server
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/sgbudje/runright/internal/types"
+)
+
+func TestDispatchDailySummaries_SendsOncePerDayPerRuleScope(t *testing.T) {
+ s := newPolicyTestServer(t)
+ resetNotificationState(t, s)
+
+ var (
+ mu sync.Mutex
+ count int
+ )
+ hook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = io.ReadAll(r.Body)
+ _ = r.Body.Close()
+ mu.Lock()
+ count++
+ mu.Unlock()
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer hook.Close()
+
+ payload := validNotificationPayload()
+ events := payload["events"].(map[string]any)
+ events["daily_summary"] = true
+
+ slack := payload["slack"].(map[string]any)
+ dests := slack["destinations"].([]map[string]any)
+ dests[0]["webhook_url"] = hook.URL
+
+ payload["rules"] = []map[string]any{
+ {
+ "id": "rule-daily-global",
+ "name": "Daily global summary",
+ "type": "event",
+ "event": "daily_summary",
+ "scope": "global",
+ "repository": "",
+ "jobId": "",
+ "metric": "max_cost_per_hour",
+ "threshold": 0,
+ "destinationIds": []string{"dest-primary"},
+ "enabled": true,
+ },
+ }
+
+ if code := doJSON(t, s, http.MethodPut, "/api/v1/notifications/settings", payload, nil); code != http.StatusOK {
+ t.Fatalf("upsert notification settings code = %d", code)
+ }
+
+ now := time.Now().UTC()
+ summary := types.MetricsSummary{
+ JobID: "build-daily",
+ Repository: "owner/repo-daily",
+ Status: "completed",
+ StartTime: now.Add(-2 * time.Minute),
+ EndTime: now,
+ CPUPercentP95: 34.5,
+ MemUsedGiBP95: 1.8,
+ MemTotalGiB: 8,
+ }
+ recs := []types.Recommendation{{
+ CostDeltaPercent: -15,
+ CurrentMonthly: 90,
+ EstimatedMonthly: 76,
+ }}
+
+ // Use tomorrow's 09:00 as dispatch time so the 24h window [today 09:00, tomorrow 09:00]
+ // is fully determined. Insert the job at dispatchAt-1h to guarantee it falls in window.
+ dispatchAt := time.Date(now.Year(), now.Month(), now.Day()+1, 9, 0, 0, 0, time.UTC)
+ jobTime := dispatchAt.Add(-1 * time.Hour)
+
+ summaryJSON, _ := json.Marshal(summary)
+ recsJSON, _ := json.Marshal(recs)
+ if _, err := s.db.Exec(`
+ INSERT INTO jobs (job_id, repository, start_time, end_time, duration_seconds, summary, recommendations, status, created_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, 'completed', $8)`,
+ summary.JobID, summary.Repository, summary.StartTime, summary.EndTime, 120.0, summaryJSON, recsJSON, jobTime); err != nil {
+ t.Fatalf("insert job: %v", err)
+ }
+
+ s.dispatchDailySummaries(dispatchAt)
+ // Second call same day should dedupe.
+ s.dispatchDailySummaries(dispatchAt.Add(20 * time.Minute))
+
+ mu.Lock()
+ defer mu.Unlock()
+ if count != 1 {
+ t.Fatalf("expected exactly 1 daily summary webhook for same day, got %d", count)
+ }
+}
diff --git a/internal/server/notification_dispatch_test.go b/internal/server/notification_dispatch_test.go
new file mode 100644
index 0000000..f4643ef
--- /dev/null
+++ b/internal/server/notification_dispatch_test.go
@@ -0,0 +1,156 @@
+package server
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/sgbudje/runright/internal/types"
+)
+
+func TestDispatchNotificationRules_SendsThresholdAlertToConfiguredDestination(t *testing.T) {
+ s := newPolicyTestServer(t)
+ resetNotificationState(t, s)
+
+ var (
+ mu sync.Mutex
+ count int
+ payload string
+ )
+ hook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ b, _ := io.ReadAll(r.Body)
+ _ = r.Body.Close()
+ mu.Lock()
+ count++
+ payload = string(b)
+ mu.Unlock()
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer hook.Close()
+
+ p := validNotificationPayload()
+ slack := p["slack"].(map[string]any)
+ dests := slack["destinations"].([]map[string]any)
+ dests[0]["webhook_url"] = hook.URL
+ rules := p["rules"].([]map[string]any)
+ rules[0]["metric"] = "max_cost_per_hour"
+ rules[0]["threshold"] = 0.5
+ rules[0]["scope"] = "global"
+
+ if code := doJSON(t, s, http.MethodPut, "/api/v1/notifications/settings", p, nil); code != http.StatusOK {
+ t.Fatalf("upsert notification settings code = %d", code)
+ }
+
+ summary := types.MetricsSummary{
+ JobID: "build",
+ Repository: "owner/repo-a",
+ Status: "completed",
+ StartTime: time.Now().Add(-2 * time.Minute),
+ EndTime: time.Now(),
+ DetectedMachine: &types.MachineType{
+ ID: "c5.xlarge",
+ OnDemandPricePerHour: 0.8,
+ },
+ CPUPercentP95: 15,
+ MemUsedGiBP95: 1.2,
+ MemTotalGiB: 8,
+ }
+ recs := []types.Recommendation{{
+ CostDeltaPercent: -40,
+ CurrentMonthly: 120,
+ EstimatedMonthly: 72,
+ }}
+
+ s.dispatchNotificationRules(summary, recs)
+
+ mu.Lock()
+ defer mu.Unlock()
+ if count != 1 {
+ t.Fatalf("expected 1 webhook call, got %d", count)
+ }
+ if payload == "" {
+ t.Fatalf("expected webhook payload body to be non-empty")
+ }
+}
+
+func TestDispatchNotificationRules_SendsPolicyViolationEventAlert(t *testing.T) {
+ s := newPolicyTestServer(t)
+ resetNotificationState(t, s)
+
+ var (
+ mu sync.Mutex
+ count int
+ )
+ hook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ mu.Lock()
+ count++
+ mu.Unlock()
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer hook.Close()
+
+ repo := "owner/repo-policy"
+ jobID := "build"
+ if _, err := s.db.Exec(`
+ INSERT INTO policy_rules (repository, job_id, max_cost_per_hour, enabled, updated_at)
+ VALUES ($1, $2, 0.25, true, NOW())`, repo, jobID); err != nil {
+ t.Fatalf("insert policy rule: %v", err)
+ }
+ defer func() {
+ _, _ = s.db.Exec(`DELETE FROM policy_rules WHERE repository = $1 AND job_id = $2`, repo, jobID)
+ }()
+
+ p := validNotificationPayload()
+ slack := p["slack"].(map[string]any)
+ dests := slack["destinations"].([]map[string]any)
+ dests[0]["webhook_url"] = hook.URL
+
+ events := p["events"].(map[string]any)
+ events["policy_violation"] = true
+ rules := []map[string]any{
+ {
+ "id": "rule-policy-event",
+ "name": "Policy violation event",
+ "type": "event",
+ "event": "policy_violation",
+ "scope": "job",
+ "repository": repo,
+ "jobId": jobID,
+ "metric": "max_cost_per_hour",
+ "threshold": 0,
+ "destinationIds": []string{"dest-primary"},
+ "enabled": true,
+ },
+ }
+ p["rules"] = rules
+
+ if code := doJSON(t, s, http.MethodPut, "/api/v1/notifications/settings", p, nil); code != http.StatusOK {
+ t.Fatalf("upsert notification settings code = %d", code)
+ }
+
+ summary := types.MetricsSummary{
+ JobID: jobID,
+ Repository: repo,
+ Status: "completed",
+ StartTime: time.Now().Add(-3 * time.Minute),
+ EndTime: time.Now(),
+ DetectedMachine: &types.MachineType{
+ ID: "c5.xlarge",
+ OnDemandPricePerHour: 0.40,
+ },
+ CPUPercentP95: 30,
+ MemUsedGiBP95: 2,
+ MemTotalGiB: 8,
+ }
+
+ s.dispatchNotificationRules(summary, nil)
+
+ mu.Lock()
+ defer mu.Unlock()
+ if count != 1 {
+ t.Fatalf("expected 1 webhook call, got %d", count)
+ }
+}
diff --git a/internal/server/notification_validation_test.go b/internal/server/notification_validation_test.go
new file mode 100644
index 0000000..12c1cfb
--- /dev/null
+++ b/internal/server/notification_validation_test.go
@@ -0,0 +1,172 @@
+package server
+
+import (
+ "net/http"
+ "testing"
+)
+
+func validNotificationPayload() map[string]any {
+ return map[string]any{
+ "enabled": true,
+ "events": map[string]any{
+ "policy_violation": true,
+ "high_waste": false,
+ "daily_summary": true,
+ },
+ "slack": map[string]any{
+ "enabled": true,
+ "destinations": []map[string]any{
+ {
+ "id": "dest-primary",
+ "name": "Primary",
+ "webhook_url": "https://hooks.slack.com/services/T000/B000/XXXX",
+ "channel": "#alerts",
+ "mention": "",
+ },
+ },
+ },
+ "rules": []map[string]any{
+ {
+ "id": "rule-threshold",
+ "name": "High cost",
+ "type": "threshold",
+ "scope": "global",
+ "repository": "",
+ "jobId": "",
+ "metric": "max_cost_per_hour",
+ "threshold": 0.5,
+ "destinationIds": []string{"dest-primary"},
+ "enabled": true,
+ },
+ },
+ "email": map[string]any{
+ "enabled": false,
+ "recipients": []string{},
+ "subject_prefix": "[RunRight]",
+ },
+ }
+}
+
+func resetNotificationState(t *testing.T, s *Server) {
+ t.Helper()
+ if _, err := s.db.Exec(`DELETE FROM notification_settings`); err != nil {
+ t.Fatalf("reset notification_settings: %v", err)
+ }
+ if _, err := s.db.Exec(`DELETE FROM notification_destination_secrets`); err != nil {
+ t.Fatalf("reset notification_destination_secrets: %v", err)
+ }
+ if _, err := s.db.Exec(`DELETE FROM notification_daily_summary_dispatches`); err != nil {
+ t.Fatalf("reset notification_daily_summary_dispatches: %v", err)
+ }
+}
+
+func TestNotificationRuleValidationAcceptsValidPayload(t *testing.T) {
+ s := newPolicyTestServer(t)
+ resetNotificationState(t, s)
+
+ code := doJSON(t, s, http.MethodPut, "/api/v1/notifications/settings", validNotificationPayload(), nil)
+ if code != http.StatusOK {
+ t.Fatalf("expected 200 for valid payload, got %d", code)
+ }
+}
+
+func TestNotificationRuleValidationRejectsMalformedRules(t *testing.T) {
+ s := newPolicyTestServer(t)
+
+ tests := []struct {
+ name string
+ mutate func(payload map[string]any)
+ }{
+ {
+ name: "unknown destination reference",
+ mutate: func(payload map[string]any) {
+ rules := payload["rules"].([]map[string]any)
+ rules[0]["destinationIds"] = []string{"does-not-exist"}
+ },
+ },
+ {
+ name: "repository scope without repository",
+ mutate: func(payload map[string]any) {
+ rules := payload["rules"].([]map[string]any)
+ rules[0]["scope"] = "repository"
+ },
+ },
+ {
+ name: "job scope without job id",
+ mutate: func(payload map[string]any) {
+ rules := payload["rules"].([]map[string]any)
+ rules[0]["scope"] = "job"
+ rules[0]["repository"] = "owner/repo"
+ rules[0]["jobId"] = ""
+ },
+ },
+ {
+ name: "threshold rule with unsupported metric",
+ mutate: func(payload map[string]any) {
+ rules := payload["rules"].([]map[string]any)
+ rules[0]["metric"] = "unknown_metric"
+ },
+ },
+ {
+ name: "threshold rule with non-positive threshold",
+ mutate: func(payload map[string]any) {
+ rules := payload["rules"].([]map[string]any)
+ rules[0]["threshold"] = 0
+ },
+ },
+ {
+ name: "event rule with unsupported event",
+ mutate: func(payload map[string]any) {
+ rules := []map[string]any{
+ {
+ "id": "rule-event",
+ "name": "Bad event",
+ "type": "event",
+ "event": "not_real",
+ "scope": "global",
+ "repository": "",
+ "jobId": "",
+ "metric": "max_cost_per_hour",
+ "threshold": 0,
+ "destinationIds": []string{"dest-primary"},
+ "enabled": true,
+ },
+ }
+ payload["rules"] = rules
+ },
+ },
+ {
+ name: "event rule with non-zero threshold",
+ mutate: func(payload map[string]any) {
+ rules := []map[string]any{
+ {
+ "id": "rule-event",
+ "name": "Bad threshold",
+ "type": "event",
+ "event": "policy_violation",
+ "scope": "global",
+ "repository": "",
+ "jobId": "",
+ "metric": "max_cost_per_hour",
+ "threshold": 0.1,
+ "destinationIds": []string{"dest-primary"},
+ "enabled": true,
+ },
+ }
+ payload["rules"] = rules
+ },
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ resetNotificationState(t, s)
+ payload := validNotificationPayload()
+ tc.mutate(payload)
+ code := doJSON(t, s, http.MethodPut, "/api/v1/notifications/settings", payload, nil)
+ if code != http.StatusBadRequest {
+ t.Fatalf("expected 400 for malformed rule payload, got %d", code)
+ }
+ })
+ }
+}
diff --git a/internal/server/notifier.go b/internal/server/notifier.go
new file mode 100644
index 0000000..9a34c5f
--- /dev/null
+++ b/internal/server/notifier.go
@@ -0,0 +1,392 @@
+package server
+
+// notifier.go bridges the notification package to server.go's DB and
+// settings storage. All notification dispatch goes through here.
+//
+// Adding a new channel type in the future:
+// 1. Add an adapter in internal/notification/adapters.go implementing Channel.
+// 2. Add a settings struct + DB secret handling in server.go (same as Teams).
+// 3. Add a case in buildNotificationDispatcher below.
+// 4. That's it — Dispatcher handles everything else.
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "strings"
+ "time"
+
+ "github.com/sgbudje/runright/internal/notification"
+ "github.com/sgbudje/runright/internal/types"
+)
+
+// dailySummaryStats holds aggregated stats for one daily summary window.
+type dailySummaryStats struct {
+ Runs int
+ AvgCPUP95 float64
+ AvgMemGiBP95 float64
+ AvgDurationSec float64
+ SavingsRunCount int
+}
+
+// dailySummaryLoop wakes up every hour and triggers daily summary dispatch at
+// exactly 09:00 UTC.
+func (s *Server) dailySummaryLoop() {
+ for {
+ now := time.Now().UTC()
+ next := now.Truncate(time.Hour).Add(time.Hour)
+ time.Sleep(time.Until(next))
+ s.dispatchDailySummaries(next)
+ }
+}
+
+// buildNotificationDispatcher constructs a notification.Dispatcher from the// current settings and the secret store. The Dispatcher is immutable per call;
+// callers should not cache it across requests.
+func (s *Server) buildNotificationDispatcher(ctx context.Context) (*notification.Dispatcher, []notification.Rule, notification.GlobalEvents, error) {
+ settings, secrets, err := s.loadNotificationSettingsForDispatch(ctx)
+ if err != nil {
+ return nil, nil, notification.GlobalEvents{}, err
+ }
+
+ // Build channel-agnostic destination index.
+ destinations := make(map[string]notification.Destination)
+
+ for _, d := range settings.Slack.Destinations {
+ url := strings.TrimSpace(secrets[d.ID])
+ if url == "" {
+ continue
+ }
+ destinations[d.ID] = notification.Destination{
+ ID: d.ID,
+ Name: d.Name,
+ Channel: ¬ification.SlackChannel{
+ WebhookURL: url,
+ Mention: d.Mention,
+ },
+ }
+ }
+ for _, d := range settings.Teams.Destinations {
+ url := strings.TrimSpace(secrets[d.ID])
+ if url == "" {
+ continue
+ }
+ destinations[d.ID] = notification.Destination{
+ ID: d.ID,
+ Name: d.Name,
+ Channel: ¬ification.TeamsChannel{WebhookURL: url},
+ }
+ }
+ for _, d := range settings.Webhooks.Destinations {
+ url := strings.TrimSpace(secrets[d.ID])
+ if url == "" {
+ continue
+ }
+ destinations[d.ID] = notification.Destination{
+ ID: d.ID,
+ Name: d.Name,
+ Channel: ¬ification.WebhookChannel{URL: url, Headers: d.Headers},
+ }
+ }
+
+ // Convert server-side rule structs to notification.Rule.
+ rules := make([]notification.Rule, 0, len(settings.Rules))
+ for _, r := range settings.Rules {
+ rules = append(rules, notification.Rule{
+ ID: r.ID,
+ Name: r.Name,
+ Type: notification.RuleType(r.Type),
+ Event: notification.EventKind(r.Event),
+ Scope: notification.RuleScope(r.Scope),
+ Repository: r.Repository,
+ JobID: r.JobID,
+ Metric: r.Metric,
+ Threshold: r.Threshold,
+ DestinationIDs: r.DestinationIDs,
+ Enabled: r.Enabled,
+ })
+ }
+
+ globalEvents := notification.GlobalEvents{
+ PolicyViolation: settings.Events.PolicyViolation,
+ HighWaste: settings.Events.HighWaste,
+ DailySummary: settings.Events.DailySummary,
+ }
+
+ dispatcher := ¬ification.Dispatcher{
+ Destinations: destinations,
+ OnDelivery: func(result notification.DeliveryResult) {
+ s.writeDeliveryLog(context.Background(), result, "", "")
+ },
+ }
+
+ return dispatcher, rules, globalEvents, nil
+}
+
+// dispatchNotificationRules is called after a job completes. It evaluates all
+// enabled rules and routes matching events to their destinations.
+func (s *Server) dispatchNotificationRules(summary types.MetricsSummary, recs []types.Recommendation) {
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
+ defer cancel()
+
+ settings, secrets, err := s.loadNotificationSettingsForDispatch(ctx)
+ if err != nil || !settings.Enabled {
+ return
+ }
+
+ dispatcher, rules, globalEvents, err := s.buildNotificationDispatcher(ctx)
+ if err != nil {
+ return
+ }
+ _ = secrets // consumed inside buildNotificationDispatcher
+
+ // Augment rules with ownership-based destinations for this repository.
+ ownershipDestIDs, _ := s.loadOwnershipDestinations(ctx, summary.Repository)
+ rules = mergeOwnershipIntoRules(rules, ownershipDestIDs)
+
+ // Wire delivery log with retry scheduling.
+ dispatcher.OnDelivery = func(result notification.DeliveryResult) {
+ s.writeDeliveryLog(ctx, result, summary.JobID, summary.Repository)
+ }
+
+ metrics := s.buildRuntimeMetrics(ctx, summary, recs)
+ dispatcher.Dispatch(ctx, rules, globalEvents, metrics)
+}
+
+// dispatchDailySummaries runs at 09:00 UTC and dispatches any daily summary
+// rules that haven't been sent today, scoped by the rule's configured scope.
+func (s *Server) dispatchDailySummaries(now time.Time) {
+ now = now.UTC()
+ if now.Hour() != 9 {
+ return
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ settings, _, err := s.loadNotificationSettingsForDispatch(ctx)
+ if err != nil || !settings.Enabled || !settings.Events.DailySummary {
+ return
+ }
+
+ dispatcher, rules, globalEvents, err := s.buildNotificationDispatcher(ctx)
+ if err != nil {
+ return
+ }
+
+ day := now.Format("2006-01-02")
+
+ for _, rule := range rules {
+ if !rule.Enabled || rule.Type != notification.RuleTypeEvent || rule.Event != notification.EventDailySummary {
+ continue
+ }
+ scopeKey := notificationScopeKey(rule)
+ alreadySent, err := s.dailySummaryAlreadySent(ctx, rule.ID, scopeKey, day)
+ if err != nil || alreadySent {
+ continue
+ }
+
+ stats, err := s.computeDailySummaryStats(ctx, rule, now)
+ if err != nil || stats.Runs == 0 {
+ continue
+ }
+
+ notifStats := notification.DailySummaryStats{
+ Runs: stats.Runs,
+ AvgCPUP95: stats.AvgCPUP95,
+ AvgMemGiBP95: stats.AvgMemGiBP95,
+ AvgDurationSec: stats.AvgDurationSec,
+ SavingsRunCount: stats.SavingsRunCount,
+ }
+
+ var delivered int
+ origOnDelivery := dispatcher.OnDelivery
+ dispatcher.OnDelivery = func(result notification.DeliveryResult) {
+ if origOnDelivery != nil {
+ origOnDelivery(result)
+ }
+ if result.Status == "delivered" {
+ delivered++
+ }
+ }
+
+ dispatcher.DispatchDailySummary(ctx, []notification.Rule{rule}, globalEvents, notifStats,
+ rule.Scope, rule.Repository, rule.JobID, now)
+
+ if delivered > 0 {
+ _ = s.markDailySummarySent(ctx, rule.ID, scopeKey, day)
+ }
+ }
+}
+
+// buildRuntimeMetrics computes the runtime metrics for a completed job.
+func (s *Server) buildRuntimeMetrics(ctx context.Context, summary types.MetricsSummary, recs []types.Recommendation) notification.RuntimeMetrics {
+ detectedPrice := float64(0)
+ if summary.DetectedMachine != nil {
+ detectedPrice = summary.DetectedMachine.OnDemandPricePerHour
+ }
+
+ cpuUtil := summary.CPUPercentP95
+ memUtil := 0.0
+ if summary.MemTotalGiB > 0 {
+ memUtil = (summary.MemUsedGiBP95 / summary.MemTotalGiB) * 100
+ }
+ util := cpuUtil
+ if memUtil > util {
+ util = memUtil
+ }
+ if util < 0 {
+ util = 0
+ }
+ if util > 100 {
+ util = 100
+ }
+ wastePercent := 100 - util
+
+ metrics := notification.RuntimeMetrics{
+ Repository: summary.Repository,
+ JobID: summary.JobID,
+ DetectedPricePerHour: detectedPrice,
+ WastePercent: wastePercent,
+ MonthlySavingsDropPercent: s.computeSavingsDropPercent(ctx, summary.JobID, summary.Repository, recs),
+ }
+
+ if detectedPrice > 0 {
+ policyEval := s.evaluateEffectivePolicy(ctx, summary.Repository, summary.JobID, detectedPrice)
+ metrics.PolicyViolated = policyEval.Violated
+ metrics.PolicyScope = policyEval.SourceScope
+ }
+
+ return metrics
+}
+
+// writeDeliveryLog writes one delivery attempt to the DB (no retry scheduling).
+// Use writeDeliveryLogWithRetry for dispatch paths that should retry on failure.
+func (s *Server) writeDeliveryLog(ctx context.Context, result notification.DeliveryResult, jobID, repository string) {
+ _, _ = s.db.ExecContext(ctx, `
+ INSERT INTO notification_delivery_logs
+ (rule_id, destination_id, channel, job_id, repository, status, error_message, attempts, max_attempts, sent_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, 1, 4, NOW())`,
+ "", result.DestinationID, result.Channel, jobID, repository, result.Status, result.Error)
+}
+
+// notificationScopeKey produces a stable dedupe key for a rule.
+func notificationScopeKey(rule notification.Rule) string {
+ switch rule.Scope {
+ case notification.ScopeJob:
+ return "job:" + rule.Repository + ":" + rule.JobID
+ case notification.ScopeRepository:
+ return "repository:" + rule.Repository
+ default:
+ return "global"
+ }
+}
+
+// computeSavingsDropPercent computes the % drop in monthly savings vs the
+// previous completed run for the same job. Returns 0 if no regression.
+func (s *Server) computeSavingsDropPercent(ctx context.Context, jobID, repository string, recs []types.Recommendation) float64 {
+ currentSavings := monthlySavingsFromRecommendations(recs)
+
+ rows, err := s.db.QueryContext(ctx, `
+ SELECT recommendations FROM jobs
+ WHERE job_id = $1 AND COALESCE(repository, '') = $2 AND status = 'completed'
+ ORDER BY created_at DESC
+ LIMIT 2`, jobID, repository)
+ if err != nil {
+ return 0
+ }
+ defer rows.Close()
+
+ index := 0
+ previousSavings := 0.0
+ for rows.Next() {
+ var recsJSON json.RawMessage
+ if err := rows.Scan(&recsJSON); err != nil {
+ continue
+ }
+ if index == 1 {
+ var prev []types.Recommendation
+ if err := json.Unmarshal(recsJSON, &prev); err == nil {
+ previousSavings = monthlySavingsFromRecommendations(prev)
+ }
+ break
+ }
+ index++
+ }
+ if previousSavings <= 0 || currentSavings >= previousSavings {
+ return 0
+ }
+ return ((previousSavings - currentSavings) / previousSavings) * 100
+}
+
+func monthlySavingsFromRecommendations(recs []types.Recommendation) float64 {
+ best := 0.0
+ for _, rec := range recs {
+ if rec.CostDeltaPercent < -0.5 {
+ if saving := rec.CurrentMonthly - rec.EstimatedMonthly; saving > best {
+ best = saving
+ }
+ }
+ }
+ return best
+}
+
+// computeDailySummaryStats runs a DB query to aggregate job metrics in a 24h
+// window relative to now.
+func (s *Server) computeDailySummaryStats(ctx context.Context, rule notification.Rule, now time.Time) (dailySummaryStats, error) {
+ windowStart := now.Add(-24 * time.Hour)
+ var rows *sql.Rows
+ var err error
+ switch rule.Scope {
+ case notification.ScopeJob:
+ rows, err = s.db.QueryContext(ctx, `
+ SELECT summary, recommendations, duration_seconds FROM jobs
+ WHERE status='completed' AND created_at>=$1 AND created_at<=$2
+ AND COALESCE(repository,'')=$3 AND job_id=$4`,
+ windowStart, now, rule.Repository, rule.JobID)
+ case notification.ScopeRepository:
+ rows, err = s.db.QueryContext(ctx, `
+ SELECT summary, recommendations, duration_seconds FROM jobs
+ WHERE status='completed' AND created_at>=$1 AND created_at<=$2
+ AND COALESCE(repository,'')=$3`,
+ windowStart, now, rule.Repository)
+ default:
+ rows, err = s.db.QueryContext(ctx, `
+ SELECT summary, recommendations, duration_seconds FROM jobs
+ WHERE status='completed' AND created_at>=$1 AND created_at<=$2`,
+ windowStart, now)
+ }
+ if err != nil {
+ return dailySummaryStats{}, err
+ }
+ defer rows.Close()
+
+ stats := dailySummaryStats{}
+ for rows.Next() {
+ var summaryJSON, recsJSON json.RawMessage
+ var dur float64
+ if err := rows.Scan(&summaryJSON, &recsJSON, &dur); err != nil {
+ continue
+ }
+ var sm types.MetricsSummary
+ if err := json.Unmarshal(summaryJSON, &sm); err == nil {
+ stats.AvgCPUP95 += sm.CPUPercentP95
+ stats.AvgMemGiBP95 += sm.MemUsedGiBP95
+ }
+ stats.AvgDurationSec += dur
+ var recs []types.Recommendation
+ if err := json.Unmarshal(recsJSON, &recs); err == nil && monthlySavingsFromRecommendations(recs) > 0 {
+ stats.SavingsRunCount++
+ }
+ stats.Runs++
+ }
+ if rows.Err() != nil {
+ return dailySummaryStats{}, rows.Err()
+ }
+ if stats.Runs > 0 {
+ d := float64(stats.Runs)
+ stats.AvgCPUP95 /= d
+ stats.AvgMemGiBP95 /= d
+ stats.AvgDurationSec /= d
+ }
+ return stats, nil
+}
diff --git a/internal/server/ownership.go b/internal/server/ownership.go
new file mode 100644
index 0000000..600bc34
--- /dev/null
+++ b/internal/server/ownership.go
@@ -0,0 +1,179 @@
+package server
+
+// ownership.go — repository → team → destination routing.
+//
+// Ownership rules let you declare "team X owns repo Y and should receive
+// alerts on destinations A, B" without manually adding that repo to every
+// alert rule. The dispatcher automatically augments rule destination lists
+// with any ownership-based destinations that match the job's repository.
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sgbudje/runright/internal/notification"
+)
+
+// ownershipEntry represents one team's ownership of a repository.
+type ownershipEntry struct {
+ Repository string `json:"repository"`
+ TeamName string `json:"team_name"`
+ DestinationIDs []string `json:"destination_ids"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+// listOwnership returns all ownership entries, optionally filtered by repo.
+func (s *Server) listOwnership(c *gin.Context) {
+ repo := c.Query("repository")
+ var (
+ rows interface{ Next() bool; Scan(...any) error; Close() error; Err() error }
+ err error
+ )
+ if repo != "" {
+ rows, err = s.db.QueryContext(c.Request.Context(), `
+ SELECT repository, team_name, destination_ids, created_at, updated_at
+ FROM repository_ownership
+ WHERE repository = $1
+ ORDER BY team_name ASC`, repo)
+ } else {
+ rows, err = s.db.QueryContext(c.Request.Context(), `
+ SELECT repository, team_name, destination_ids, created_at, updated_at
+ FROM repository_ownership
+ ORDER BY repository ASC, team_name ASC`)
+ }
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ defer rows.Close()
+
+ out := []ownershipEntry{}
+ for rows.Next() {
+ var entry ownershipEntry
+ var destJSON []byte
+ if err := rows.Scan(&entry.Repository, &entry.TeamName, &destJSON, &entry.CreatedAt, &entry.UpdatedAt); err != nil {
+ continue
+ }
+ if err := json.Unmarshal(destJSON, &entry.DestinationIDs); err != nil {
+ entry.DestinationIDs = []string{}
+ }
+ out = append(out, entry)
+ }
+ c.JSON(http.StatusOK, out)
+}
+
+// upsertOwnership creates or replaces one ownership entry.
+func (s *Server) upsertOwnership(c *gin.Context) {
+ var body struct {
+ Repository string `json:"repository" binding:"required"`
+ TeamName string `json:"team_name" binding:"required"`
+ DestinationIDs []string `json:"destination_ids"`
+ }
+ if err := c.ShouldBindJSON(&body); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ if body.DestinationIDs == nil {
+ body.DestinationIDs = []string{}
+ }
+ destJSON, _ := json.Marshal(body.DestinationIDs)
+
+ _, err := s.db.ExecContext(c.Request.Context(), `
+ INSERT INTO repository_ownership (repository, team_name, destination_ids, updated_at)
+ VALUES ($1, $2, $3, NOW())
+ ON CONFLICT (repository, team_name) DO UPDATE SET
+ destination_ids = EXCLUDED.destination_ids,
+ updated_at = NOW()`,
+ body.Repository, body.TeamName, destJSON)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"status": "ok"})
+}
+
+// deleteOwnership removes one ownership entry.
+func (s *Server) deleteOwnership(c *gin.Context) {
+ repo := c.Query("repository")
+ team := c.Query("team_name")
+ if repo == "" || team == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "repository and team_name are required"})
+ return
+ }
+ _, err := s.db.ExecContext(c.Request.Context(), `
+ DELETE FROM repository_ownership WHERE repository = $1 AND team_name = $2`,
+ repo, team)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"status": "ok"})
+}
+
+// loadOwnershipDestinations returns the union of all destination IDs that
+// ownership rules assign to a given repository. Returns nil if none.
+func (s *Server) loadOwnershipDestinations(ctx context.Context, repository string) ([]string, error) {
+ if repository == "" {
+ return nil, nil
+ }
+ rows, err := s.db.QueryContext(ctx, `
+ SELECT destination_ids FROM repository_ownership WHERE repository = $1`, repository)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ seen := map[string]struct{}{}
+ var result []string
+ for rows.Next() {
+ var raw []byte
+ if err := rows.Scan(&raw); err != nil {
+ continue
+ }
+ var ids []string
+ if err := json.Unmarshal(raw, &ids); err != nil {
+ continue
+ }
+ for _, id := range ids {
+ if _, dup := seen[id]; !dup {
+ seen[id] = struct{}{}
+ result = append(result, id)
+ }
+ }
+ }
+ return result, rows.Err()
+}
+
+// mergeOwnershipIntoRules augments each rule's DestinationIDs with any
+// ownership-based destination IDs for the job's repository. Only adds IDs
+// that aren't already present; does not remove existing IDs.
+func mergeOwnershipIntoRules(rules []notification.Rule, ownershipDestIDs []string) []notification.Rule {
+ if len(ownershipDestIDs) == 0 {
+ return rules
+ }
+ merged := make([]notification.Rule, len(rules))
+ for i, rule := range rules {
+ existing := map[string]struct{}{}
+ for _, id := range rule.DestinationIDs {
+ existing[id] = struct{}{}
+ }
+ var extra []string
+ for _, id := range ownershipDestIDs {
+ if _, ok := existing[id]; !ok {
+ extra = append(extra, id)
+ }
+ }
+ if len(extra) == 0 {
+ merged[i] = rule
+ continue
+ }
+ cp := rule
+ cp.DestinationIDs = append(append([]string{}, rule.DestinationIDs...), extra...)
+ merged[i] = cp
+ }
+ return merged
+}
diff --git a/internal/server/policy_test.go b/internal/server/policy_test.go
new file mode 100644
index 0000000..f02c5b3
--- /dev/null
+++ b/internal/server/policy_test.go
@@ -0,0 +1,140 @@
+package server
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "testing"
+ "time"
+)
+
+func newPolicyTestServer(t *testing.T) *Server {
+ t.Helper()
+ dsn := os.Getenv("DATABASE_URL")
+ if dsn == "" {
+ dsn = "postgres://runright:runright@localhost:5435/runright?sslmode=disable"
+ }
+ s, err := New(Config{DSN: dsn, APIKey: ""})
+ if err != nil {
+ t.Fatalf("new server: %v", err)
+ }
+ return s
+}
+
+func doJSON(t *testing.T, s *Server, method, path string, body any, out any) int {
+ t.Helper()
+ var reqBody *bytes.Buffer
+ if body != nil {
+ b, err := json.Marshal(body)
+ if err != nil {
+ t.Fatalf("marshal body: %v", err)
+ }
+ reqBody = bytes.NewBuffer(b)
+ } else {
+ reqBody = bytes.NewBuffer(nil)
+ }
+ req := httptest.NewRequest(method, path, reqBody)
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ s.router.ServeHTTP(rec, req)
+ if out != nil {
+ if err := json.NewDecoder(rec.Body).Decode(out); err != nil {
+ t.Fatalf("decode response: %v; body=%s", err, rec.Body.String())
+ }
+ }
+ return rec.Code
+}
+
+func TestPolicyCRUDAndEvaluation(t *testing.T) {
+ s := newPolicyTestServer(t)
+ repo := "owner/repo-" + time.Now().Format("150405.000000000")
+ job := "build"
+
+ t.Cleanup(func() {
+ _, _ = s.db.Exec(`DELETE FROM policy_rules WHERE repository = $1`, repo)
+ _, _ = s.db.Exec(`DELETE FROM policy_rules WHERE repository = '' AND job_id = ''`)
+ })
+
+ code := doJSON(t, s, http.MethodPut, "/api/v1/policies", map[string]any{
+ "repository": repo,
+ "job_id": "",
+ "max_cost_per_hour": 0.5,
+ "enabled": true,
+ }, nil)
+ if code != http.StatusOK {
+ t.Fatalf("repo policy upsert code = %d", code)
+ }
+
+ code = doJSON(t, s, http.MethodPut, "/api/v1/policies", map[string]any{
+ "repository": repo,
+ "job_id": job,
+ "max_cost_per_hour": 0.25,
+ "enabled": true,
+ }, nil)
+ if code != http.StatusOK {
+ t.Fatalf("job policy upsert code = %d", code)
+ }
+
+ var policies []policyRule
+ code = doJSON(t, s, http.MethodGet, "/api/v1/policies?repository="+repo, nil, &policies)
+ if code != http.StatusOK {
+ t.Fatalf("list policies code = %d", code)
+ }
+ if len(policies) != 2 {
+ t.Fatalf("expected 2 policies, got %d", len(policies))
+ }
+
+ var eval policyEvaluation
+ code = doJSON(t, s, http.MethodPost, "/api/v1/policies/evaluate", map[string]any{
+ "repository": repo,
+ "job_id": job,
+ "detected_price_per_hour": 0.30,
+ }, &eval)
+ if code != http.StatusOK {
+ t.Fatalf("evaluate code = %d", code)
+ }
+ if !eval.Violated {
+ t.Fatalf("expected violation, got false")
+ }
+ if eval.SourceScope != "job" {
+ t.Fatalf("expected job scope, got %q", eval.SourceScope)
+ }
+ if eval.EffectiveMaxCostPerHour != 0.25 {
+ t.Fatalf("expected 0.25 max, got %v", eval.EffectiveMaxCostPerHour)
+ }
+}
+
+func TestPolicyPrecedenceFallsBackToRepositoryThenGlobal(t *testing.T) {
+ s := newPolicyTestServer(t)
+ repo := "owner/repo-" + time.Now().Add(time.Second).Format("150405.000000000")
+ job := "lint"
+
+ t.Cleanup(func() {
+ _, _ = s.db.Exec(`DELETE FROM policy_rules WHERE repository = $1`, repo)
+ _, _ = s.db.Exec(`DELETE FROM policy_rules WHERE repository = '' AND job_id = ''`)
+ })
+
+ _, _ = s.db.Exec(`INSERT INTO policy_rules (repository, job_id, max_cost_per_hour, enabled, updated_at) VALUES ('', '', 0.75, true, NOW())`)
+ _, _ = s.db.Exec(`INSERT INTO policy_rules (repository, job_id, max_cost_per_hour, enabled, updated_at) VALUES ($1, '', 0.50, true, NOW())`, repo)
+
+ var eval policyEvaluation
+ code := doJSON(t, s, http.MethodPost, "/api/v1/policies/evaluate", map[string]any{
+ "repository": repo,
+ "job_id": job,
+ "detected_price_per_hour": 0.60,
+ }, &eval)
+ if code != http.StatusOK {
+ t.Fatalf("evaluate code = %d", code)
+ }
+ if eval.SourceScope != "repository" {
+ t.Fatalf("expected repository scope, got %q", eval.SourceScope)
+ }
+ if eval.EffectiveMaxCostPerHour != 0.50 {
+ t.Fatalf("expected repo max 0.50, got %v", eval.EffectiveMaxCostPerHour)
+ }
+ if !eval.Violated {
+ t.Fatalf("expected violation against repo policy")
+ }
+}
\ No newline at end of file
diff --git a/internal/server/retry.go b/internal/server/retry.go
new file mode 100644
index 0000000..ac44436
--- /dev/null
+++ b/internal/server/retry.go
@@ -0,0 +1,188 @@
+package server
+
+// retry.go — Postgres-backed retry queue for failed notification deliveries.
+//
+// How it works:
+// 1. writeDeliveryLog records every attempt. For failures it also sets
+// next_retry_at using exponential backoff and increments attempts.
+// 2. retryLoop polls every 30 s for failed rows that are due for retry.
+// 3. Each retry re-loads the destination secret (so rotated secrets work),
+// replays the stored message payload, and updates the log row.
+// 4. Rows that reach max_attempts are marked "permanently_failed" and
+// next_retry_at is cleared — they act as the dead letter.
+
+import (
+ "context"
+ "encoding/json"
+ "strings"
+ "time"
+
+ "github.com/sgbudje/runright/internal/notification"
+)
+
+// retryBackoffDurations controls the wait time before each retry attempt.
+// Attempt 1 (first failure) → 1 min, attempt 2 → 5 min, attempt 3 → 30 min.
+// Attempt 4+ → permanently failed (no further retries).
+var retryBackoffDurations = []time.Duration{
+ 1 * time.Minute,
+ 5 * time.Minute,
+ 30 * time.Minute,
+}
+
+const retryMaxAttempts = 4 // 1 initial + 3 retries
+
+// writeDeliveryLogWithRetry records one delivery attempt and, if it failed,
+// schedules a retry by computing next_retry_at from the backoff table.
+func (s *Server) writeDeliveryLogWithRetry(
+ ctx context.Context,
+ result notification.DeliveryResult,
+ ruleID, jobID, repository string,
+ payload notification.Message,
+) {
+ payloadJSON, _ := json.Marshal(payload)
+ payloadStr := string(payloadJSON)
+
+ if result.Status == "delivered" {
+ _, _ = s.db.ExecContext(ctx, `
+ INSERT INTO notification_delivery_logs
+ (rule_id, destination_id, channel, job_id, repository,
+ status, error_message, attempts, max_attempts, payload, sent_at)
+ VALUES ($1,$2,$3,$4,$5,'delivered','',$6,$7,$8,NOW())`,
+ ruleID, result.DestinationID, result.Channel, jobID, repository,
+ 1, retryMaxAttempts, payloadStr)
+ return
+ }
+
+ // Failed — schedule first retry if we haven't exceeded max attempts.
+ nextRetry := time.Now().UTC().Add(retryBackoffDurations[0])
+ _, _ = s.db.ExecContext(ctx, `
+ INSERT INTO notification_delivery_logs
+ (rule_id, destination_id, channel, job_id, repository,
+ status, error_message, attempts, max_attempts, next_retry_at, payload, sent_at)
+ VALUES ($1,$2,$3,$4,$5,'failed',$6,1,$7,$8,$9,NOW())`,
+ ruleID, result.DestinationID, result.Channel, jobID, repository,
+ result.Error, retryMaxAttempts, nextRetry, payloadStr)
+}
+
+// retryLoop polls the delivery log every 30 s for failed rows that are due for
+// retry and attempts redelivery. It runs as a background goroutine.
+func (s *Server) retryLoop() {
+ ticker := time.NewTicker(30 * time.Second)
+ defer ticker.Stop()
+ for range ticker.C {
+ s.processRetries()
+ }
+}
+
+type retryRow struct {
+ id int
+ ruleID string
+ destinationID string
+ channel string
+ jobID string
+ repository string
+ attempts int
+ maxAttempts int
+ payload string
+}
+
+func (s *Server) processRetries() {
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ rows, err := s.db.QueryContext(ctx, `
+ SELECT id, rule_id, destination_id, channel,
+ COALESCE(job_id,''), COALESCE(repository,''),
+ attempts, max_attempts, COALESCE(payload,'')
+ FROM notification_delivery_logs
+ WHERE status = 'failed'
+ AND next_retry_at IS NOT NULL
+ AND next_retry_at <= NOW()
+ AND attempts < max_attempts
+ ORDER BY next_retry_at ASC
+ LIMIT 50`)
+ if err != nil {
+ return
+ }
+ defer rows.Close()
+
+ var pending []retryRow
+ for rows.Next() {
+ var r retryRow
+ if err := rows.Scan(&r.id, &r.ruleID, &r.destinationID, &r.channel,
+ &r.jobID, &r.repository, &r.attempts, &r.maxAttempts, &r.payload); err != nil {
+ continue
+ }
+ pending = append(pending, r)
+ }
+ _ = rows.Close()
+
+ if len(pending) == 0 {
+ return
+ }
+
+ // Build dispatcher once for this batch.
+ dispatcher, _, _, err := s.buildNotificationDispatcher(ctx)
+ if err != nil {
+ return
+ }
+
+ for _, row := range pending {
+ s.retryDelivery(ctx, dispatcher, row)
+ }
+}
+
+func (s *Server) retryDelivery(ctx context.Context, dispatcher *notification.Dispatcher, row retryRow) {
+ dest, ok := dispatcher.Destinations[row.destinationID]
+ if !ok {
+ // Destination removed — permanently fail this row.
+ s.markRetryExhausted(ctx, row.id, "destination no longer configured")
+ return
+ }
+
+ var msg notification.Message
+ if err := json.Unmarshal([]byte(row.payload), &msg); err != nil {
+ s.markRetryExhausted(ctx, row.id, "payload unmarshal failed: "+err.Error())
+ return
+ }
+
+ // Deliver via channel.
+ deliveryCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
+ deliveryErr := dest.Channel.Send(deliveryCtx, msg)
+ cancel()
+
+ newAttempts := row.attempts + 1
+ if deliveryErr == nil {
+ // Success — mark delivered.
+ _, _ = s.db.ExecContext(ctx, `
+ UPDATE notification_delivery_logs
+ SET status='delivered', error_message='', next_retry_at=NULL, attempts=$1
+ WHERE id=$2`, newAttempts, row.id)
+ return
+ }
+
+ // Still failing.
+ if newAttempts >= row.maxAttempts {
+ s.markRetryExhausted(ctx, row.id, deliveryErr.Error())
+ return
+ }
+
+ // Schedule next retry with backoff.
+ backoffIdx := newAttempts - 1
+ if backoffIdx >= len(retryBackoffDurations) {
+ backoffIdx = len(retryBackoffDurations) - 1
+ }
+ nextRetry := time.Now().UTC().Add(retryBackoffDurations[backoffIdx])
+ _, _ = s.db.ExecContext(ctx, `
+ UPDATE notification_delivery_logs
+ SET attempts=$1, error_message=$2, next_retry_at=$3
+ WHERE id=$4`,
+ newAttempts, strings.TrimSpace(deliveryErr.Error()), nextRetry, row.id)
+}
+
+func (s *Server) markRetryExhausted(ctx context.Context, id int, reason string) {
+ _, _ = s.db.ExecContext(ctx, `
+ UPDATE notification_delivery_logs
+ SET status='permanently_failed', error_message=$1, next_retry_at=NULL
+ WHERE id=$2`, reason, id)
+}
diff --git a/internal/server/server.go b/internal/server/server.go
index fe415a1..d8d4ef1 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -8,27 +8,32 @@ import (
"fmt"
"net/http"
"os"
+ "sort"
+ "strings"
"time"
"github.com/gin-gonic/gin"
- _ "github.com/lib/pq"
+ "github.com/lib/pq"
"github.com/sgbudje/runright/internal/catalog"
"github.com/sgbudje/runright/internal/types"
)
// Server wraps the Gin engine and database connection.
type Server struct {
- router *gin.Engine
- db *sql.DB
- slackWebhook string
+ router *gin.Engine
+ db *sql.DB
+ slackWebhook string
+ alertWebhookURL string
}
// Config holds server configuration.
type Config struct {
- Port int
- DSN string // Postgres DSN, e.g. postgres://user:pass@localhost/scalecidb
- APIKey string
- SlackWebhook string // optional; if set, weekly savings digests are posted here
+ Port int
+ DSN string // Postgres DSN, e.g. postgres://user:pass@localhost/scalecidb
+ APIKey string
+ DisableAuth bool
+ SlackWebhook string // optional; if set, weekly savings digests are posted here
+ AlertWebhookURL string // optional; if set, fired when a job consistently wastes >80% of its machine
}
// New creates a Server, runs migrations, and wires up routes.
@@ -46,21 +51,42 @@ func New(cfg Config) (*Server, error) {
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(gin.Recovery())
+ r.Use(corsMiddleware())
- s := &Server{router: r, db: db, slackWebhook: cfg.SlackWebhook}
+ s := &Server{router: r, db: db, slackWebhook: cfg.SlackWebhook, alertWebhookURL: cfg.AlertWebhookURL}
// Auth endpoint — no middleware applied here.
- r.POST("/api/v1/auth", authLogin(cfg.APIKey))
+ r.POST("/api/v1/auth", authLogin(cfg.APIKey, cfg.DisableAuth))
r.POST("/api/v1/auth/logout", authLogout())
v1 := r.Group("/api/v1")
- v1.Use(authMiddleware(cfg.APIKey))
+ v1.Use(authMiddleware(cfg.APIKey, cfg.DisableAuth))
{
v1.POST("/jobs", s.createJob)
v1.GET("/jobs", s.listJobs)
v1.GET("/jobs/:id", s.getJob)
+ v1.GET("/jobs/:id/trend", s.getJobTrend)
v1.GET("/catalog", s.getCatalog)
v1.GET("/savings", s.getSavings)
+ v1.GET("/savings/history", s.getSavingsHistory)
+ v1.GET("/policies", s.listPolicies)
+ v1.PUT("/policies", s.upsertPolicy)
+ v1.DELETE("/policies", s.deletePolicy)
+ v1.POST("/policies/evaluate", s.evaluatePolicy)
+ // Repository-centric and job-management routes.
+ v1.GET("/repos", s.getRepos)
+ v1.GET("/repo-jobs", s.getRepoJobs) // ?repository=owner%2Frepo
+ v1.GET("/isolated-jobs", s.getIsolatedJobs) // jobs without a repository
+ v1.PUT("/job-meta", s.upsertJobMeta) // snooze / archive / stale_days
+ v1.DELETE("/job-runs", s.deleteJobRuns) // hard-delete all runs for a job
+ v1.GET("/notifications/settings", s.getNotificationSettings)
+ v1.PUT("/notifications/settings", s.upsertNotificationSettings)
+ v1.POST("/notifications/test", s.sendNotificationTest)
+ v1.GET("/notifications/deliveries", s.listNotificationDeliveries)
+ // Ownership routing.
+ v1.GET("/ownership", s.listOwnership)
+ v1.PUT("/ownership", s.upsertOwnership)
+ v1.DELETE("/ownership", s.deleteOwnership)
}
// Badge endpoint — intentionally unauthenticated for embedding in READMEs.
@@ -74,6 +100,12 @@ func New(cfg Config) (*Server, error) {
go s.weeklyDigestLoop()
}
+ // Start rule-driven daily summaries (settings/rules determine whether any send).
+ go s.dailySummaryLoop()
+
+ // Start retry loop for failed notification deliveries.
+ go s.retryLoop()
+
return s, nil
}
@@ -111,6 +143,9 @@ func (s *Server) createJob(c *gin.Context) {
runID = &p.Summary.RunID
}
+ // Repository may be empty for local / non-GitHub runs.
+ repository := p.Summary.Repository
+
// Status defaults to "completed" if not set (e.g. seed data).
status := p.Summary.Status
if status == "" {
@@ -125,8 +160,8 @@ func (s *Server) createJob(c *gin.Context) {
// "completed" flush overwrites it. A completed record is never
// downgraded back to "heartbeat" (WHERE clause guards this).
err = s.db.QueryRowContext(c.Request.Context(), `
- INSERT INTO jobs (job_id, run_id, start_time, end_time, duration_seconds, summary, recommendations, status)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
+ INSERT INTO jobs (job_id, run_id, repository, start_time, end_time, duration_seconds, summary, recommendations, status)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (run_id) WHERE run_id IS NOT NULL DO UPDATE SET
end_time = EXCLUDED.end_time,
duration_seconds = EXCLUDED.duration_seconds,
@@ -135,15 +170,15 @@ func (s *Server) createJob(c *gin.Context) {
status = EXCLUDED.status
WHERE jobs.status != 'completed'
RETURNING id`,
- p.Summary.JobID, runID,
+ p.Summary.JobID, runID, repository,
p.Summary.StartTime, p.Summary.EndTime, p.Summary.DurationSeconds,
summaryJSON, recsJSON, status,
).Scan(&id)
} else {
err = s.db.QueryRowContext(c.Request.Context(),
- `INSERT INTO jobs (job_id, start_time, end_time, duration_seconds, summary, recommendations, status)
- VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
- p.Summary.JobID,
+ `INSERT INTO jobs (job_id, repository, start_time, end_time, duration_seconds, summary, recommendations, status)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`,
+ p.Summary.JobID, repository,
p.Summary.StartTime, p.Summary.EndTime, p.Summary.DurationSeconds,
summaryJSON, recsJSON, status,
).Scan(&id)
@@ -166,6 +201,17 @@ func (s *Server) createJob(c *gin.Context) {
go s.checkDurationRegression(p.Summary.JobID, p.Summary.DurationSeconds, id)
}
+ // High-waste detection: if the last 5 completed runs for this job all had
+ // p95 CPU < 20%, fire an alert webhook (if configured).
+ if status == "completed" && s.alertWebhookURL != "" {
+ go s.checkHighWaste(p.Summary.JobID)
+ }
+
+ // Rule-based notification routing for completed runs.
+ if status == "completed" {
+ go s.dispatchNotificationRules(p.Summary, p.Recommendations)
+ }
+
c.JSON(http.StatusCreated, gin.H{"id": id})
}
@@ -212,6 +258,7 @@ func (s *Server) checkDurationRegression(jobID string, newDuration float64, rowI
type jobRow struct {
ID int `json:"id"`
JobID string `json:"job_id"`
+ Repository string `json:"repository,omitempty"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
DurationSeconds float64 `json:"duration_seconds"`
@@ -222,9 +269,20 @@ type jobRow struct {
}
func (s *Server) listJobs(c *gin.Context) {
- rows, err := s.db.QueryContext(c.Request.Context(),
- `SELECT id, job_id, start_time, end_time, duration_seconds, summary, recommendations, status, created_at
- FROM jobs ORDER BY created_at DESC LIMIT 500`)
+ repo := c.Query("repository")
+ var (
+ rows *sql.Rows
+ err error
+ )
+ if repo != "" {
+ rows, err = s.db.QueryContext(c.Request.Context(),
+ `SELECT id, job_id, COALESCE(repository,''), start_time, end_time, duration_seconds, summary, recommendations, status, created_at
+ FROM jobs WHERE repository = $1 ORDER BY created_at DESC LIMIT 500`, repo)
+ } else {
+ rows, err = s.db.QueryContext(c.Request.Context(),
+ `SELECT id, job_id, COALESCE(repository,''), start_time, end_time, duration_seconds, summary, recommendations, status, created_at
+ FROM jobs ORDER BY created_at DESC LIMIT 500`)
+ }
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -234,7 +292,7 @@ func (s *Server) listJobs(c *gin.Context) {
var jobs []jobRow
for rows.Next() {
var j jobRow
- if err := rows.Scan(&j.ID, &j.JobID, &j.StartTime, &j.EndTime, &j.DurationSeconds,
+ if err := rows.Scan(&j.ID, &j.JobID, &j.Repository, &j.StartTime, &j.EndTime, &j.DurationSeconds,
&j.Summary, &j.Recommendations, &j.Status, &j.CreatedAt); err != nil {
continue
}
@@ -247,9 +305,9 @@ func (s *Server) getJob(c *gin.Context) {
id := c.Param("id")
var j jobRow
err := s.db.QueryRowContext(c.Request.Context(),
- `SELECT id, job_id, start_time, end_time, duration_seconds, summary, recommendations, status, created_at
+ `SELECT id, job_id, COALESCE(repository,''), start_time, end_time, duration_seconds, summary, recommendations, status, created_at
FROM jobs WHERE id = $1`, id).
- Scan(&j.ID, &j.JobID, &j.StartTime, &j.EndTime, &j.DurationSeconds,
+ Scan(&j.ID, &j.JobID, &j.Repository, &j.StartTime, &j.EndTime, &j.DurationSeconds,
&j.Summary, &j.Recommendations, &j.Status, &j.CreatedAt)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "job not found"})
@@ -270,8 +328,20 @@ func (s *Server) getCatalog(c *gin.Context) {
// getSavings aggregates potential monthly savings across all completed jobs.
func (s *Server) getSavings(c *gin.Context) {
- rows, err := s.db.QueryContext(c.Request.Context(),
- `SELECT recommendations, duration_seconds FROM jobs WHERE status = 'completed' ORDER BY created_at DESC LIMIT 1000`)
+ repo := c.Query("repository")
+ var (
+ rows *sql.Rows
+ err error
+ )
+ if repo != "" {
+ rows, err = s.db.QueryContext(c.Request.Context(),
+ `SELECT recommendations, duration_seconds FROM jobs
+ WHERE status = 'completed' AND repository = $1
+ ORDER BY created_at DESC LIMIT 1000`, repo)
+ } else {
+ rows, err = s.db.QueryContext(c.Request.Context(),
+ `SELECT recommendations, duration_seconds FROM jobs WHERE status = 'completed' ORDER BY created_at DESC LIMIT 1000`)
+ }
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -319,128 +389,167 @@ func (s *Server) getSavings(c *gin.Context) {
})
}
-// getBadge returns a shields.io-style SVG badge showing the latest tier for a job.
-// This endpoint is intentionally unauthenticated so teams can embed it in READMEs.
-func (s *Server) getBadge(c *gin.Context) {
- jobId := c.Param("jobId")
- var recsJSON json.RawMessage
- err := s.db.QueryRowContext(c.Request.Context(),
- `SELECT recommendations FROM jobs WHERE job_id = $1 AND status = 'completed'
- ORDER BY created_at DESC LIMIT 1`, jobId).Scan(&recsJSON)
- if err == sql.ErrNoRows {
- c.Header("Content-Type", "image/svg+xml")
- c.String(http.StatusOK, badgeSVG("runright", "unknown", "#9f9f9f"))
- return
- }
+// getSavingsHistory returns daily savings over the last 90 days for charting.
+func (s *Server) getSavingsHistory(c *gin.Context) {
+ rows, err := s.db.QueryContext(c.Request.Context(),
+ `SELECT DATE(start_time) AS day,
+ COUNT(*) AS job_count,
+ SUM(CASE
+ WHEN (recommendations->0->>'cost_delta_percent')::float < -0.5
+ THEN (recommendations->0->>'current_monthly_usd')::float
+ - (recommendations->0->>'estimated_monthly_usd')::float
+ ELSE 0
+ END) AS daily_saving
+ FROM jobs
+ WHERE status = 'completed'
+ AND start_time >= NOW() - INTERVAL '90 days'
+ GROUP BY day
+ ORDER BY day ASC`)
if err != nil {
- c.Header("Content-Type", "image/svg+xml")
- c.String(http.StatusInternalServerError, badgeSVG("runright", "error", "#e05d44"))
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
- var recs []types.Recommendation
- if err := json.Unmarshal(recsJSON, &recs); err != nil || len(recs) == 0 {
- c.Header("Content-Type", "image/svg+xml")
- c.String(http.StatusOK, badgeSVG("runright", "unknown", "#9f9f9f"))
- return
+ defer rows.Close()
+
+ type point struct {
+ Date string `json:"date"`
+ JobCount int `json:"job_count"`
+ MonthlySaving float64 `json:"monthly_savings"`
}
- tier := string(recs[0].Tier)
- color := map[string]string{
- "right-sized": "#44cc11",
- "cheaper-option": "#dfb317",
- "more-headroom": "#e05d44",
- }[tier]
- if color == "" {
- color = "#9f9f9f"
+ var history []point
+ for rows.Next() {
+ var p point
+ if err := rows.Scan(&p.Date, &p.JobCount, &p.MonthlySaving); err != nil {
+ continue
+ }
+ if p.MonthlySaving < 0 {
+ p.MonthlySaving = 0
+ }
+ history = append(history, p)
}
- c.Header("Content-Type", "image/svg+xml")
- c.Header("Cache-Control", "no-cache, max-age=3600")
- c.String(http.StatusOK, badgeSVG("runright", tier, color))
+ c.JSON(http.StatusOK, history)
}
-// badgeSVG generates a minimal shields.io-compatible badge SVG.
-func badgeSVG(label, message, color string) string {
- lw := len(label)*6 + 10
- mw := len(message)*6 + 10
- tw := lw + mw
- return fmt.Sprintf(``,
- tw, tw, lw, mw, color, lw, color, tw,
- lw/2+1, label, lw/2, label,
- lw+mw/2+1, message, lw+mw/2, message,
+// getJobTrend returns aggregated p95 metrics across the last N completed runs for a job_id.
+// Query param: window (default 10).
+func (s *Server) getJobTrend(c *gin.Context) {
+ jobID := c.Param("id")
+ window := 10
+ if w := c.Query("window"); w != "" {
+ if n, err := fmt.Sscan(w, &window); err != nil || n == 0 || window < 1 {
+ window = 10
+ }
+ }
+ rows, err := s.db.QueryContext(c.Request.Context(),
+ `SELECT summary FROM jobs
+ WHERE job_id = $1 AND status = 'completed'
+ ORDER BY created_at DESC LIMIT $2`, jobID, window)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ defer rows.Close()
+
+ var (
+ cpuP95s []float64
+ memP95s []float64
+ durations []float64
)
+ for rows.Next() {
+ var summaryJSON json.RawMessage
+ if err := rows.Scan(&summaryJSON); err != nil {
+ continue
+ }
+ var s types.MetricsSummary
+ if err := json.Unmarshal(summaryJSON, &s); err != nil {
+ continue
+ }
+ if s.CPUPercentP95 > 0 {
+ cpuP95s = append(cpuP95s, s.CPUPercentP95)
+ }
+ if s.MemUsedGiBP95 > 0 {
+ memP95s = append(memP95s, s.MemUsedGiBP95)
+ }
+ if s.DurationSeconds > 0 {
+ durations = append(durations, s.DurationSeconds)
+ }
+ }
+ if len(cpuP95s) == 0 {
+ c.JSON(http.StatusNotFound, gin.H{"error": "no completed runs found"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "job_id": jobID,
+ "window": len(cpuP95s),
+ "cpu_p95_avg": avg(cpuP95s),
+ "mem_p95_avg_gib": avg(memP95s),
+ "duration_avg_sec": avg(durations),
+ "cpu_p95_values": cpuP95s,
+ "mem_p95_values_gib": memP95s,
+ "duration_values_sec": durations,
+ })
}
-// weeklyDigestLoop fires every Monday at 09:00 UTC and posts a savings summary to Slack.
-func (s *Server) weeklyDigestLoop() {
- for {
- now := time.Now().UTC()
- // Next Monday 09:00 UTC.
- daysUntilMonday := (int(time.Monday) - int(now.Weekday()) + 7) % 7
- if daysUntilMonday == 0 && now.Hour() >= 9 {
- daysUntilMonday = 7
- }
- next := time.Date(now.Year(), now.Month(), now.Day()+daysUntilMonday, 9, 0, 0, 0, time.UTC)
- time.Sleep(time.Until(next))
- s.postWeeklyDigest()
+// avg computes the mean of a slice. Returns 0 for empty input.
+func avg(vals []float64) float64 {
+ if len(vals) == 0 {
+ return 0
}
+ sum := 0.0
+ for _, v := range vals {
+ sum += v
+ }
+ return sum / float64(len(vals))
}
-func (s *Server) postWeeklyDigest() {
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+// checkHighWaste fires the alert webhook if the last 5 completed runs for jobID
+// all have p95 CPU utilisation below 20% — a strong signal of over-provisioning.
+func (s *Server) checkHighWaste(jobID string) {
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
rows, err := s.db.QueryContext(ctx,
- `SELECT recommendations FROM jobs
- WHERE status = 'completed' AND created_at >= NOW() - INTERVAL '7 days'`)
+ `SELECT summary FROM jobs
+ WHERE job_id = $1 AND status = 'completed'
+ ORDER BY created_at DESC LIMIT 5`, jobID)
if err != nil {
return
}
defer rows.Close()
- var totalJobs int
- var savingJobs int
- var totalSaving float64
+ var cpuP95s []float64
for rows.Next() {
- var recsJSON json.RawMessage
- if err := rows.Scan(&recsJSON); err != nil {
+ var summaryJSON json.RawMessage
+ if err := rows.Scan(&summaryJSON); err != nil {
continue
}
- var recs []types.Recommendation
- if err := json.Unmarshal(recsJSON, &recs); err != nil || len(recs) == 0 {
+ var summary types.MetricsSummary
+ if err := json.Unmarshal(summaryJSON, &summary); err != nil {
continue
}
- totalJobs++
- best := recs[0]
- if best.CostDeltaPercent < -0.5 {
- savingJobs++
- if saving := best.CurrentMonthly - best.EstimatedMonthly; saving > 0 {
- totalSaving += saving
- }
- }
+ cpuP95s = append(cpuP95s, summary.CPUPercentP95)
}
- if totalJobs == 0 {
+ // Need all 5 consecutive runs to be low-CPU before alerting.
+ if len(cpuP95s) < 5 {
return
}
+ for _, v := range cpuP95s {
+ if v >= 20.0 {
+ return
+ }
+ }
- msg := fmt.Sprintf(
- ":moneybag: *RunRight weekly digest*\n"+
- "• %d CI jobs ran this week\n"+
- "• %d are over-provisioned\n"+
- "• Potential savings: *$%.2f/month* ($%.0f/year)",
- totalJobs, savingJobs, totalSaving, totalSaving*12,
- )
- body, _ := json.Marshal(map[string]string{"text": msg})
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.slackWebhook, bytes.NewReader(body))
+ payload := map[string]interface{}{
+ "event": "high_waste_detected",
+ "job_id": jobID,
+ "message": fmt.Sprintf("Job '%s' has had p95 CPU < 20%% for 5 consecutive runs. Consider right-sizing.", jobID),
+ "cpu_p95_last_5_runs": cpuP95s,
+ }
+ body, err := json.Marshal(payload)
+ if err != nil {
+ return
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.alertWebhookURL, bytes.NewReader(body))
if err != nil {
return
}
@@ -453,100 +562,1479 @@ func (s *Server) postWeeklyDigest() {
_ = resp.Body.Close()
}
-// --- Auth handlers + middleware ---
+// ─── Repository-centric and job-management handlers ───────────────────────────
-const sessionCookie = "runright_session"
+// repoSummary is the per-repository aggregate returned by getRepos.
+type repoSummary struct {
+ Repository string `json:"repository"`
+ JobCount int `json:"job_count"`
+ StaleCount int `json:"stale_count"`
+ SnoozedCount int `json:"snoozed_count"`
+ MonthlySavings float64 `json:"monthly_savings_usd"`
+ AnnualSavings float64 `json:"annual_savings_usd"`
+ LastSeen time.Time `json:"last_seen"`
+}
-// authLogin validates the API key and issues an HttpOnly session cookie.
-func authLogin(apiKey string) gin.HandlerFunc {
- return func(c *gin.Context) {
- if apiKey == "" {
- // Auth not configured — nothing to log in to.
- c.JSON(http.StatusOK, gin.H{"status": "no auth configured"})
- return
- }
- var body struct {
- APIKey string `json:"api_key"`
+// jobSummaryRow is one row in the per-repo or isolated jobs listing.
+type jobSummaryRow struct {
+ JobID string `json:"job_id"`
+ Repository string `json:"repository"`
+ RunCount int `json:"run_count"`
+ LastSeen time.Time `json:"last_seen"`
+ Stale bool `json:"stale"`
+ StaleDays int `json:"stale_days"`
+ SnoozedUntil *time.Time `json:"snoozed_until,omitempty"`
+ SnoozeReason string `json:"snooze_reason,omitempty"`
+ Archived bool `json:"archived"`
+ LatestSummary json.RawMessage `json:"latest_summary"`
+ LatestRecommendations json.RawMessage `json:"latest_recommendations"`
+ MonthlySavingsUSD float64 `json:"monthly_savings_usd"`
+}
+
+type policyRule struct {
+ Repository string `json:"repository"`
+ JobID string `json:"job_id"`
+ MaxCostPerHour float64 `json:"max_cost_per_hour"`
+ Enabled bool `json:"enabled"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+type policyEvaluation struct {
+ Repository string `json:"repository"`
+ JobID string `json:"job_id"`
+ DetectedPricePerHour float64 `json:"detected_price_per_hour"`
+ EffectiveMaxCostPerHour float64 `json:"effective_max_cost_per_hour"`
+ Violated bool `json:"violated"`
+ MatchedPolicy *policyRule `json:"matched_policy,omitempty"`
+ SourceScope string `json:"source_scope"`
+}
+
+type notificationEvents struct {
+ PolicyViolation bool `json:"policy_violation"`
+ HighWaste bool `json:"high_waste"`
+ DailySummary bool `json:"daily_summary"`
+}
+
+type notificationAlertRule struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Type string `json:"type"`
+ Event string `json:"event,omitempty"`
+ Scope string `json:"scope"`
+ Repository string `json:"repository"`
+ JobID string `json:"jobId"`
+ Metric string `json:"metric"`
+ Threshold float64 `json:"threshold"`
+ DestinationIDs []string `json:"destinationIds"`
+ Enabled bool `json:"enabled"`
+}
+
+type slackDestination struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ WebhookURL string `json:"webhook_url"`
+ HasSecret bool `json:"has_secret,omitempty"`
+ Channel string `json:"channel"`
+ Mention string `json:"mention"`
+}
+
+// teamsDestination is a Microsoft Teams Incoming Webhook destination.
+type teamsDestination struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ WebhookURL string `json:"webhook_url"`
+ HasSecret bool `json:"has_secret,omitempty"`
+}
+
+// webhookDestination is a generic HTTP POST destination.
+type webhookDestination struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ URL string `json:"url"`
+ HasSecret bool `json:"has_secret,omitempty"`
+ Headers map[string]string `json:"headers,omitempty"`
+}
+
+type slackNotificationSettings struct {
+ Enabled bool `json:"enabled"`
+ WebhookURL string `json:"webhook_url"`
+ Channel string `json:"channel"`
+ Mention string `json:"mention"`
+ Destinations []slackDestination `json:"destinations"`
+}
+
+type emailNotificationSettings struct {
+ Enabled bool `json:"enabled"`
+ Recipients []string `json:"recipients"`
+ SubjectPrefix string `json:"subject_prefix"`
+}
+
+type notificationSettings struct {
+ Enabled bool `json:"enabled"`
+ Events notificationEvents `json:"events"`
+ Slack slackNotificationSettings `json:"slack"`
+ Teams teamsSettings `json:"teams"`
+ Webhooks webhooksSettings `json:"webhooks"`
+ Rules []notificationAlertRule `json:"rules"`
+ Email emailNotificationSettings `json:"email"`
+}
+
+type teamsSettings struct {
+ Enabled bool `json:"enabled"`
+ Destinations []teamsDestination `json:"destinations"`
+}
+
+type webhooksSettings struct {
+ Enabled bool `json:"enabled"`
+ Destinations []webhookDestination `json:"destinations"`
+}
+
+func defaultNotificationSettings() notificationSettings {
+ return notificationSettings{
+ Enabled: true,
+ Events: notificationEvents{
+ PolicyViolation: true,
+ HighWaste: false,
+ DailySummary: true,
+ },
+ Slack: slackNotificationSettings{
+ Enabled: false,
+ WebhookURL: "",
+ Channel: "",
+ Mention: "",
+ Destinations: []slackDestination{},
+ },
+ Teams: teamsSettings{
+ Enabled: false,
+ Destinations: []teamsDestination{},
+ },
+ Webhooks: webhooksSettings{
+ Enabled: false,
+ Destinations: []webhookDestination{},
+ },
+ Rules: []notificationAlertRule{},
+ Email: emailNotificationSettings{
+ Enabled: false,
+ Recipients: []string{},
+ SubjectPrefix: "[RunRight]",
+ },
+ }
+}
+
+func (s *Server) getNotificationSettings(c *gin.Context) {
+ var raw json.RawMessage
+ err := s.db.QueryRowContext(c.Request.Context(),
+ `SELECT settings FROM notification_settings WHERE id = 1`).Scan(&raw)
+ if err == sql.ErrNoRows {
+ c.JSON(http.StatusOK, defaultNotificationSettings())
+ return
+ }
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ var settings notificationSettings
+ if err := json.Unmarshal(raw, &settings); err != nil {
+ c.JSON(http.StatusOK, defaultNotificationSettings())
+ return
+ }
+ if settings.Slack.Destinations == nil {
+ settings.Slack.Destinations = []slackDestination{}
+ }
+ if settings.Teams.Destinations == nil {
+ settings.Teams.Destinations = []teamsDestination{}
+ }
+ if settings.Webhooks.Destinations == nil {
+ settings.Webhooks.Destinations = []webhookDestination{}
+ }
+ if settings.Rules == nil {
+ settings.Rules = []notificationAlertRule{}
+ }
+
+ secrets, err := s.loadDestinationSecrets(c.Request.Context())
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ for i := range settings.Slack.Destinations {
+ destination := &settings.Slack.Destinations[i]
+ if webhook, ok := secrets[destination.ID]; ok && webhook != "" {
+ destination.WebhookURL = maskWebhookURL(webhook)
+ destination.HasSecret = true
+ } else {
+ destination.WebhookURL = ""
+ destination.HasSecret = false
}
- if err := c.ShouldBindJSON(&body); err != nil || body.APIKey == "" {
- c.JSON(http.StatusBadRequest, gin.H{"error": "api_key required"})
- return
+ }
+ for i := range settings.Teams.Destinations {
+ destination := &settings.Teams.Destinations[i]
+ if webhook, ok := secrets[destination.ID]; ok && webhook != "" {
+ destination.WebhookURL = maskWebhookURL(webhook)
+ destination.HasSecret = true
+ } else {
+ destination.WebhookURL = ""
+ destination.HasSecret = false
}
- if body.APIKey != apiKey {
- c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid api_key"})
- return
+ }
+ for i := range settings.Webhooks.Destinations {
+ destination := &settings.Webhooks.Destinations[i]
+ if u, ok := secrets[destination.ID]; ok && u != "" {
+ destination.URL = maskWebhookURL(u)
+ destination.HasSecret = true
+ } else {
+ destination.URL = ""
+ destination.HasSecret = false
}
- // Set HttpOnly, SameSite=Strict cookie. JS cannot read this.
- c.SetSameSite(http.SameSiteStrictMode)
- c.SetCookie(sessionCookie, apiKey, 86400*30, "/", "", false, true /* httpOnly */)
- c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
+ if settings.Slack.WebhookURL != "" {
+ settings.Slack.WebhookURL = maskWebhookURL(settings.Slack.WebhookURL)
+ }
+ c.JSON(http.StatusOK, settings)
}
-// authLogout clears the session cookie.
-func authLogout() gin.HandlerFunc {
- return func(c *gin.Context) {
- c.SetSameSite(http.SameSiteStrictMode)
- c.SetCookie(sessionCookie, "", -1, "/", "", false, true)
- c.JSON(http.StatusOK, gin.H{"status": "logged out"})
+func (s *Server) upsertNotificationSettings(c *gin.Context) {
+ settings := defaultNotificationSettings()
+ if err := c.ShouldBindJSON(&settings); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
}
-}
-// authMiddleware accepts either the HttpOnly cookie or a Bearer token (for CLI/curl).
-func authMiddleware(apiKey string) gin.HandlerFunc {
- return func(c *gin.Context) {
- // If no API key is configured, skip auth (dev mode).
- if apiKey == "" {
- c.Next()
+ if settings.Slack.Destinations == nil {
+ settings.Slack.Destinations = []slackDestination{}
+ }
+ if settings.Teams.Destinations == nil {
+ settings.Teams.Destinations = []teamsDestination{}
+ }
+ if settings.Webhooks.Destinations == nil {
+ settings.Webhooks.Destinations = []webhookDestination{}
+ }
+ if settings.Rules == nil {
+ settings.Rules = []notificationAlertRule{}
+ }
+
+ if settings.Enabled && !settings.Slack.Enabled && !settings.Teams.Enabled && !settings.Webhooks.Enabled {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "at least one destination channel (slack, teams, or webhooks) must be enabled when notifications are enabled"})
+ return
+ }
+
+ if settings.Slack.Enabled {
+ if len(settings.Slack.Destinations) == 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "at least one slack destination is required"})
return
}
- // Check HttpOnly cookie first (dashboard).
- if cookie, err := c.Cookie(sessionCookie); err == nil && cookie == apiKey {
- c.Next()
+
+ existingSecrets, err := s.loadDestinationSecrets(c.Request.Context())
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
- // Fall back to Bearer token (CLI / programmatic access).
- if c.GetHeader("Authorization") == "Bearer "+apiKey {
- c.Next()
- return
+ for _, destination := range settings.Slack.Destinations {
+ if destination.ID == "" || destination.Name == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "each slack destination requires id and name"})
+ return
+ }
+ if isWebhookURL(destination.WebhookURL) {
+ if err := s.upsertDestinationSecret(c.Request.Context(), destination.ID, destination.WebhookURL); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ existingSecrets[destination.ID] = destination.WebhookURL
+ } else if _, ok := existingSecrets[destination.ID]; !ok {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "new destination requires a valid webhook_url"})
+ return
+ }
}
- c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
+ for i := range settings.Slack.Destinations {
+ destination := &settings.Slack.Destinations[i]
+ if webhook, ok := existingSecrets[destination.ID]; ok {
+ destination.WebhookURL = maskWebhookURL(webhook)
+ destination.HasSecret = true
+ } else {
+ destination.WebhookURL = ""
+ destination.HasSecret = false
+ }
+ }
+ // Keep legacy single-destination fields synchronized for compatibility.
+ if firstWebhook, ok := existingSecrets[settings.Slack.Destinations[0].ID]; ok {
+ settings.Slack.WebhookURL = maskWebhookURL(firstWebhook)
+ } else {
+ settings.Slack.WebhookURL = ""
+ }
+ settings.Slack.Channel = settings.Slack.Destinations[0].Channel
+ settings.Slack.Mention = settings.Slack.Destinations[0].Mention
}
-}
-// --- Migrations ---
+ if err := s.upsertTeamsAndWebhookSecrets(c.Request.Context(), &settings); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ // Prune secrets whose destination IDs are no longer in any channel.
+ allActiveIDs := make([]string, 0)
+ for _, d := range settings.Slack.Destinations {
+ allActiveIDs = append(allActiveIDs, d.ID)
+ }
+ for _, d := range settings.Teams.Destinations {
+ allActiveIDs = append(allActiveIDs, d.ID)
+ }
+ for _, d := range settings.Webhooks.Destinations {
+ allActiveIDs = append(allActiveIDs, d.ID)
+ }
+ if err := s.deleteDestinationSecretsNotIn(c.Request.Context(), allActiveIDs); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ if err := validateNotificationRuleSchema(&settings); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ payload, err := json.Marshal(settings)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ _, err = s.db.ExecContext(c.Request.Context(), `
+ INSERT INTO notification_settings (id, settings, updated_at)
+ VALUES (1, $1, NOW())
+ ON CONFLICT (id) DO UPDATE SET
+ settings = EXCLUDED.settings,
+ updated_at = NOW()`, payload)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"status": "ok"})
+}
+
+// upsertTeamsAndWebhookSecrets saves Teams and generic webhook URLs into the
+// shared destination-secrets table. It mirrors the Slack secret-management
+// pattern so the same loadDestinationSecrets helper serves all providers.
+func (s *Server) upsertTeamsAndWebhookSecrets(ctx context.Context, settings *notificationSettings) error {
+ if settings.Teams.Enabled {
+ for i := range settings.Teams.Destinations {
+ dest := &settings.Teams.Destinations[i]
+ dest.ID = strings.TrimSpace(dest.ID)
+ dest.Name = strings.TrimSpace(dest.Name)
+ if dest.ID == "" || dest.Name == "" {
+ return fmt.Errorf("each teams destination requires id and name")
+ }
+ if isWebhookURL(dest.WebhookURL) {
+ if err := s.upsertDestinationSecret(ctx, dest.ID, dest.WebhookURL); err != nil {
+ return err
+ }
+ }
+ dest.WebhookURL = ""
+ dest.HasSecret = true
+ }
+ }
+ if settings.Webhooks.Enabled {
+ for i := range settings.Webhooks.Destinations {
+ dest := &settings.Webhooks.Destinations[i]
+ dest.ID = strings.TrimSpace(dest.ID)
+ dest.Name = strings.TrimSpace(dest.Name)
+ if dest.ID == "" || dest.Name == "" {
+ return fmt.Errorf("each webhook destination requires id and name")
+ }
+ if isWebhookURL(dest.URL) {
+ if err := s.upsertDestinationSecret(ctx, dest.ID, dest.URL); err != nil {
+ return err
+ }
+ }
+ dest.URL = ""
+ dest.HasSecret = true
+ }
+ }
+ return nil
+}
+
+func validateNotificationRuleSchema(settings *notificationSettings) error {
+ destinationIDs := map[string]struct{}{}
+ for i := range settings.Slack.Destinations {
+ destination := &settings.Slack.Destinations[i]
+ destination.ID = strings.TrimSpace(destination.ID)
+ destination.Name = strings.TrimSpace(destination.Name)
+ if destination.ID == "" || destination.Name == "" {
+ return fmt.Errorf("each slack destination requires id and name")
+ }
+ if _, exists := destinationIDs[destination.ID]; exists {
+ return fmt.Errorf("duplicate slack destination id: %s", destination.ID)
+ }
+ destinationIDs[destination.ID] = struct{}{}
+ }
+ for i := range settings.Teams.Destinations {
+ destination := &settings.Teams.Destinations[i]
+ destination.ID = strings.TrimSpace(destination.ID)
+ destination.Name = strings.TrimSpace(destination.Name)
+ if destination.ID == "" || destination.Name == "" {
+ return fmt.Errorf("each teams destination requires id and name")
+ }
+ if _, exists := destinationIDs[destination.ID]; exists {
+ return fmt.Errorf("duplicate teams destination id: %s", destination.ID)
+ }
+ destinationIDs[destination.ID] = struct{}{}
+ }
+ for i := range settings.Webhooks.Destinations {
+ destination := &settings.Webhooks.Destinations[i]
+ destination.ID = strings.TrimSpace(destination.ID)
+ destination.Name = strings.TrimSpace(destination.Name)
+ if destination.ID == "" || destination.Name == "" {
+ return fmt.Errorf("each webhook destination requires id and name")
+ }
+ if _, exists := destinationIDs[destination.ID]; exists {
+ return fmt.Errorf("duplicate webhook destination id: %s", destination.ID)
+ }
+ destinationIDs[destination.ID] = struct{}{}
+ }
+
+ validEvents := map[string]struct{}{
+ "policy_violation": {},
+ "high_waste": {},
+ "daily_summary": {},
+ }
+ validMetrics := map[string]struct{}{
+ "max_cost_per_hour": {},
+ "waste_percent": {},
+ "monthly_savings_drop_percent": {},
+ }
+
+ ruleIDs := map[string]struct{}{}
+ for i := range settings.Rules {
+ rule := &settings.Rules[i]
+ rule.ID = strings.TrimSpace(rule.ID)
+ rule.Name = strings.TrimSpace(rule.Name)
+ rule.Type = strings.TrimSpace(rule.Type)
+ rule.Event = strings.TrimSpace(rule.Event)
+ rule.Scope = strings.TrimSpace(rule.Scope)
+ rule.Repository = strings.TrimSpace(rule.Repository)
+ rule.JobID = strings.TrimSpace(rule.JobID)
+ rule.Metric = strings.TrimSpace(rule.Metric)
+
+ if rule.ID == "" || rule.Name == "" {
+ return fmt.Errorf("rule %d requires id and name", i)
+ }
+ if _, exists := ruleIDs[rule.ID]; exists {
+ return fmt.Errorf("duplicate rule id: %s", rule.ID)
+ }
+ ruleIDs[rule.ID] = struct{}{}
+
+ if len(rule.DestinationIDs) == 0 {
+ return fmt.Errorf("rule %s must include at least one destinationId", rule.ID)
+ }
+ seenDestinations := map[string]struct{}{}
+ for j := range rule.DestinationIDs {
+ destinationID := strings.TrimSpace(rule.DestinationIDs[j])
+ if destinationID == "" {
+ return fmt.Errorf("rule %s has an empty destinationId", rule.ID)
+ }
+ if _, exists := destinationIDs[destinationID]; !exists {
+ return fmt.Errorf("rule %s references unknown destinationId: %s", rule.ID, destinationID)
+ }
+ if _, duplicate := seenDestinations[destinationID]; duplicate {
+ return fmt.Errorf("rule %s has duplicate destinationId: %s", rule.ID, destinationID)
+ }
+ seenDestinations[destinationID] = struct{}{}
+ rule.DestinationIDs[j] = destinationID
+ }
+
+ switch rule.Scope {
+ case "global":
+ if rule.Repository != "" || rule.JobID != "" {
+ return fmt.Errorf("rule %s with global scope must not set repository or jobId", rule.ID)
+ }
+ case "repository":
+ if rule.Repository == "" {
+ return fmt.Errorf("rule %s with repository scope requires repository", rule.ID)
+ }
+ if rule.JobID != "" {
+ return fmt.Errorf("rule %s with repository scope must not set jobId", rule.ID)
+ }
+ case "job":
+ if rule.Repository == "" || rule.JobID == "" {
+ return fmt.Errorf("rule %s with job scope requires repository and jobId", rule.ID)
+ }
+ default:
+ return fmt.Errorf("rule %s has unsupported scope: %s", rule.ID, rule.Scope)
+ }
+
+ switch rule.Type {
+ case "event":
+ if _, ok := validEvents[rule.Event]; !ok {
+ return fmt.Errorf("rule %s has unsupported event: %s", rule.ID, rule.Event)
+ }
+ if rule.Metric != "max_cost_per_hour" {
+ return fmt.Errorf("rule %s event type must set metric to max_cost_per_hour", rule.ID)
+ }
+ if rule.Threshold != 0 {
+ return fmt.Errorf("rule %s event type must set threshold to 0", rule.ID)
+ }
+ case "threshold":
+ if rule.Event != "" {
+ return fmt.Errorf("rule %s threshold type must not set event", rule.ID)
+ }
+ if _, ok := validMetrics[rule.Metric]; !ok {
+ return fmt.Errorf("rule %s has unsupported metric: %s", rule.ID, rule.Metric)
+ }
+ if rule.Threshold <= 0 {
+ return fmt.Errorf("rule %s threshold type requires threshold > 0", rule.ID)
+ }
+ default:
+ return fmt.Errorf("rule %s has unsupported type: %s", rule.ID, rule.Type)
+ }
+ }
+
+ return nil
+}
+
+type deliveryLogRow struct {
+ ID int `json:"id"`
+ RuleID string `json:"rule_id"`
+ DestinationID string `json:"destination_id"`
+ Channel string `json:"channel"`
+ JobID string `json:"job_id"`
+ Repository string `json:"repository"`
+ Status string `json:"status"`
+ ErrorMessage string `json:"error_message,omitempty"`
+ SentAt time.Time `json:"sent_at"`
+}
+
+func (s *Server) listNotificationDeliveries(c *gin.Context) {
+ ruleID := c.Query("rule_id")
+ limitStr := c.DefaultQuery("limit", "50")
+ limit := 50
+ if n, err := fmt.Sscanf(limitStr, "%d", &limit); n != 1 || err != nil || limit < 1 || limit > 500 {
+ limit = 50
+ }
+
+ var (
+ rows *sql.Rows
+ err error
+ )
+ if ruleID != "" {
+ rows, err = s.db.QueryContext(c.Request.Context(), `
+ SELECT id, rule_id, destination_id, channel, job_id, COALESCE(repository,''), status, COALESCE(error_message,''), sent_at
+ FROM notification_delivery_logs
+ WHERE rule_id = $1
+ ORDER BY sent_at DESC
+ LIMIT $2`, ruleID, limit)
+ } else {
+ rows, err = s.db.QueryContext(c.Request.Context(), `
+ SELECT id, rule_id, destination_id, channel, job_id, COALESCE(repository,''), status, COALESCE(error_message,''), sent_at
+ FROM notification_delivery_logs
+ ORDER BY sent_at DESC
+ LIMIT $1`, limit)
+ }
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ defer rows.Close()
+
+ var out []deliveryLogRow
+ for rows.Next() {
+ var row deliveryLogRow
+ if err := rows.Scan(&row.ID, &row.RuleID, &row.DestinationID, &row.Channel, &row.JobID, &row.Repository, &row.Status, &row.ErrorMessage, &row.SentAt); err != nil {
+ continue
+ }
+ out = append(out, row)
+ }
+ if out == nil {
+ out = []deliveryLogRow{}
+ }
+ c.JSON(http.StatusOK, out)
+}
+
+func (s *Server) loadNotificationSettingsForDispatch(ctx context.Context) (notificationSettings, map[string]string, error) {
+ settings := defaultNotificationSettings()
+
+ var raw json.RawMessage
+ err := s.db.QueryRowContext(ctx, `SELECT settings FROM notification_settings WHERE id = 1`).Scan(&raw)
+ if err == sql.ErrNoRows {
+ return settings, map[string]string{}, nil
+ }
+ if err != nil {
+ return settings, nil, err
+ }
+ if err := json.Unmarshal(raw, &settings); err != nil {
+ return settings, nil, err
+ }
+ if settings.Slack.Destinations == nil {
+ settings.Slack.Destinations = []slackDestination{}
+ }
+ if settings.Rules == nil {
+ settings.Rules = []notificationAlertRule{}
+ }
+
+ secrets, err := s.loadDestinationSecrets(ctx)
+ if err != nil {
+ return settings, nil, err
+ }
+ return settings, secrets, nil
+}
+
+func (s *Server) evaluateEffectivePolicy(ctx context.Context, repository, jobID string, detectedPrice float64) policyEvaluation {
+ eval := policyEvaluation{
+ Repository: repository,
+ JobID: jobID,
+ DetectedPricePerHour: detectedPrice,
+ SourceScope: "none",
+ }
+
+ if detectedPrice <= 0 {
+ return eval
+ }
+
+ row := s.db.QueryRowContext(ctx, `
+ SELECT repository, COALESCE(job_id, ''), max_cost_per_hour, enabled, updated_at
+ FROM policy_rules
+ WHERE (repository = $1 AND job_id = $2)
+ OR (repository = $1 AND job_id = '')
+ OR (repository = '' AND job_id = '')
+ ORDER BY CASE
+ WHEN repository = $1 AND job_id = $2 THEN 0
+ WHEN repository = $1 AND job_id = '' THEN 1
+ ELSE 2
+ END
+ LIMIT 1`, repository, jobID)
+
+ var rule policyRule
+ if err := row.Scan(&rule.Repository, &rule.JobID, &rule.MaxCostPerHour, &rule.Enabled, &rule.UpdatedAt); err == nil {
+ eval.MatchedPolicy = &rule
+ eval.EffectiveMaxCostPerHour = rule.MaxCostPerHour
+ eval.Violated = rule.Enabled && detectedPrice > rule.MaxCostPerHour
+ switch {
+ case rule.Repository == repository && rule.JobID == jobID:
+ eval.SourceScope = "job"
+ case rule.Repository == repository && rule.JobID == "":
+ eval.SourceScope = "repository"
+ default:
+ eval.SourceScope = "global"
+ }
+ }
+
+ return eval
+}
+
+func (s *Server) dailySummaryAlreadySent(ctx context.Context, ruleID, scopeKey, day string) (bool, error) {
+ var exists int
+ err := s.db.QueryRowContext(ctx, `
+ SELECT 1 FROM notification_daily_summary_dispatches
+ WHERE rule_id = $1 AND scope_key = $2 AND summary_date = $3
+ LIMIT 1`, ruleID, scopeKey, day).Scan(&exists)
+ if err == sql.ErrNoRows {
+ return false, nil
+ }
+ if err != nil {
+ return false, err
+ }
+ return true, nil
+}
+
+func (s *Server) markDailySummarySent(ctx context.Context, ruleID, scopeKey, day string) error {
+ _, err := s.db.ExecContext(ctx, `
+ INSERT INTO notification_daily_summary_dispatches (rule_id, scope_key, summary_date, sent_at)
+ VALUES ($1, $2, $3, NOW())
+ ON CONFLICT (rule_id, scope_key, summary_date) DO NOTHING`, ruleID, scopeKey, day)
+ return err
+}
+
+func (s *Server) sendNotificationTest(c *gin.Context) {
+ var settingsRaw json.RawMessage
+ err := s.db.QueryRowContext(c.Request.Context(),
+ `SELECT settings FROM notification_settings WHERE id = 1`).Scan(&settingsRaw)
+ if err == sql.ErrNoRows {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "notification settings are not configured"})
+ return
+ }
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ settings := defaultNotificationSettings()
+ if err := json.Unmarshal(settingsRaw, &settings); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse notification settings"})
+ return
+ }
+ secrets, err := s.loadDestinationSecrets(c.Request.Context())
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ if !settings.Enabled || !settings.Slack.Enabled || len(settings.Slack.Destinations) == 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "no enabled slack destinations configured"})
+ return
+ }
+
+ msg := fmt.Sprintf(":bell: RunRight test notification at %s", time.Now().UTC().Format(time.RFC3339))
+ failures := 0
+ for _, destination := range settings.Slack.Destinations {
+ webhook := secrets[destination.ID]
+ if webhook == "" {
+ continue
+ }
+ text := msg
+ if destination.Mention != "" {
+ text = destination.Mention + " " + text
+ }
+ if err := postSlackWebhook(c.Request.Context(), webhook, text); err != nil {
+ failures++
+ }
+ }
+
+ if failures > 0 {
+ c.JSON(http.StatusBadGateway, gin.H{"error": "failed to send one or more test notifications"})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"status": "ok"})
+}
+
+func postSlackWebhook(ctx context.Context, webhookURL, text string) error {
+ body, err := json.Marshal(map[string]string{"text": text})
+ if err != nil {
+ return err
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, webhookURL, bytes.NewReader(body))
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ client := &http.Client{Timeout: 10 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return fmt.Errorf("slack returned status %d", resp.StatusCode)
+ }
+ return nil
+}
+
+func isWebhookURL(v string) bool {
+ v = strings.TrimSpace(v)
+ return strings.HasPrefix(v, "http://") || strings.HasPrefix(v, "https://")
+}
+
+func maskWebhookURL(v string) string {
+ v = strings.TrimSpace(v)
+ if v == "" {
+ return ""
+ }
+ if len(v) <= 16 {
+ return "********"
+ }
+ if len(v) <= 32 {
+ return v[:8] + "..." + v[len(v)-4:]
+ }
+ return v[:26] + "..." + v[len(v)-6:]
+}
+
+func (s *Server) loadDestinationSecrets(ctx context.Context) (map[string]string, error) {
+ rows, err := s.db.QueryContext(ctx, `SELECT destination_id, webhook_url FROM notification_destination_secrets`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ secrets := map[string]string{}
+ for rows.Next() {
+ var destinationID, webhook string
+ if err := rows.Scan(&destinationID, &webhook); err != nil {
+ return nil, err
+ }
+ secrets[destinationID] = webhook
+ }
+ return secrets, rows.Err()
+}
+
+func (s *Server) upsertDestinationSecret(ctx context.Context, destinationID, webhook string) error {
+ _, err := s.db.ExecContext(ctx, `
+ INSERT INTO notification_destination_secrets (destination_id, webhook_url, updated_at)
+ VALUES ($1, $2, NOW())
+ ON CONFLICT (destination_id) DO UPDATE SET
+ webhook_url = EXCLUDED.webhook_url,
+ updated_at = NOW()`, destinationID, webhook)
+ return err
+}
+
+func (s *Server) deleteDestinationSecretsNotIn(ctx context.Context, ids []string) error {
+ if len(ids) == 0 {
+ _, err := s.db.ExecContext(ctx, `DELETE FROM notification_destination_secrets`)
+ return err
+ }
+ _, err := s.db.ExecContext(ctx,
+ `DELETE FROM notification_destination_secrets WHERE NOT (destination_id = ANY($1))`, pq.StringArray(ids))
+ return err
+}
+
+// queryJobSummaries is a shared helper that fetches per-job-id summaries.
+// When repository is non-empty it scopes to that repo; empty string = isolated (no repo).
+// includeArchived controls whether archived rows are included.
+func (s *Server) queryJobSummaries(ctx context.Context, repository string, isolatedOnly bool, includeArchived bool) ([]jobSummaryRow, error) {
+ var repoFilter string
+ var args []interface{}
+
+ if isolatedOnly {
+ repoFilter = "AND repository IS NULL"
+ } else {
+ repoFilter = "AND repository = $1"
+ args = append(args, repository)
+ }
+
+ archivedFilter := "AND COALESCE(m.archived, false) = false"
+ if includeArchived {
+ archivedFilter = ""
+ }
+
+ query := fmt.Sprintf(`
+ WITH latest AS (
+ SELECT DISTINCT ON (job_id)
+ job_id,
+ COALESCE(repository, '') AS repository,
+ created_at AS last_seen,
+ summary,
+ recommendations
+ FROM jobs
+ WHERE status = 'completed'
+ %s
+ ORDER BY job_id, created_at DESC
+ ),
+ counts AS (
+ SELECT job_id, COUNT(*) AS run_count
+ FROM jobs
+ WHERE status = 'completed'
+ %s
+ GROUP BY job_id
+ )
+ SELECT
+ l.job_id,
+ l.repository,
+ l.last_seen,
+ l.summary,
+ l.recommendations,
+ c.run_count,
+ m.snoozed_until,
+ COALESCE(m.snooze_reason, '') AS snooze_reason,
+ COALESCE(m.archived, false) AS archived,
+ COALESCE(m.stale_days, 30) AS stale_days
+ FROM latest l
+ JOIN counts c ON c.job_id = l.job_id
+ LEFT JOIN job_metadata m
+ ON m.job_id = l.job_id
+ AND m.repository = l.repository
+ %s
+ ORDER BY l.last_seen DESC`,
+ repoFilter, repoFilter, archivedFilter)
+
+ rows, err := s.db.QueryContext(ctx, query, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ now := time.Now()
+ var result []jobSummaryRow
+ for rows.Next() {
+ var row jobSummaryRow
+ var snoozedUntil *time.Time
+ var staleDays int
+ if err := rows.Scan(
+ &row.JobID, &row.Repository, &row.LastSeen,
+ &row.LatestSummary, &row.LatestRecommendations,
+ &row.RunCount, &snoozedUntil, &row.SnoozeReason,
+ &row.Archived, &staleDays,
+ ); err != nil {
+ continue
+ }
+ row.StaleDays = staleDays
+ row.SnoozedUntil = snoozedUntil
+ snoozed := snoozedUntil != nil && snoozedUntil.After(now)
+ row.Stale = !snoozed && now.Sub(row.LastSeen) > time.Duration(staleDays)*24*time.Hour
+
+ // Compute monthly savings from latest recommendation.
+ var recs []types.Recommendation
+ if err := json.Unmarshal(row.LatestRecommendations, &recs); err == nil && len(recs) > 0 {
+ if recs[0].CostDeltaPercent < -0.5 {
+ if saving := recs[0].CurrentMonthly - recs[0].EstimatedMonthly; saving > 0 {
+ row.MonthlySavingsUSD = saving
+ }
+ }
+ }
+ result = append(result, row)
+ }
+ return result, nil
+}
+
+// getRepos returns a per-repository summary (job count, stale count, savings, last seen).
+func (s *Server) getRepos(c *gin.Context) {
+ // Fetch the latest run per job_id for all repos in one pass.
+ rows, err := s.db.QueryContext(c.Request.Context(), `
+ WITH latest AS (
+ SELECT DISTINCT ON (job_id)
+ job_id, COALESCE(repository, '') AS repository,
+ created_at AS last_seen, recommendations
+ FROM jobs
+ WHERE status = 'completed' AND repository IS NOT NULL
+ ORDER BY job_id, created_at DESC
+ )
+ SELECT l.job_id, l.repository, l.last_seen, l.recommendations,
+ m.snoozed_until,
+ COALESCE(m.archived, false) AS archived,
+ COALESCE(m.stale_days, 30) AS stale_days
+ FROM latest l
+ LEFT JOIN job_metadata m ON m.job_id = l.job_id AND m.repository = l.repository
+ WHERE COALESCE(m.archived, false) = false
+ `)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ defer rows.Close()
+
+ type entry struct {
+ lastSeen time.Time
+ recs []types.Recommendation
+ snoozedUntil *time.Time
+ staleDays int
+ }
+ repoMap := make(map[string][]entry)
+ now := time.Now()
+
+ for rows.Next() {
+ var jobID, repo string
+ var lastSeen time.Time
+ var recsJSON json.RawMessage
+ var snoozedUntil *time.Time
+ var archived bool
+ var staleDays int
+ if err := rows.Scan(&jobID, &repo, &lastSeen, &recsJSON, &snoozedUntil, &archived, &staleDays); err != nil {
+ continue
+ }
+ var recs []types.Recommendation
+ _ = json.Unmarshal(recsJSON, &recs)
+ repoMap[repo] = append(repoMap[repo], entry{lastSeen, recs, snoozedUntil, staleDays})
+ }
+
+ var summaries []repoSummary
+ for repo, jobs := range repoMap {
+ rs := repoSummary{Repository: repo, JobCount: len(jobs)}
+ for _, j := range jobs {
+ snoozed := j.snoozedUntil != nil && j.snoozedUntil.After(now)
+ if snoozed {
+ rs.SnoozedCount++
+ } else if now.Sub(j.lastSeen) > time.Duration(j.staleDays)*24*time.Hour {
+ rs.StaleCount++
+ }
+ if j.lastSeen.After(rs.LastSeen) {
+ rs.LastSeen = j.lastSeen
+ }
+ if len(j.recs) > 0 && j.recs[0].CostDeltaPercent < -0.5 {
+ if saving := j.recs[0].CurrentMonthly - j.recs[0].EstimatedMonthly; saving > 0 {
+ rs.MonthlySavings += saving
+ }
+ }
+ }
+ rs.AnnualSavings = rs.MonthlySavings * 12
+ summaries = append(summaries, rs)
+ }
+ sort.Slice(summaries, func(i, j int) bool {
+ return summaries[i].LastSeen.After(summaries[j].LastSeen)
+ })
+ if summaries == nil {
+ summaries = []repoSummary{}
+ }
+ c.JSON(http.StatusOK, summaries)
+}
+
+// getRepoJobs returns per-job-id summaries scoped to a single repository.
+// Query param: repository (required), include_archived (optional, default false).
+func (s *Server) getRepoJobs(c *gin.Context) {
+ repo := c.Query("repository")
+ if repo == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "repository query param required"})
+ return
+ }
+ includeArchived := c.Query("include_archived") == "true"
+ rows, err := s.queryJobSummaries(c.Request.Context(), repo, false, includeArchived)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ if rows == nil {
+ rows = []jobSummaryRow{}
+ }
+ c.JSON(http.StatusOK, rows)
+}
+
+// getIsolatedJobs returns per-job-id summaries for jobs with no repository set.
+func (s *Server) getIsolatedJobs(c *gin.Context) {
+ includeArchived := c.Query("include_archived") == "true"
+ rows, err := s.queryJobSummaries(c.Request.Context(), "", true, includeArchived)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ if rows == nil {
+ rows = []jobSummaryRow{}
+ }
+ c.JSON(http.StatusOK, rows)
+}
+
+// listPolicies returns policy rules, optionally filtered by repository.
+func (s *Server) listPolicies(c *gin.Context) {
+ repository := c.Query("repository")
+ rows, err := s.db.QueryContext(c.Request.Context(), `
+ SELECT repository, COALESCE(job_id, ''), max_cost_per_hour, enabled, updated_at
+ FROM policy_rules
+ WHERE ($1 = '' OR repository = $1)
+ ORDER BY repository ASC, job_id ASC`, repository)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ defer rows.Close()
+
+ var out []policyRule
+ for rows.Next() {
+ var rule policyRule
+ if err := rows.Scan(&rule.Repository, &rule.JobID, &rule.MaxCostPerHour, &rule.Enabled, &rule.UpdatedAt); err != nil {
+ continue
+ }
+ out = append(out, rule)
+ }
+ if out == nil {
+ out = []policyRule{}
+ }
+ c.JSON(http.StatusOK, out)
+}
+
+// upsertPolicy creates or updates a policy rule.
+func (s *Server) upsertPolicy(c *gin.Context) {
+ var body struct {
+ Repository string `json:"repository"`
+ JobID string `json:"job_id"`
+ MaxCostPerHour float64 `json:"max_cost_per_hour"`
+ Enabled *bool `json:"enabled"`
+ }
+ if err := c.ShouldBindJSON(&body); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ if body.JobID != "" && body.Repository == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "repository is required when job_id is set"})
+ return
+ }
+ if body.MaxCostPerHour <= 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "max_cost_per_hour must be greater than zero"})
+ return
+ }
+ enabled := true
+ if body.Enabled != nil {
+ enabled = *body.Enabled
+ }
+ _, err := s.db.ExecContext(c.Request.Context(), `
+ INSERT INTO policy_rules (repository, job_id, max_cost_per_hour, enabled, updated_at)
+ VALUES ($1, $2, $3, $4, NOW())
+ ON CONFLICT (repository, job_id) DO UPDATE SET
+ max_cost_per_hour = EXCLUDED.max_cost_per_hour,
+ enabled = EXCLUDED.enabled,
+ updated_at = NOW()`,
+ body.Repository, body.JobID, body.MaxCostPerHour, enabled,
+ )
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"status": "ok"})
+}
-func migrate(db *sql.DB) error {
- stmts := []string{
- // Core table (idempotent).
- `CREATE TABLE IF NOT EXISTS jobs (
- id SERIAL PRIMARY KEY,
- job_id TEXT NOT NULL,
- run_id TEXT,
- start_time TIMESTAMPTZ NOT NULL,
- end_time TIMESTAMPTZ NOT NULL,
- duration_seconds DOUBLE PRECISION NOT NULL DEFAULT 0,
- summary JSONB NOT NULL DEFAULT '{}',
- recommendations JSONB NOT NULL DEFAULT '[]',
- status TEXT NOT NULL DEFAULT 'completed',
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
- )`,
- // Columns added in later migrations (safe to re-run on fresh DBs too).
- `ALTER TABLE jobs ADD COLUMN IF NOT EXISTS run_id TEXT`,
- `ALTER TABLE jobs ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'completed'`,
- // Indexes (must come after columns exist).
- `CREATE INDEX IF NOT EXISTS idx_jobs_job_id ON jobs(job_id)`,
- `CREATE INDEX IF NOT EXISTS idx_jobs_created_at ON jobs(created_at)`,
- `CREATE UNIQUE INDEX IF NOT EXISTS idx_jobs_run_id ON jobs(run_id) WHERE run_id IS NOT NULL`,
- }
- for _, s := range stmts {
- if _, err := db.Exec(s); err != nil {
- return fmt.Errorf("migration: %w", err)
+// deletePolicy removes a policy rule for a repository/job scope.
+func (s *Server) deletePolicy(c *gin.Context) {
+ repository := c.Query("repository")
+ jobID := c.Query("job_id")
+ _, err := s.db.ExecContext(c.Request.Context(), `DELETE FROM policy_rules WHERE repository = $1 AND job_id = $2`, repository, jobID)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"status": "ok"})
+}
+
+// evaluatePolicy finds the effective policy for a repository/job and reports whether a detected price violates it.
+func (s *Server) evaluatePolicy(c *gin.Context) {
+ var body struct {
+ Repository string `json:"repository"`
+ JobID string `json:"job_id"`
+ DetectedPricePerHour float64 `json:"detected_price_per_hour"`
+ }
+ if err := c.ShouldBindJSON(&body); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ if body.DetectedPricePerHour <= 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "detected_price_per_hour must be greater than zero"})
+ return
+ }
+
+ eval := policyEvaluation{
+ Repository: body.Repository,
+ JobID: body.JobID,
+ DetectedPricePerHour: body.DetectedPricePerHour,
+ SourceScope: "none",
+ }
+
+ row := s.db.QueryRowContext(c.Request.Context(), `
+ SELECT repository, COALESCE(job_id, ''), max_cost_per_hour, enabled, updated_at
+ FROM policy_rules
+ WHERE (repository = $1 AND job_id = $2)
+ OR (repository = $1 AND job_id = '')
+ OR (repository = '' AND job_id = '')
+ ORDER BY CASE
+ WHEN repository = $1 AND job_id = $2 THEN 0
+ WHEN repository = $1 AND job_id = '' THEN 1
+ ELSE 2
+ END
+ LIMIT 1`, body.Repository, body.JobID)
+
+ var rule policyRule
+ if err := row.Scan(&rule.Repository, &rule.JobID, &rule.MaxCostPerHour, &rule.Enabled, &rule.UpdatedAt); err == nil {
+ eval.MatchedPolicy = &rule
+ eval.EffectiveMaxCostPerHour = rule.MaxCostPerHour
+ eval.Violated = rule.Enabled && body.DetectedPricePerHour > rule.MaxCostPerHour
+ switch {
+ case rule.Repository == body.Repository && rule.JobID == body.JobID:
+ eval.SourceScope = "job"
+ case rule.Repository == body.Repository && rule.JobID == "":
+ eval.SourceScope = "repository"
+ default:
+ eval.SourceScope = "global"
}
}
- return nil
+
+ c.JSON(http.StatusOK, eval)
+}
+
+// upsertJobMeta creates or updates snooze/archive settings for a job.
+func (s *Server) upsertJobMeta(c *gin.Context) {
+ var body struct {
+ JobID string `json:"job_id" binding:"required"`
+ Repository string `json:"repository"`
+ SnoozedUntil *time.Time `json:"snoozed_until"`
+ SnoozeReason string `json:"snooze_reason"`
+ Archived *bool `json:"archived"`
+ StaleDays *int `json:"stale_days"`
+ }
+ if err := c.ShouldBindJSON(&body); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ // Resolve archived_at.
+ var archivedAt *time.Time
+ if body.Archived != nil && *body.Archived {
+ now := time.Now()
+ archivedAt = &now
+ }
+ staleDays := 30
+ if body.StaleDays != nil && *body.StaleDays > 0 {
+ staleDays = *body.StaleDays
+ }
+ archived := false
+ if body.Archived != nil {
+ archived = *body.Archived
+ }
+
+ _, err := s.db.ExecContext(c.Request.Context(), `
+ INSERT INTO job_metadata (job_id, repository, snoozed_until, snooze_reason, archived, archived_at, stale_days)
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
+ ON CONFLICT (job_id, repository) DO UPDATE SET
+ snoozed_until = EXCLUDED.snoozed_until,
+ snooze_reason = EXCLUDED.snooze_reason,
+ archived = EXCLUDED.archived,
+ archived_at = CASE WHEN EXCLUDED.archived AND NOT job_metadata.archived
+ THEN NOW() ELSE job_metadata.archived_at END,
+ stale_days = EXCLUDED.stale_days`,
+ body.JobID, body.Repository,
+ body.SnoozedUntil, body.SnoozeReason,
+ archived, archivedAt, staleDays,
+ )
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"status": "ok"})
+}
+
+// deleteJobRuns hard-deletes ALL run records for a given job_id + repository pair,
+// and removes the associated job_metadata row.
+// Query params: job_id (required), repository (optional, defaults to empty string).
+func (s *Server) deleteJobRuns(c *gin.Context) {
+ jobID := c.Query("job_id")
+ if jobID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "job_id query param required"})
+ return
+ }
+ repository := c.Query("repository")
+
+ tx, err := s.db.BeginTx(c.Request.Context(), nil)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ defer func() { _ = tx.Rollback() }()
+
+ var deleted int64
+ if repository == "" {
+ res, err := tx.ExecContext(c.Request.Context(),
+ `DELETE FROM jobs WHERE job_id = $1 AND repository IS NULL`, jobID)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ deleted, _ = res.RowsAffected()
+ _, _ = tx.ExecContext(c.Request.Context(),
+ `DELETE FROM job_metadata WHERE job_id = $1 AND repository = ''`, jobID)
+ } else {
+ res, err := tx.ExecContext(c.Request.Context(),
+ `DELETE FROM jobs WHERE job_id = $1 AND repository = $2`, jobID, repository)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ deleted, _ = res.RowsAffected()
+ _, _ = tx.ExecContext(c.Request.Context(),
+ `DELETE FROM job_metadata WHERE job_id = $1 AND repository = $2`, jobID, repository)
+ }
+
+ if err := tx.Commit(); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"deleted_runs": deleted})
+}
+
+// This endpoint is intentionally unauthenticated so teams can embed it in READMEs.
+func (s *Server) getBadge(c *gin.Context) {
+ jobId := c.Param("jobId")
+ var recsJSON json.RawMessage
+ err := s.db.QueryRowContext(c.Request.Context(),
+ `SELECT recommendations FROM jobs WHERE job_id = $1 AND status = 'completed'
+ ORDER BY created_at DESC LIMIT 1`, jobId).Scan(&recsJSON)
+ if err == sql.ErrNoRows {
+ c.Header("Content-Type", "image/svg+xml")
+ c.String(http.StatusOK, badgeSVG("runright", "unknown", "#9f9f9f"))
+ return
+ }
+ if err != nil {
+ c.Header("Content-Type", "image/svg+xml")
+ c.String(http.StatusInternalServerError, badgeSVG("runright", "error", "#e05d44"))
+ return
+ }
+ var recs []types.Recommendation
+ if err := json.Unmarshal(recsJSON, &recs); err != nil || len(recs) == 0 {
+ c.Header("Content-Type", "image/svg+xml")
+ c.String(http.StatusOK, badgeSVG("runright", "unknown", "#9f9f9f"))
+ return
+ }
+ tier := string(recs[0].Tier)
+ color := map[string]string{
+ "right-sized": "#44cc11",
+ "cheaper-option": "#dfb317",
+ "more-headroom": "#e05d44",
+ }[tier]
+ if color == "" {
+ color = "#9f9f9f"
+ }
+ c.Header("Content-Type", "image/svg+xml")
+ c.Header("Cache-Control", "no-cache, max-age=3600")
+ c.String(http.StatusOK, badgeSVG("runright", tier, color))
+}
+
+// badgeSVG generates a minimal shields.io-compatible badge SVG.
+func badgeSVG(label, message, color string) string {
+ lw := len(label)*6 + 10
+ mw := len(message)*6 + 10
+ tw := lw + mw
+ return fmt.Sprintf(``,
+ tw, tw, lw, mw, color, lw, color, tw,
+ lw/2+1, label, lw/2, label,
+ lw+mw/2+1, message, lw+mw/2, message,
+ )
+}
+
+// weeklyDigestLoop fires every Monday at 09:00 UTC and posts a savings summary to Slack.
+func (s *Server) weeklyDigestLoop() {
+ for {
+ now := time.Now().UTC()
+ // Next Monday 09:00 UTC.
+ daysUntilMonday := (int(time.Monday) - int(now.Weekday()) + 7) % 7
+ if daysUntilMonday == 0 && now.Hour() >= 9 {
+ daysUntilMonday = 7
+ }
+ next := time.Date(now.Year(), now.Month(), now.Day()+daysUntilMonday, 9, 0, 0, 0, time.UTC)
+ time.Sleep(time.Until(next))
+ s.postWeeklyDigest()
+ }
+}
+
+func (s *Server) postWeeklyDigest() {
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT recommendations FROM jobs
+ WHERE status = 'completed' AND created_at >= NOW() - INTERVAL '7 days'`)
+ if err != nil {
+ return
+ }
+ defer rows.Close()
+
+ var totalJobs int
+ var savingJobs int
+ var totalSaving float64
+ for rows.Next() {
+ var recsJSON json.RawMessage
+ if err := rows.Scan(&recsJSON); err != nil {
+ continue
+ }
+ var recs []types.Recommendation
+ if err := json.Unmarshal(recsJSON, &recs); err != nil || len(recs) == 0 {
+ continue
+ }
+ totalJobs++
+ best := recs[0]
+ if best.CostDeltaPercent < -0.5 {
+ savingJobs++
+ if saving := best.CurrentMonthly - best.EstimatedMonthly; saving > 0 {
+ totalSaving += saving
+ }
+ }
+ }
+ if totalJobs == 0 {
+ return
+ }
+
+ msg := fmt.Sprintf(
+ ":moneybag: *RunRight weekly digest*\n"+
+ "• %d CI jobs ran this week\n"+
+ "• %d are over-provisioned\n"+
+ "• Potential savings: *$%.2f/month* ($%.0f/year)",
+ totalJobs, savingJobs, totalSaving, totalSaving*12,
+ )
+ body, _ := json.Marshal(map[string]string{"text": msg})
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.slackWebhook, bytes.NewReader(body))
+ if err != nil {
+ return
+ }
+ req.Header.Set("Content-Type", "application/json")
+ client := &http.Client{Timeout: 10 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return
+ }
+ _ = resp.Body.Close()
+}
+
+// --- CORS middleware ---
+
+func corsMiddleware() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
+ c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
+ c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
+ c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
+
+ if c.Request.Method == "OPTIONS" {
+ c.AbortWithStatus(http.StatusNoContent)
+ return
+ }
+
+ c.Next()
+ }
+}
+
+// --- Auth handlers + middleware ---
+
+const sessionCookie = "runright_session"
+
+// authLogin validates the API key and issues an HttpOnly session cookie.
+func authLogin(apiKey string, disableAuth bool) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if disableAuth || apiKey == "" {
+ // Auth not configured — nothing to log in to.
+ c.JSON(http.StatusOK, gin.H{"status": "no auth configured"})
+ return
+ }
+ var body struct {
+ APIKey string `json:"api_key"`
+ }
+ if err := c.ShouldBindJSON(&body); err != nil || body.APIKey == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "api_key required"})
+ return
+ }
+ if body.APIKey != apiKey {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid api_key"})
+ return
+ }
+ // Set HttpOnly, SameSite=Strict cookie. JS cannot read this.
+ c.SetSameSite(http.SameSiteStrictMode)
+ c.SetCookie(sessionCookie, apiKey, 86400*30, "/", "", false, true /* httpOnly */)
+ c.JSON(http.StatusOK, gin.H{"status": "ok"})
+ }
}
+// authLogout clears the session cookie.
+func authLogout() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ c.SetSameSite(http.SameSiteStrictMode)
+ c.SetCookie(sessionCookie, "", -1, "/", "", false, true)
+ c.JSON(http.StatusOK, gin.H{"status": "logged out"})
+ }
+}
+
+// authMiddleware accepts either the HttpOnly cookie or a Bearer token (for CLI/curl).
+func authMiddleware(apiKey string, disableAuth bool) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ // If auth is disabled or no API key is configured, skip auth (dev mode).
+ if disableAuth || apiKey == "" {
+ c.Next()
+ return
+ }
+ // Check HttpOnly cookie first (dashboard).
+ if cookie, err := c.Cookie(sessionCookie); err == nil && cookie == apiKey {
+ c.Next()
+ return
+ }
+ // Fall back to Bearer token (CLI / programmatic access).
+ if c.GetHeader("Authorization") == "Bearer "+apiKey {
+ c.Next()
+ return
+ }
+ c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
+ }
+}
+
+// --- Migrations ---
+
+func migrate(db *sql.DB) error { return runEmbeddedMigrations(db) }
+
// ConfigFromEnv reads server config from environment variables.
func ConfigFromEnv() Config {
port := 8080
@@ -554,10 +2042,16 @@ func ConfigFromEnv() Config {
if dsn == "" {
dsn = "postgres://runright:runright@localhost:5435/runright?sslmode=disable"
}
+ disableAuth := strings.EqualFold(strings.TrimSpace(os.Getenv("RUNRIGHT_DISABLE_AUTH")), "true")
+ if !disableAuth {
+ disableAuth = strings.EqualFold(strings.TrimSpace(os.Getenv("RUNRIGHT_DEV_MODE")), "true")
+ }
return Config{
- Port: port,
- DSN: dsn,
- APIKey: os.Getenv("RUNRIGHT_API_KEY"),
- SlackWebhook: os.Getenv("RUNRIGHT_SLACK_WEBHOOK"),
+ Port: port,
+ DSN: dsn,
+ APIKey: os.Getenv("RUNRIGHT_API_KEY"),
+ DisableAuth: disableAuth,
+ SlackWebhook: os.Getenv("RUNRIGHT_SLACK_WEBHOOK"),
+ AlertWebhookURL: os.Getenv("RUNRIGHT_ALERT_WEBHOOK"),
}
}
diff --git a/internal/types/types.go b/internal/types/types.go
index 7c1a934..8618f0e 100644
--- a/internal/types/types.go
+++ b/internal/types/types.go
@@ -23,7 +23,15 @@ type MachineType struct {
StorageType string `json:"storage_type"`
Architecture string `json:"architecture"`
OnDemandPricePerHour float64 `json:"on_demand_price_per_hour"`
+ // SpotPricePerHour is an explicitly sourced spot/preemptible price.
+ // If zero, spot data is considered unavailable and must not be inferred.
+ SpotPricePerHour float64 `json:"spot_price_per_hour,omitempty"`
+ // SpotInterruptionRatePct is an explicitly sourced interruption/preemption rate
+ // (0..100). If zero, interruption risk should not be inferred.
+ SpotInterruptionRatePct float64 `json:"spot_interruption_rate_pct,omitempty"`
Tags []string `json:"tags"`
+ // SpotRisk indicates interruption likelihood for spot/preemptible: low, medium, or high.
+ SpotRisk string `json:"spot_risk,omitempty"`
}
// MetricSnapshot is a single point-in-time reading from the metrics agent.
@@ -46,6 +54,7 @@ type MetricsSummary struct {
RunID string `json:"run_id,omitempty"` // unique per agent invocation; used for upserts
Status string `json:"status,omitempty"` // "heartbeat" | "completed"
CIPlatform string `json:"ci_platform,omitempty"` // "github" | "jenkins" | "gitlab" | "circleci" | "bitbucket" | "local"
+ Repository string `json:"repository,omitempty"` // e.g. "owner/repo" from GITHUB_REPOSITORY
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
DurationSeconds float64 `json:"duration_seconds"`
@@ -104,5 +113,10 @@ type Recommendation struct {
RequiredMemoryGiB float64 `json:"required_memory_gib"`
Reasoning string `json:"reasoning"`
DurationRegressionPct *float64 `json:"duration_regression_pct,omitempty"`
- KubernetesResources *KubernetesResources `json:"kubernetes_resources,omitempty"`
+ // DurationRiskNote is set when the recommended machine has significantly fewer vCPUs
+ // than the current one; a CPU-bound job may run slower after the change.
+ DurationRiskNote string `json:"duration_risk_note,omitempty"`
+ // SpotRisk is inherited from the recommended machine's spot interruption likelihood.
+ SpotRisk string `json:"spot_risk,omitempty"`
+ KubernetesResources *KubernetesResources `json:"kubernetes_resources,omitempty"`
}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..6df0acc
--- /dev/null
+++ b/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "runright",
+ "private": true,
+ "scripts": {
+ "dev": "sh ./scripts/dev.sh",
+ "dev:web": "pnpm --dir web dev",
+ "dev:api": "RUNRIGHT_DISABLE_AUTH=true DATABASE_URL=postgres://runright:runright@localhost:5435/runright?sslmode=disable go run ./cmd/runright serve --port 8080"
+ }
+}
diff --git a/scripts/dev.sh b/scripts/dev.sh
new file mode 100644
index 0000000..62ee3e3
--- /dev/null
+++ b/scripts/dev.sh
@@ -0,0 +1,18 @@
+#!/bin/sh
+set -eu
+
+cd "$(dirname "$0")/.."
+
+docker compose up -d postgres >/dev/null
+
+RUNRIGHT_DISABLE_AUTH=true \
+DATABASE_URL='postgres://runright:runright@localhost:5435/runright?sslmode=disable' \
+go run ./cmd/runright serve --port 8080 &
+backend_pid=$!
+
+cleanup() {
+ kill "$backend_pid" 2>/dev/null || true
+}
+trap cleanup EXIT INT TERM
+
+pnpm --dir web dev --host 0.0.0.0 --port 5173
diff --git a/scripts/monitor-start.sh b/scripts/monitor-start.sh
index a79c7df..47fdc55 100644
--- a/scripts/monitor-start.sh
+++ b/scripts/monitor-start.sh
@@ -1,11 +1,13 @@
#!/usr/bin/env bash
# Start the runright monitor in the background and record its PID.
# Env vars:
-# RUNRIGHT_INTERVAL - sampling interval (e.g. "5s")
-# RUNRIGHT_EXPORT - export backends (e.g. "file")
-# RUNRIGHT_OUTPUT_DIR - directory to write metrics files
-# RUNRIGHT_JOB_ID - unique job identifier
-# RUNRIGHT_HTTP_URL - backend URL for http export (may be empty)
+# RUNRIGHT_INTERVAL - sampling interval (e.g. "5s")
+# RUNRIGHT_EXPORT - export backends (e.g. "file")
+# RUNRIGHT_OUTPUT_DIR - directory to write metrics files
+# RUNRIGHT_JOB_ID - unique job identifier
+# RUNRIGHT_HTTP_URL - backend URL for http export (may be empty)
+# RUNRIGHT_SLACK_WEBHOOK - Slack incoming webhook URL (may be empty)
+# RUNRIGHT_TEAMS_WEBHOOK - Microsoft Teams incoming webhook URL (may be empty)
set -euo pipefail
mkdir -p "$RUNRIGHT_OUTPUT_DIR"
@@ -14,6 +16,8 @@ runright monitor \
--export "$RUNRIGHT_EXPORT" \
--output-dir "$RUNRIGHT_OUTPUT_DIR" \
--job-id "$RUNRIGHT_JOB_ID" \
- --http-url "${RUNRIGHT_HTTP_URL:-}" &
+ --http-url "${RUNRIGHT_HTTP_URL:-}" \
+ --slack-webhook "${RUNRIGHT_SLACK_WEBHOOK:-}" \
+ --teams-webhook "${RUNRIGHT_TEAMS_WEBHOOK:-}" &
echo $! > /tmp/runright-monitor.pid
echo "runright: monitor started (PID $(cat /tmp/runright-monitor.pid))"
diff --git a/scripts/recommend.sh b/scripts/recommend.sh
index b02c84d..116d1d0 100644
--- a/scripts/recommend.sh
+++ b/scripts/recommend.sh
@@ -1,14 +1,151 @@
#!/usr/bin/env bash
# Generate machine sizing recommendations from collected metrics.
# Env vars:
-# RUNRIGHT_OUTPUT_DIR - directory containing metrics files
-# RUNRIGHT_JOB_ID - job identifier used in PR comment marker
+# RUNRIGHT_OUTPUT_DIR - directory containing metrics files
+# RUNRIGHT_JOB_ID - job identifier used in PR comment marker
+# RUNRIGHT_MAX_COST_PER_HOUR - budget guard; non-empty = fail if detected machine exceeds this price
+# RUNRIGHT_DRY_RUN - "true" = skip writing PR comment body
# GitHub-provided (auto):
# GITHUB_OUTPUT, GITHUB_STEP_SUMMARY, GITHUB_ENV
# GITHUB_RUN_NUMBER, GITHUB_SERVER_URL, GITHUB_REPOSITORY, GITHUB_RUN_ID
# RUNRIGHT_UNEXPECTED_EXIT (set by monitor-stop.sh when monitor died early)
set -euo pipefail
+SUMMARY="${RUNRIGHT_OUTPUT_DIR}/metrics-summary.json"
+
+if [[ ! -f "$SUMMARY" ]]; then
+ HEARTBEAT="${RUNRIGHT_OUTPUT_DIR}/metrics-heartbeat.json"
+ if [[ -f "$HEARTBEAT" ]] && [[ "${RUNRIGHT_UNEXPECTED_EXIT:-0}" == "1" ]]; then
+ echo "::warning::RunRight: Job was interrupted before completion. Using partial metrics from last heartbeat checkpoint for best-effort recommendation."
+ SUMMARY="$HEARTBEAT"
+ elif [[ "${RUNRIGHT_UNEXPECTED_EXIT:-0}" == "1" ]]; then
+ # Killed before the first heartbeat (< 30 s) — report current machine specs.
+ VCPUS=$(nproc 2>/dev/null || echo "?")
+ MEM_KB=$(grep MemTotal /proc/meminfo 2>/dev/null | awk '{print $2}' || echo "0")
+ MEM_GIB=$(python3 -c "print(round(${MEM_KB:-0}/1048576,1))" 2>/dev/null || echo "?")
+ echo "::error::RunRight: Monitor was killed before collecting any metrics (likely OOM or force-stop)."
+ echo "::notice::Detected runner: ${VCPUS} vCPU / ${MEM_GIB} GiB RAM. The job exceeded this machine's capacity. Consider upgrading to a larger instance type."
+ exit 0
+ else
+ echo "No metrics-summary.json found at $SUMMARY, skipping recommendations."
+ exit 0
+ fi
+fi
+
+# ── Budget guard ──────────────────────────────────────────────────────────────
+if [[ -n "${RUNRIGHT_HTTP_URL:-}" ]]; then
+ POLICY_PAYLOAD=$(python3 -c "import json; d=json.load(open('$SUMMARY')); print(json.dumps({'repository': d.get('repository', ''), 'job_id': d.get('job_id', ''), 'detected_price_per_hour': (d.get('detected_machine') or {}).get('on_demand_price_per_hour', 0)}))" 2>/dev/null || echo "")
+ if [[ -n "$POLICY_PAYLOAD" ]]; then
+ AUTH_HEADER=()
+ if [[ -n "${RUNRIGHT_API_KEY:-}" ]]; then
+ AUTH_HEADER=(-H "Authorization: Bearer ${RUNRIGHT_API_KEY}")
+ fi
+ POLICY_RESULT=$(curl -fsS -X POST "${RUNRIGHT_HTTP_URL}/api/v1/policies/evaluate" \
+ -H 'Content-Type: application/json' "${AUTH_HEADER[@]}" \
+ -d "$POLICY_PAYLOAD" 2>/dev/null || echo "")
+ if [[ -n "$POLICY_RESULT" ]]; then
+ VIOLATED=$(echo "$POLICY_RESULT" | python3 -c "import sys,json; d=json.load(sys.stdin); print('yes' if d.get('violated') else 'no')" 2>/dev/null || echo "no")
+ if [[ "$VIOLATED" == "yes" ]]; then
+ SCHEMA=$(echo "$POLICY_RESULT" | python3 -c "import sys,json; d=json.load(sys.stdin); p=d.get('matched_policy') or {}; scope='global' if p.get('repository','')=='' and p.get('job_id','')=='' else ('repository' if p.get('job_id','')=='' else 'job'); print(scope)" 2>/dev/null || echo "policy")
+ THRESHOLD=$(echo "$POLICY_RESULT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('effective_max_cost_per_hour', 0))" 2>/dev/null || echo "0")
+ DETECTED_PRICE=$(echo "$POLICY_RESULT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('detected_price_per_hour', 0))" 2>/dev/null || echo "0")
+ echo "::error::RunRight policy violation (${SCHEMA} scope): detected machine costs ${DETECTED_PRICE}/hr which exceeds the allowed maximum of ${THRESHOLD}/hr."
+ exit 1
+ fi
+ fi
+ fi
+fi
+
+if [[ -n "${RUNRIGHT_MAX_COST_PER_HOUR:-}" ]]; then
+ DETECTED_PRICE=$(python3 -c \
+ "import sys,json; d=json.load(open('$SUMMARY')); m=d.get('detected_machine'); print(m['on_demand_price_per_hour'] if m else 0)" \
+ 2>/dev/null || echo "0")
+ OVER_BUDGET=$(python3 -c \
+ "print('yes' if ${DETECTED_PRICE} > ${RUNRIGHT_MAX_COST_PER_HOUR} else 'no')" \
+ 2>/dev/null || echo "no")
+ if [[ "$OVER_BUDGET" == "yes" ]]; then
+ DETECTED_ID=$(python3 -c \
+ "import sys,json; d=json.load(open('$SUMMARY')); m=d.get('detected_machine'); print(m['id'] if m else 'unknown')" \
+ 2>/dev/null || echo "unknown")
+ echo "::error::RunRight budget guard: detected machine '$DETECTED_ID' costs \$${DETECTED_PRICE}/hr which exceeds the allowed maximum of \$${RUNRIGHT_MAX_COST_PER_HOUR}/hr. Update your runner configuration to use a smaller machine type."
+ exit 1
+ fi
+fi
+
+# ── Generate recommendations as JSON ─────────────────────────────────────────
+PROVIDER_FLAG=${RUNRIGHT_PROVIDER:+--provider "$RUNRIGHT_PROVIDER"}
+RESULT=$(runright recommend --metrics "$SUMMARY" --format json $PROVIDER_FLAG 2>/dev/null || echo "[]")
+echo "result=$(echo "$RESULT" | tr -d '\n')" >> "$GITHUB_OUTPUT"
+
+# Extract top recommendation.
+TOP=$(echo "$RESULT" | python3 -c \
+ "import sys,json; d=json.load(sys.stdin); print(d[0]['machine']['id'] if d else 'unknown')" \
+ 2>/dev/null || echo "unknown")
+echo "suggested_machine=$TOP" >> "$GITHUB_OUTPUT"
+
+# Extract detected machine.
+DETECTED=$(python3 -c \
+ "import sys,json; d=json.load(open('$SUMMARY')); m=d.get('detected_machine'); print(m['id'] if m else 'unknown')" \
+ 2>/dev/null || echo "unknown")
+echo "detected_machine=$DETECTED" >> "$GITHUB_OUTPUT"
+
+# ── Annual savings projection ─────────────────────────────────────────────────
+ANNUAL_SAVINGS=$(echo "$RESULT" | python3 -c "
+import sys, json
+recs = json.load(sys.stdin)
+if not recs:
+ print('')
+else:
+ best = next((r for r in recs if r.get('cost_delta_percent', 0) < -0.5), None)
+ if best:
+ monthly = best.get('current_monthly_usd', 0) - best.get('estimated_monthly_usd', 0)
+ annual = monthly * 12
+ print(f'~\${annual:.0f}/yr (~\${monthly:.2f}/mo) by switching to {best[\"machine\"][\"id\"]}')
+ else:
+ print('')
+" 2>/dev/null || echo "")
+
+# ── Print recommendation table to CI logs ────────────────────────────────────
+echo ""
+echo "╔══════════════════════════════════════════════════════╗"
+echo "║ RunRight — Machine Sizing Recommendation ║"
+echo "╚══════════════════════════════════════════════════════╝"
+runright recommend --metrics "$SUMMARY" --format table $PROVIDER_FLAG 2>/dev/null || true
+if [[ -n "$ANNUAL_SAVINGS" ]]; then
+ echo ""
+ echo " 💡 Projected annual savings: $ANNUAL_SAVINGS"
+fi
+echo ""
+
+# ── Write markdown to the Actions Step Summary tab ───────────────────────────
+runright recommend --metrics "$SUMMARY" --format markdown $PROVIDER_FLAG >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true
+if [[ -n "$ANNUAL_SAVINGS" ]]; then
+ echo "" >> "$GITHUB_STEP_SUMMARY"
+ echo "> 💡 **Projected annual savings:** $ANNUAL_SAVINGS" >> "$GITHUB_STEP_SUMMARY"
+fi
+
+# ── Build PR comment body (skipped in dry-run mode) ──────────────────────────
+if [[ "${RUNRIGHT_DRY_RUN:-false}" != "true" ]]; then
+ MARKER=""
+ {
+ echo "$MARKER"
+ echo ""
+ echo "### ⚡ RunRight — \`${RUNRIGHT_JOB_ID}\`"
+ echo ""
+ runright recommend --metrics "$SUMMARY" --format markdown $PROVIDER_FLAG 2>/dev/null || true
+ if [[ -n "$ANNUAL_SAVINGS" ]]; then
+ echo ""
+ echo "> 💡 **Projected annual savings:** $ANNUAL_SAVINGS"
+ fi
+ echo ""
+ echo "Powered by [RunRight](https://github.com/sgbudje/runright) · [Run #${GITHUB_RUN_NUMBER}](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})"
+ } > /tmp/runright-pr-comment.md
+fi
+
+# ── Save evaluated output-dir so the upload-artifact step resolves the same path ──
+echo "RUNRIGHT_OUTPUT_DIR=${RUNRIGHT_OUTPUT_DIR}" >> "$GITHUB_ENV"
+
+
SUMMARY="${RUNRIGHT_OUTPUT_DIR}/metrics-summary.json"
if [[ ! -f "$SUMMARY" ]]; then
diff --git a/web/package.json b/web/package.json
index be4eaec..ff03d55 100644
--- a/web/package.json
+++ b/web/package.json
@@ -24,10 +24,13 @@
"@types/react-dom": "^19.2.3",
"@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^6.0.1",
+ "autoprefixer": "^10.5.1",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
+ "postcss": "^8.5.15",
+ "tailwindcss": "^3.4.19",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12"
diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml
index a4c350a..03be0c9 100644
--- a/web/pnpm-lock.yaml
+++ b/web/pnpm-lock.yaml
@@ -26,7 +26,7 @@ importers:
devDependencies:
'@eslint/js':
specifier: ^10.0.1
- version: 10.0.1(eslint@10.5.0)
+ version: 10.0.1(eslint@10.5.0(jiti@1.21.7))
'@types/node':
specifier: ^24.12.3
version: 24.13.2
@@ -41,31 +41,44 @@ importers:
version: 5.3.3
'@vitejs/plugin-react':
specifier: ^6.0.1
- version: 6.0.2(vite@8.0.16(@types/node@24.13.2))
+ version: 6.0.2(vite@8.0.16(@types/node@24.13.2)(jiti@1.21.7))
+ autoprefixer:
+ specifier: ^10.5.1
+ version: 10.5.1(postcss@8.5.15)
eslint:
specifier: ^10.3.0
- version: 10.5.0
+ version: 10.5.0(jiti@1.21.7)
eslint-plugin-react-hooks:
specifier: ^7.1.1
- version: 7.1.1(eslint@10.5.0)
+ version: 7.1.1(eslint@10.5.0(jiti@1.21.7))
eslint-plugin-react-refresh:
specifier: ^0.5.2
- version: 0.5.3(eslint@10.5.0)
+ version: 0.5.3(eslint@10.5.0(jiti@1.21.7))
globals:
specifier: ^17.6.0
version: 17.6.0
+ postcss:
+ specifier: ^8.5.15
+ version: 8.5.15
+ tailwindcss:
+ specifier: ^3.4.19
+ version: 3.4.19
typescript:
specifier: ~6.0.2
version: 6.0.3
typescript-eslint:
specifier: ^8.59.2
- version: 8.61.1(eslint@10.5.0)(typescript@6.0.3)
+ version: 8.61.1(eslint@10.5.0(jiti@1.21.7))(typescript@6.0.3)
vite:
specifier: ^8.0.12
- version: 8.0.16(@types/node@24.13.2)
+ version: 8.0.16(@types/node@24.13.2)(jiti@1.21.7)
packages:
+ '@alloc/quick-lru@5.2.0':
+ resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
+ engines: {node: '>=10'}
+
'@babel/code-frame@7.29.7':
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
engines: {node: '>=6.9.0'}
@@ -223,6 +236,18 @@ packages:
'@emnapi/core': ^1.7.1
'@emnapi/runtime': ^1.7.1
+ '@nodelib/fs.scandir@2.1.5':
+ resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
+ engines: {node: '>= 8'}
+
+ '@nodelib/fs.stat@2.0.5':
+ resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
+ engines: {node: '>= 8'}
+
+ '@nodelib/fs.walk@1.2.8':
+ resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
+ engines: {node: '>= 8'}
+
'@oxc-project/types@0.133.0':
resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==}
@@ -492,9 +517,26 @@ packages:
ajv@6.15.0:
resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}
+ any-promise@1.3.0:
+ resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
+
+ anymatch@3.1.3:
+ resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
+ engines: {node: '>= 8'}
+
+ arg@5.0.2:
+ resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
+
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
+ autoprefixer@10.5.1:
+ resolution: {integrity: sha512-jwM2pcTuCWUoN70FEvf5XrXyDbUgRURK4FnU8v0jWZZYU/KkVvN9T33mu1sVLFY9JW3kTWzKheEpn6xYLRc/VA==}
+ engines: {node: ^10 || ^12 || >=14}
+ hasBin: true
+ peerDependencies:
+ postcss: ^8.1.0
+
axios@1.18.0:
resolution: {integrity: sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==}
@@ -507,22 +549,43 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
+ binary-extensions@2.3.0:
+ resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
+ engines: {node: '>=8'}
+
brace-expansion@5.0.6:
resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
engines: {node: 18 || 20 || >=22}
+ braces@3.0.3:
+ resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
+ engines: {node: '>=8'}
+
browserslist@4.28.2:
resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
+ browserslist@4.28.4:
+ resolution: {integrity: sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==}
+ engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
+ hasBin: true
+
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
+ camelcase-css@2.0.1:
+ resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
+ engines: {node: '>= 6'}
+
caniuse-lite@1.0.30001799:
resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==}
+ chokidar@3.6.0:
+ resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
+ engines: {node: '>= 8.10.0'}
+
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
@@ -531,6 +594,10 @@ packages:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
+ commander@4.1.1:
+ resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
+ engines: {node: '>= 6'}
+
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
@@ -542,6 +609,11 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
+ cssesc@3.0.0:
+ resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
+ engines: {node: '>=4'}
+ hasBin: true
+
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
@@ -612,6 +684,12 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
+ didyoumean@1.2.2:
+ resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
+
+ dlv@1.1.3:
+ resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
+
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
@@ -705,12 +783,19 @@ packages:
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
+ fast-glob@3.3.3:
+ resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
+ engines: {node: '>=8.6.0'}
+
fast-json-stable-stringify@2.1.0:
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
fast-levenshtein@2.0.6:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
+ fastq@1.20.1:
+ resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
+
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@@ -724,6 +809,10 @@ packages:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'}
+ fill-range@7.1.1:
+ resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
+ engines: {node: '>=8'}
+
find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
@@ -748,6 +837,9 @@ packages:
resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
engines: {node: '>= 6'}
+ fraction.js@5.3.4:
+ resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
+
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -768,6 +860,10 @@ packages:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
+ glob-parent@5.1.2:
+ resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
+ engines: {node: '>= 6'}
+
glob-parent@6.0.2:
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
engines: {node: '>=10.13.0'}
@@ -824,6 +920,14 @@ packages:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
+ is-binary-path@2.1.0:
+ resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
+ engines: {node: '>=8'}
+
+ is-core-module@2.16.2:
+ resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==}
+ engines: {node: '>= 0.4'}
+
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
@@ -832,9 +936,17 @@ packages:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
+ is-number@7.0.0:
+ resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
+ engines: {node: '>=0.12.0'}
+
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+ jiti@1.21.7:
+ resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
+ hasBin: true
+
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -938,6 +1050,13 @@ packages:
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
engines: {node: '>= 12.0.0'}
+ lilconfig@3.1.3:
+ resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
+ engines: {node: '>=14'}
+
+ lines-and-columns@1.2.4:
+ resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
+
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
@@ -949,6 +1068,14 @@ packages:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
+ merge2@1.4.1:
+ resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
+ engines: {node: '>= 8'}
+
+ micromatch@4.0.8:
+ resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
+ engines: {node: '>=8.6'}
+
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
@@ -964,6 +1091,9 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+ mz@2.7.0:
+ resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
+
nanoid@3.3.13:
resolution: {integrity: sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -976,6 +1106,18 @@ packages:
resolution: {integrity: sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==}
engines: {node: '>=18'}
+ normalize-path@3.0.0:
+ resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
+ engines: {node: '>=0.10.0'}
+
+ object-assign@4.1.1:
+ resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
+ engines: {node: '>=0.10.0'}
+
+ object-hash@3.0.0:
+ resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
+ engines: {node: '>= 6'}
+
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@@ -996,13 +1138,71 @@ packages:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
+ path-parse@1.0.7:
+ resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
+
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+ picomatch@2.3.2:
+ resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
+ engines: {node: '>=8.6'}
+
picomatch@4.0.4:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'}
+ pify@2.3.0:
+ resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
+ engines: {node: '>=0.10.0'}
+
+ pirates@4.0.7:
+ resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
+ engines: {node: '>= 6'}
+
+ postcss-import@15.1.0:
+ resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
+ engines: {node: '>=14.0.0'}
+ peerDependencies:
+ postcss: ^8.0.0
+
+ postcss-js@4.1.0:
+ resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==}
+ engines: {node: ^12 || ^14 || >= 16}
+ peerDependencies:
+ postcss: ^8.4.21
+
+ postcss-load-config@6.0.1:
+ resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==}
+ engines: {node: '>= 18'}
+ peerDependencies:
+ jiti: '>=1.21.0'
+ postcss: '>=8.0.9'
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ jiti:
+ optional: true
+ postcss:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
+ postcss-nested@6.2.0:
+ resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==}
+ engines: {node: '>=12.0'}
+ peerDependencies:
+ postcss: ^8.2.14
+
+ postcss-selector-parser@6.1.4:
+ resolution: {integrity: sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==}
+ engines: {node: '>=4'}
+
+ postcss-value-parser@4.2.0:
+ resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
+
postcss@8.5.15:
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
engines: {node: ^10 || ^12 || >=14}
@@ -1019,6 +1219,9 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
+ queue-microtask@1.2.3:
+ resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
+
react-dom@19.2.7:
resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==}
peerDependencies:
@@ -1060,6 +1263,13 @@ packages:
resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==}
engines: {node: '>=0.10.0'}
+ read-cache@1.0.0:
+ resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
+
+ readdirp@3.6.0:
+ resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
+ engines: {node: '>=8.10.0'}
+
recharts@3.8.1:
resolution: {integrity: sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==}
engines: {node: '>=18'}
@@ -1079,11 +1289,23 @@ packages:
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
+ resolve@1.22.12:
+ resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==}
+ engines: {node: '>= 0.4'}
+ hasBin: true
+
+ reusify@1.1.0:
+ resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
+ engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
+
rolldown@1.0.3:
resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
+ run-parallel@1.2.0:
+ resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
+
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@@ -1111,6 +1333,27 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
+ sucrase@3.35.1:
+ resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==}
+ engines: {node: '>=16 || 14 >=14.17'}
+ hasBin: true
+
+ supports-preserve-symlinks-flag@1.0.0:
+ resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
+ engines: {node: '>= 0.4'}
+
+ tailwindcss@3.4.19:
+ resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==}
+ engines: {node: '>=14.0.0'}
+ hasBin: true
+
+ thenify-all@1.6.0:
+ resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
+ engines: {node: '>=0.8'}
+
+ thenify@3.3.1:
+ resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
+
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
@@ -1118,12 +1361,19 @@ packages:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'}
+ to-regex-range@5.0.1:
+ resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
+ engines: {node: '>=8.0'}
+
ts-api-utils@2.5.0:
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
engines: {node: '>=18.12'}
peerDependencies:
typescript: '>=4.8.4'
+ ts-interface-checker@0.1.13:
+ resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
+
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -1160,6 +1410,9 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ util-deprecate@1.0.2:
+ resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+
victory-vendor@37.3.6:
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
@@ -1233,6 +1486,8 @@ packages:
snapshots:
+ '@alloc/quick-lru@5.2.0': {}
+
'@babel/code-frame@7.29.7':
dependencies:
'@babel/helper-validator-identifier': 7.29.7
@@ -1349,9 +1604,9 @@ snapshots:
tslib: 2.8.1
optional: true
- '@eslint-community/eslint-utils@4.9.1(eslint@10.5.0)':
+ '@eslint-community/eslint-utils@4.9.1(eslint@10.5.0(jiti@1.21.7))':
dependencies:
- eslint: 10.5.0
+ eslint: 10.5.0(jiti@1.21.7)
eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.2': {}
@@ -1372,9 +1627,9 @@ snapshots:
dependencies:
'@types/json-schema': 7.0.15
- '@eslint/js@10.0.1(eslint@10.5.0)':
+ '@eslint/js@10.0.1(eslint@10.5.0(jiti@1.21.7))':
optionalDependencies:
- eslint: 10.5.0
+ eslint: 10.5.0(jiti@1.21.7)
'@eslint/object-schema@3.0.5': {}
@@ -1425,6 +1680,18 @@ snapshots:
'@tybys/wasm-util': 0.10.2
optional: true
+ '@nodelib/fs.scandir@2.1.5':
+ dependencies:
+ '@nodelib/fs.stat': 2.0.5
+ run-parallel: 1.2.0
+
+ '@nodelib/fs.stat@2.0.5': {}
+
+ '@nodelib/fs.walk@1.2.8':
+ dependencies:
+ '@nodelib/fs.scandir': 2.1.5
+ fastq: 1.20.1
+
'@oxc-project/types@0.133.0': {}
'@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1))(react@19.2.7)':
@@ -1556,15 +1823,15 @@ snapshots:
'@types/use-sync-external-store@0.0.6': {}
- '@typescript-eslint/eslint-plugin@8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.5.0)(typescript@6.0.3))(eslint@10.5.0)(typescript@6.0.3)':
+ '@typescript-eslint/eslint-plugin@8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.5.0(jiti@1.21.7))(typescript@6.0.3))(eslint@10.5.0(jiti@1.21.7))(typescript@6.0.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
- '@typescript-eslint/parser': 8.61.1(eslint@10.5.0)(typescript@6.0.3)
+ '@typescript-eslint/parser': 8.61.1(eslint@10.5.0(jiti@1.21.7))(typescript@6.0.3)
'@typescript-eslint/scope-manager': 8.61.1
- '@typescript-eslint/type-utils': 8.61.1(eslint@10.5.0)(typescript@6.0.3)
- '@typescript-eslint/utils': 8.61.1(eslint@10.5.0)(typescript@6.0.3)
+ '@typescript-eslint/type-utils': 8.61.1(eslint@10.5.0(jiti@1.21.7))(typescript@6.0.3)
+ '@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@1.21.7))(typescript@6.0.3)
'@typescript-eslint/visitor-keys': 8.61.1
- eslint: 10.5.0
+ eslint: 10.5.0(jiti@1.21.7)
ignore: 7.0.5
natural-compare: 1.4.0
ts-api-utils: 2.5.0(typescript@6.0.3)
@@ -1572,14 +1839,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/parser@8.61.1(eslint@10.5.0)(typescript@6.0.3)':
+ '@typescript-eslint/parser@8.61.1(eslint@10.5.0(jiti@1.21.7))(typescript@6.0.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.61.1
'@typescript-eslint/types': 8.61.1
'@typescript-eslint/typescript-estree': 8.61.1(typescript@6.0.3)
'@typescript-eslint/visitor-keys': 8.61.1
debug: 4.4.3
- eslint: 10.5.0
+ eslint: 10.5.0(jiti@1.21.7)
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
@@ -1602,13 +1869,13 @@ snapshots:
dependencies:
typescript: 6.0.3
- '@typescript-eslint/type-utils@8.61.1(eslint@10.5.0)(typescript@6.0.3)':
+ '@typescript-eslint/type-utils@8.61.1(eslint@10.5.0(jiti@1.21.7))(typescript@6.0.3)':
dependencies:
'@typescript-eslint/types': 8.61.1
'@typescript-eslint/typescript-estree': 8.61.1(typescript@6.0.3)
- '@typescript-eslint/utils': 8.61.1(eslint@10.5.0)(typescript@6.0.3)
+ '@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@1.21.7))(typescript@6.0.3)
debug: 4.4.3
- eslint: 10.5.0
+ eslint: 10.5.0(jiti@1.21.7)
ts-api-utils: 2.5.0(typescript@6.0.3)
typescript: 6.0.3
transitivePeerDependencies:
@@ -1631,13 +1898,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/utils@8.61.1(eslint@10.5.0)(typescript@6.0.3)':
+ '@typescript-eslint/utils@8.61.1(eslint@10.5.0(jiti@1.21.7))(typescript@6.0.3)':
dependencies:
- '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0)
+ '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@1.21.7))
'@typescript-eslint/scope-manager': 8.61.1
'@typescript-eslint/types': 8.61.1
'@typescript-eslint/typescript-estree': 8.61.1(typescript@6.0.3)
- eslint: 10.5.0
+ eslint: 10.5.0(jiti@1.21.7)
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
@@ -1647,10 +1914,10 @@ snapshots:
'@typescript-eslint/types': 8.61.1
eslint-visitor-keys: 5.0.1
- '@vitejs/plugin-react@6.0.2(vite@8.0.16(@types/node@24.13.2))':
+ '@vitejs/plugin-react@6.0.2(vite@8.0.16(@types/node@24.13.2)(jiti@1.21.7))':
dependencies:
'@rolldown/pluginutils': 1.0.1
- vite: 8.0.16(@types/node@24.13.2)
+ vite: 8.0.16(@types/node@24.13.2)(jiti@1.21.7)
acorn-jsx@5.3.2(acorn@8.17.0):
dependencies:
@@ -1671,8 +1938,26 @@ snapshots:
json-schema-traverse: 0.4.1
uri-js: 4.4.1
+ any-promise@1.3.0: {}
+
+ anymatch@3.1.3:
+ dependencies:
+ normalize-path: 3.0.0
+ picomatch: 2.3.2
+
+ arg@5.0.2: {}
+
asynckit@0.4.0: {}
+ autoprefixer@10.5.1(postcss@8.5.15):
+ dependencies:
+ browserslist: 4.28.4
+ caniuse-lite: 1.0.30001799
+ fraction.js: 5.3.4
+ picocolors: 1.1.1
+ postcss: 8.5.15
+ postcss-value-parser: 4.2.0
+
axios@1.18.0:
dependencies:
follow-redirects: 1.16.0
@@ -1687,10 +1972,16 @@ snapshots:
baseline-browser-mapping@2.10.38: {}
+ binary-extensions@2.3.0: {}
+
brace-expansion@5.0.6:
dependencies:
balanced-match: 4.0.4
+ braces@3.0.3:
+ dependencies:
+ fill-range: 7.1.1
+
browserslist@4.28.2:
dependencies:
baseline-browser-mapping: 2.10.38
@@ -1699,19 +1990,43 @@ snapshots:
node-releases: 2.0.48
update-browserslist-db: 1.2.3(browserslist@4.28.2)
+ browserslist@4.28.4:
+ dependencies:
+ baseline-browser-mapping: 2.10.38
+ caniuse-lite: 1.0.30001799
+ electron-to-chromium: 1.5.376
+ node-releases: 2.0.48
+ update-browserslist-db: 1.2.3(browserslist@4.28.4)
+
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
function-bind: 1.1.2
+ camelcase-css@2.0.1: {}
+
caniuse-lite@1.0.30001799: {}
+ chokidar@3.6.0:
+ dependencies:
+ anymatch: 3.1.3
+ braces: 3.0.3
+ glob-parent: 5.1.2
+ is-binary-path: 2.1.0
+ is-glob: 4.0.3
+ normalize-path: 3.0.0
+ readdirp: 3.6.0
+ optionalDependencies:
+ fsevents: 2.3.3
+
clsx@2.1.1: {}
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
+ commander@4.1.1: {}
+
convert-source-map@2.0.0: {}
cookie@1.1.1: {}
@@ -1722,6 +2037,8 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
+ cssesc@3.0.0: {}
+
csstype@3.2.3: {}
d3-array@3.2.4:
@@ -1774,6 +2091,10 @@ snapshots:
detect-libc@2.1.2: {}
+ didyoumean@1.2.2: {}
+
+ dlv@1.1.3: {}
+
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -1803,20 +2124,20 @@ snapshots:
escape-string-regexp@4.0.0: {}
- eslint-plugin-react-hooks@7.1.1(eslint@10.5.0):
+ eslint-plugin-react-hooks@7.1.1(eslint@10.5.0(jiti@1.21.7)):
dependencies:
'@babel/core': 7.29.7
'@babel/parser': 7.29.7
- eslint: 10.5.0
+ eslint: 10.5.0(jiti@1.21.7)
hermes-parser: 0.25.1
zod: 4.4.3
zod-validation-error: 4.0.2(zod@4.4.3)
transitivePeerDependencies:
- supports-color
- eslint-plugin-react-refresh@0.5.3(eslint@10.5.0):
+ eslint-plugin-react-refresh@0.5.3(eslint@10.5.0(jiti@1.21.7)):
dependencies:
- eslint: 10.5.0
+ eslint: 10.5.0(jiti@1.21.7)
eslint-scope@9.1.2:
dependencies:
@@ -1829,9 +2150,9 @@ snapshots:
eslint-visitor-keys@5.0.1: {}
- eslint@10.5.0:
+ eslint@10.5.0(jiti@1.21.7):
dependencies:
- '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0)
+ '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@1.21.7))
'@eslint-community/regexpp': 4.12.2
'@eslint/config-array': 0.23.5
'@eslint/config-helpers': 0.6.0
@@ -1861,6 +2182,8 @@ snapshots:
minimatch: 10.2.5
natural-compare: 1.4.0
optionator: 0.9.4
+ optionalDependencies:
+ jiti: 1.21.7
transitivePeerDependencies:
- supports-color
@@ -1886,10 +2209,22 @@ snapshots:
fast-deep-equal@3.1.3: {}
+ fast-glob@3.3.3:
+ dependencies:
+ '@nodelib/fs.stat': 2.0.5
+ '@nodelib/fs.walk': 1.2.8
+ glob-parent: 5.1.2
+ merge2: 1.4.1
+ micromatch: 4.0.8
+
fast-json-stable-stringify@2.1.0: {}
fast-levenshtein@2.0.6: {}
+ fastq@1.20.1:
+ dependencies:
+ reusify: 1.1.0
+
fdir@6.5.0(picomatch@4.0.4):
optionalDependencies:
picomatch: 4.0.4
@@ -1898,6 +2233,10 @@ snapshots:
dependencies:
flat-cache: 4.0.1
+ fill-range@7.1.1:
+ dependencies:
+ to-regex-range: 5.0.1
+
find-up@5.0.0:
dependencies:
locate-path: 6.0.0
@@ -1920,6 +2259,8 @@ snapshots:
hasown: 2.0.4
mime-types: 2.1.35
+ fraction.js@5.3.4: {}
+
fsevents@2.3.3:
optional: true
@@ -1945,6 +2286,10 @@ snapshots:
dunder-proto: 1.0.1
es-object-atoms: 1.1.2
+ glob-parent@5.1.2:
+ dependencies:
+ is-glob: 4.0.3
+
glob-parent@6.0.2:
dependencies:
is-glob: 4.0.3
@@ -1988,14 +2333,26 @@ snapshots:
internmap@2.0.3: {}
+ is-binary-path@2.1.0:
+ dependencies:
+ binary-extensions: 2.3.0
+
+ is-core-module@2.16.2:
+ dependencies:
+ hasown: 2.0.4
+
is-extglob@2.1.1: {}
is-glob@4.0.3:
dependencies:
is-extglob: 2.1.1
+ is-number@7.0.0: {}
+
isexe@2.0.0: {}
+ jiti@1.21.7: {}
+
js-tokens@4.0.0: {}
jsesc@3.1.0: {}
@@ -2066,6 +2423,10 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.32.0
lightningcss-win32-x64-msvc: 1.32.0
+ lilconfig@3.1.3: {}
+
+ lines-and-columns@1.2.4: {}
+
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
@@ -2076,6 +2437,13 @@ snapshots:
math-intrinsics@1.1.0: {}
+ merge2@1.4.1: {}
+
+ micromatch@4.0.8:
+ dependencies:
+ braces: 3.0.3
+ picomatch: 2.3.2
+
mime-db@1.52.0: {}
mime-types@2.1.35:
@@ -2088,12 +2456,24 @@ snapshots:
ms@2.1.3: {}
+ mz@2.7.0:
+ dependencies:
+ any-promise: 1.3.0
+ object-assign: 4.1.1
+ thenify-all: 1.6.0
+
nanoid@3.3.13: {}
natural-compare@1.4.0: {}
node-releases@2.0.48: {}
+ normalize-path@3.0.0: {}
+
+ object-assign@4.1.1: {}
+
+ object-hash@3.0.0: {}
+
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
@@ -2115,10 +2495,49 @@ snapshots:
path-key@3.1.1: {}
+ path-parse@1.0.7: {}
+
picocolors@1.1.1: {}
+ picomatch@2.3.2: {}
+
picomatch@4.0.4: {}
+ pify@2.3.0: {}
+
+ pirates@4.0.7: {}
+
+ postcss-import@15.1.0(postcss@8.5.15):
+ dependencies:
+ postcss: 8.5.15
+ postcss-value-parser: 4.2.0
+ read-cache: 1.0.0
+ resolve: 1.22.12
+
+ postcss-js@4.1.0(postcss@8.5.15):
+ dependencies:
+ camelcase-css: 2.0.1
+ postcss: 8.5.15
+
+ postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.15):
+ dependencies:
+ lilconfig: 3.1.3
+ optionalDependencies:
+ jiti: 1.21.7
+ postcss: 8.5.15
+
+ postcss-nested@6.2.0(postcss@8.5.15):
+ dependencies:
+ postcss: 8.5.15
+ postcss-selector-parser: 6.1.4
+
+ postcss-selector-parser@6.1.4:
+ dependencies:
+ cssesc: 3.0.0
+ util-deprecate: 1.0.2
+
+ postcss-value-parser@4.2.0: {}
+
postcss@8.5.15:
dependencies:
nanoid: 3.3.13
@@ -2131,6 +2550,8 @@ snapshots:
punycode@2.3.1: {}
+ queue-microtask@1.2.3: {}
+
react-dom@19.2.7(react@19.2.7):
dependencies:
react: 19.2.7
@@ -2163,6 +2584,14 @@ snapshots:
react@19.2.7: {}
+ read-cache@1.0.0:
+ dependencies:
+ pify: 2.3.0
+
+ readdirp@3.6.0:
+ dependencies:
+ picomatch: 2.3.2
+
recharts@3.8.1(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1):
dependencies:
'@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1))(react@19.2.7)
@@ -2191,6 +2620,15 @@ snapshots:
reselect@5.1.1: {}
+ resolve@1.22.12:
+ dependencies:
+ es-errors: 1.3.0
+ is-core-module: 2.16.2
+ path-parse: 1.0.7
+ supports-preserve-symlinks-flag: 1.0.0
+
+ reusify@1.1.0: {}
+
rolldown@1.0.3:
dependencies:
'@oxc-project/types': 0.133.0
@@ -2212,6 +2650,10 @@ snapshots:
'@rolldown/binding-win32-arm64-msvc': 1.0.3
'@rolldown/binding-win32-x64-msvc': 1.0.3
+ run-parallel@1.2.0:
+ dependencies:
+ queue-microtask: 1.2.3
+
scheduler@0.27.0: {}
semver@6.3.1: {}
@@ -2228,6 +2670,54 @@ snapshots:
source-map-js@1.2.1: {}
+ sucrase@3.35.1:
+ dependencies:
+ '@jridgewell/gen-mapping': 0.3.13
+ commander: 4.1.1
+ lines-and-columns: 1.2.4
+ mz: 2.7.0
+ pirates: 4.0.7
+ tinyglobby: 0.2.17
+ ts-interface-checker: 0.1.13
+
+ supports-preserve-symlinks-flag@1.0.0: {}
+
+ tailwindcss@3.4.19:
+ dependencies:
+ '@alloc/quick-lru': 5.2.0
+ arg: 5.0.2
+ chokidar: 3.6.0
+ didyoumean: 1.2.2
+ dlv: 1.1.3
+ fast-glob: 3.3.3
+ glob-parent: 6.0.2
+ is-glob: 4.0.3
+ jiti: 1.21.7
+ lilconfig: 3.1.3
+ micromatch: 4.0.8
+ normalize-path: 3.0.0
+ object-hash: 3.0.0
+ picocolors: 1.1.1
+ postcss: 8.5.15
+ postcss-import: 15.1.0(postcss@8.5.15)
+ postcss-js: 4.1.0(postcss@8.5.15)
+ postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.15)
+ postcss-nested: 6.2.0(postcss@8.5.15)
+ postcss-selector-parser: 6.1.4
+ resolve: 1.22.12
+ sucrase: 3.35.1
+ transitivePeerDependencies:
+ - tsx
+ - yaml
+
+ thenify-all@1.6.0:
+ dependencies:
+ thenify: 3.3.1
+
+ thenify@3.3.1:
+ dependencies:
+ any-promise: 1.3.0
+
tiny-invariant@1.3.3: {}
tinyglobby@0.2.17:
@@ -2235,10 +2725,16 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
+ to-regex-range@5.0.1:
+ dependencies:
+ is-number: 7.0.0
+
ts-api-utils@2.5.0(typescript@6.0.3):
dependencies:
typescript: 6.0.3
+ ts-interface-checker@0.1.13: {}
+
tslib@2.8.1:
optional: true
@@ -2246,13 +2742,13 @@ snapshots:
dependencies:
prelude-ls: 1.2.1
- typescript-eslint@8.61.1(eslint@10.5.0)(typescript@6.0.3):
+ typescript-eslint@8.61.1(eslint@10.5.0(jiti@1.21.7))(typescript@6.0.3):
dependencies:
- '@typescript-eslint/eslint-plugin': 8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.5.0)(typescript@6.0.3))(eslint@10.5.0)(typescript@6.0.3)
- '@typescript-eslint/parser': 8.61.1(eslint@10.5.0)(typescript@6.0.3)
+ '@typescript-eslint/eslint-plugin': 8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.5.0(jiti@1.21.7))(typescript@6.0.3))(eslint@10.5.0(jiti@1.21.7))(typescript@6.0.3)
+ '@typescript-eslint/parser': 8.61.1(eslint@10.5.0(jiti@1.21.7))(typescript@6.0.3)
'@typescript-eslint/typescript-estree': 8.61.1(typescript@6.0.3)
- '@typescript-eslint/utils': 8.61.1(eslint@10.5.0)(typescript@6.0.3)
- eslint: 10.5.0
+ '@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@1.21.7))(typescript@6.0.3)
+ eslint: 10.5.0(jiti@1.21.7)
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
@@ -2267,6 +2763,12 @@ snapshots:
escalade: 3.2.0
picocolors: 1.1.1
+ update-browserslist-db@1.2.3(browserslist@4.28.4):
+ dependencies:
+ browserslist: 4.28.4
+ escalade: 3.2.0
+ picocolors: 1.1.1
+
uri-js@4.4.1:
dependencies:
punycode: 2.3.1
@@ -2275,6 +2777,8 @@ snapshots:
dependencies:
react: 19.2.7
+ util-deprecate@1.0.2: {}
+
victory-vendor@37.3.6:
dependencies:
'@types/d3-array': 3.2.2
@@ -2292,7 +2796,7 @@ snapshots:
d3-time: 3.1.0
d3-timer: 3.0.1
- vite@8.0.16(@types/node@24.13.2):
+ vite@8.0.16(@types/node@24.13.2)(jiti@1.21.7):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
@@ -2302,6 +2806,7 @@ snapshots:
optionalDependencies:
'@types/node': 24.13.2
fsevents: 2.3.3
+ jiti: 1.21.7
which@2.0.2:
dependencies:
diff --git a/web/postcss.config.js b/web/postcss.config.js
new file mode 100644
index 0000000..2e7af2b
--- /dev/null
+++ b/web/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+}
diff --git a/web/src/App.css b/web/src/App.css
index 55901b2..4107cbc 100644
--- a/web/src/App.css
+++ b/web/src/App.css
@@ -1,15 +1,13 @@
/* ============================================================
RUNRIGHT — VINTAGE / CUPHEAD-INSPIRED THEME
- Warm parchment · Art deco typography · Clean & sleek
+ Font imports + global CSS tokens + dark-mode surfaces.
+ Layout/component styles live as Tailwind classes in TSX.
============================================================ */
-:root {
- --serif: 'Playfair Display', Georgia, serif;
- --deco: 'Bebas Neue', Impact, sans-serif;
- --sans: 'Lato', system-ui, sans-serif;
- --mono: ui-monospace, Consolas, 'Courier New', monospace;
+@import url('https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Playfair+Display:wght@700;900&family=Lato:wght@400;700&display=swap');
- /* Palette */
+/* ── CSS custom properties ── */
+:root {
--cream: #FBF0DC;
--cream-alt: #F3E2C2;
--paper: #FFFDF7;
@@ -23,190 +21,171 @@
--navy: #1B3361;
--border: #D4B896;
--border-dark: #B8946A;
- --shadow: 3px 3px 0 rgba(92, 58, 30, .18);
- --shadow-lg: 6px 6px 0 rgba(92, 58, 30, .14);
+ --shadow: 3px 3px 0 rgba(92,58,30,.18);
+ --shadow-lg: 6px 6px 0 rgba(92,58,30,.14);
+ --serif: 'Playfair Display', Georgia, serif;
+ --deco: 'Bebas Neue', Impact, sans-serif;
+ --sans: 'Lato', system-ui, sans-serif;
+ --mono: ui-monospace, Consolas, 'Courier New', monospace;
+ --sidebar-bg: #2C1A0E;
+ --sidebar-fg: #FBF0DC;
+ --sidebar-fg-muted: rgba(251,240,220,.62);
+ --sidebar-fg-soft: rgba(251,240,220,.42);
+ --sidebar-border: rgba(251,240,220,.14);
+ --sidebar-hover-bg: rgba(251,240,220,.07);
+ --sidebar-active-bg: rgba(251,240,220,.10);
}
-/* ── Reset ── */
-*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+/* ── Dark-mode token overrides — toggled by adding .dark to ── */
+html.dark {
+ --cream: #130C05;
+ --cream-alt: #1E1209;
+ --paper: #231810;
+ --text: #F5E4C8;
+ --text-mid: #C09870;
+ --text-light: #B58D63;
+ --border: #3D2810;
+ --border-dark: #5A3A18;
+ --shadow: 3px 3px 0 rgba(0,0,0,.35);
+ --shadow-lg: 6px 6px 0 rgba(0,0,0,.25);
+ --sidebar-bg: #231810;
+ --sidebar-fg: #F9E9CD;
+ --sidebar-fg-muted: rgba(249,233,205,.68);
+ --sidebar-fg-soft: rgba(249,233,205,.46);
+ --sidebar-border: rgba(249,233,205,.16);
+ --sidebar-hover-bg: rgba(249,233,205,.08);
+ --sidebar-active-bg: rgba(249,233,205,.12);
+}
-/* ── Body ── */
-body {
- font-family: var(--sans);
- background: var(--cream);
+/* Tailwind utility `bg-paper` is static light; remap in dark mode for readability. */
+html.dark .bg-paper {
+ background-color: var(--paper) !important;
color: var(--text);
- min-height: 100vh;
- font-size: 15px;
- line-height: 1.6;
}
-/* ── App shell ── */
-.app { display: flex; min-height: 100vh; }
-
-/* ── Sidebar ── */
-.sidebar {
- width: 220px;
- background: var(--text);
- padding: 28px 20px;
- display: flex;
- flex-direction: column;
- gap: 4px;
- flex-shrink: 0;
- position: sticky;
- top: 0;
- height: 100vh;
- overflow: hidden;
- white-space: nowrap;
- transition: width .28s cubic-bezier(.4,0,.2,1), padding .28s cubic-bezier(.4,0,.2,1);
-}
-.sidebar.collapsed {
- width: 0;
- padding-left: 0;
- padding-right: 0;
+/* Dark-mode surface + input contrast pass */
+html.dark .rr-card,
+html.dark .card,
+html.dark .stat-card {
+ background: #2A1D13;
+ border-color: #6A5036;
+ box-shadow: 0 10px 24px rgba(0,0,0,.28);
}
-/* ── Sidebar toggle button ── */
-.sidebar-toggle {
- position: absolute;
- top: 14px;
- left: 14px;
- z-index: 10;
- background: var(--paper);
- border: 1px solid var(--border);
- border-bottom: 2px solid var(--border-dark);
- color: var(--text-mid);
- width: 34px;
- height: 34px;
- display: flex;
- align-items: center;
- justify-content: center;
- cursor: pointer;
- box-shadow: var(--shadow);
- transition: color .15s, background .15s, transform .1s;
- flex-shrink: 0;
-}
-.sidebar-toggle:hover {
- color: var(--text);
- background: var(--cream-alt);
- transform: translateY(-1px);
+html.dark .rr-input,
+html.dark .rr-select,
+html.dark .filter-bar input,
+html.dark .filter-bar select,
+html.dark .form-group input:not([type='checkbox']):not([type='radio']) {
+ background-color: #21160E;
+ color: #F5E4C8;
+ border-color: #6C5238;
+ border-bottom-color: #94714D;
}
-/* Push content down so the toggle doesn't overlap the h1 */
-.content {
- flex: 1;
- padding: 40px 36px;
- padding-top: 64px;
- overflow-y: auto;
- background: var(--cream);
- position: relative;
+html.dark .rr-input::placeholder,
+html.dark .filter-bar input::placeholder,
+html.dark .form-group input:not([type='checkbox']):not([type='radio'])::placeholder {
+ color: #C49D73;
}
-.logo {
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- gap: 2px;
- padding-bottom: 24px;
- border-bottom: 1px solid rgba(251,240,220,.12);
- margin-bottom: 20px;
-}
-.logo-icon { font-size: 22px; }
-.logo-text {
- font-family: var(--deco);
- font-size: 22px;
- color: var(--cream);
- letter-spacing: 3px;
+html.dark .rr-input:focus,
+html.dark .rr-select:focus,
+html.dark .filter-bar input:focus,
+html.dark .filter-bar select:focus,
+html.dark .form-group input:not([type='checkbox']):not([type='radio']):focus {
+ border-color: #E8C458;
+ box-shadow: 0 0 0 2px rgba(232,196,88,.28);
}
-.nav-link {
- display: block;
- padding: 9px 12px;
- color: rgba(251,240,220,.5);
- text-decoration: none;
- font-family: var(--deco);
- font-size: 14px;
- letter-spacing: 2px;
- border-left: 3px solid transparent;
- transition: color .15s, border-color .15s, background .15s;
+/* Prevent browser autofill from applying yellow/green tints in dark mode inputs. */
+html.dark input:-webkit-autofill,
+html.dark input:-webkit-autofill:hover,
+html.dark input:-webkit-autofill:focus,
+html.dark textarea:-webkit-autofill,
+html.dark select:-webkit-autofill {
+ -webkit-text-fill-color: #F5E4C8;
+ -webkit-box-shadow: 0 0 0 1000px #21160E inset;
+ box-shadow: 0 0 0 1000px #21160E inset;
+ caret-color: #F5E4C8;
+ transition: background-color 9999s ease-out 0s;
}
-.nav-link:hover {
- color: var(--cream);
- background: rgba(251,240,220,.06);
- border-left-color: rgba(251,240,220,.3);
-}
-.nav-link.active {
- color: var(--cream);
- border-left-color: var(--gold-light);
- background: rgba(251,240,220,.08);
+
+/* ── Base ── */
+body {
+ background: var(--cream);
+ color: var(--text);
+ font-family: var(--sans);
+ font-size: 15px;
+ line-height: 1.6;
+ min-height: 100vh;
}
-/* ── Headings ── */
+/* ── Heading resets (Tailwind preflight zeroes these out) ── */
h1 {
font-family: var(--serif);
- font-size: 30px;
+ font-size: 28px;
font-weight: 900;
color: var(--text);
- margin-bottom: 28px;
- letter-spacing: -.3px;
+ margin-bottom: 20px;
+ line-height: 1.2;
}
h2 {
font-family: var(--serif);
- font-size: 17px;
+ font-size: 19px;
font-weight: 700;
color: var(--text);
margin-bottom: 14px;
+ line-height: 1.25;
+}
+h3 {
+ font-family: var(--serif);
+ font-size: 16px;
+ font-weight: 700;
+ color: var(--text);
+ margin-bottom: 10px;
+}
+code {
+ font-family: var(--mono);
+ font-size: 13px;
+ background: var(--cream-alt);
+ padding: 1px 5px;
+ border-radius: 3px;
+ color: var(--text);
}
+pre code { background: none; padding: 0; }
-/* ── Cards ── */
-.card {
- background: var(--paper);
- border: 1px solid var(--border);
- padding: 24px;
- margin-bottom: 20px;
- box-shadow: var(--shadow);
+/* ── Fade-in ── */
+@keyframes fadein {
+ from { opacity: 0; transform: translateY(6px); }
+ to { opacity: 1; transform: translateY(0); }
}
+.fadein { animation: fadein .3s ease-out both; }
-/* ── Stat grid ── */
-.stats-row {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
- gap: 14px;
- margin-bottom: 24px;
+/* ── Recharts dark-mode ── */
+html.dark .recharts-cartesian-grid-horizontal line,
+html.dark .recharts-cartesian-grid-vertical line { stroke: rgba(245,228,200,.1); }
+html.dark .recharts-text { fill: #C09870; }
+html.dark .recharts-default-tooltip {
+ background: #231810 !important;
+ border-color: #3D2810 !important;
+ color: #F5E4C8 !important;
}
-.stat-card {
+
+/* ── Shared primitives ── */
+
+.rr-card {
background: var(--paper);
border: 1px solid var(--border);
- border-top: 3px solid var(--gold);
- padding: 16px 14px;
+ padding: 24px;
+ margin-bottom: 20px;
box-shadow: var(--shadow);
}
-.stat-label {
- font-family: var(--deco);
- font-size: 11px;
- color: var(--text-light);
- margin-bottom: 8px;
- text-transform: uppercase;
- letter-spacing: 1.5px;
-}
-.stat-value {
- font-family: var(--deco);
- font-size: 28px;
- color: var(--navy);
- line-height: 1.1;
-}
-.stat-unit {
- font-family: var(--deco);
- font-size: 13px;
- color: var(--text-light);
- margin-left: 2px;
-}
-/* ── Table ── */
-.table-wrap { overflow-x: auto; }
-table { width: 100%; border-collapse: collapse; font-size: 14px; }
-th {
+.rr-table { width: max-content; min-width: 100%; border-collapse: collapse; font-size: 13px; table-layout: auto; }
+.rr-table th {
text-align: left;
- padding: 10px 14px;
+ padding: 9px 10px;
font-family: var(--deco);
font-size: 12px;
color: var(--text-mid);
@@ -214,47 +193,35 @@ th {
letter-spacing: 1.5px;
border-bottom: 2px solid var(--border-dark);
background: var(--cream-alt);
+ white-space: nowrap;
}
-td {
- padding: 11px 14px;
+.rr-table td {
+ padding: 9px 10px;
border-bottom: 1px solid var(--border);
color: var(--text-mid);
- font-family: var(--sans);
-}
-tr:hover td { background: var(--cream-alt); color: var(--text); }
-tr:last-child td { border-bottom: none; }
-.td-job-name {
- max-width: 200px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-.td-job-name .link-btn {
- overflow: hidden;
- text-overflow: ellipsis;
white-space: nowrap;
- max-width: 100%;
- display: block;
}
+.rr-table tr:hover td { background: var(--cream-alt); color: var(--text); }
+.rr-table tr:last-child td { border-bottom: none; }
-/* ── Badges ── */
.badge {
display: inline-block;
- padding: 2px 8px;
- font-family: var(--deco);
+ padding: 2px 9px;
+ border-radius: 999px;
font-size: 11px;
- letter-spacing: 1px;
+ font-weight: 600;
+ letter-spacing: 0.5px;
text-transform: uppercase;
border: 1px solid currentColor;
+ white-space: nowrap;
+ line-height: 1.6;
}
.badge-aws { color: var(--gold); border-color: var(--gold); background: rgba(184,134,11,.08); }
.badge-gcp { color: var(--navy); border-color: var(--navy); background: rgba(27,51,97,.08); }
.badge-github { color: #2C1A0E; border-color: #2C1A0E; background: rgba(44,26,14,.08); }
.badge-right-sized { color: #2E7D32; border-color: #2E7D32; background: rgba(46,125,50,.08); }
.badge-cheaper-option { color: var(--red); border-color: var(--red); background: rgba(194,59,34,.08); }
-.badge-more-headroom { color: #5B2D8E; border-color: #5B2D8E; background: rgba(91,45,142,.08); }
-
-/* CI platform badges */
+.badge-more-headroom { color: #5B2D8E; border-color: #5B2D8E; background: rgba(91,45,142,.08); }
.badge-ci-github { color: #24292e; border-color: #24292e; background: rgba(36,41,46,.08); }
.badge-ci-gitlab { color: #FC6D26; border-color: #FC6D26; background: rgba(252,109,38,.08); }
.badge-ci-circleci { color: #343434; border-color: #343434; background: rgba(52,52,52,.08); }
@@ -263,15 +230,68 @@ tr:last-child td { border-bottom: none; }
.badge-ci-jenkins { color: #CC3421; border-color: #CC3421; background: rgba(204,52,33,.08); }
.badge-ci-local { color: var(--text-light); border-color: var(--border); background: transparent; }
-/* ── Deltas ── */
.delta-negative { color: #2E7D32; font-weight: 700; }
.delta-positive { color: var(--red); font-weight: 700; }
.delta-neutral { color: var(--text-light); }
-/* ── Filter bar ── */
-.filter-bar { display: flex; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; align-items: center; }
-.filter-bar input,
-.filter-bar select {
+.link-btn {
+ background: none;
+ border: none;
+ color: var(--navy);
+ cursor: pointer;
+ font-family: var(--sans);
+ font-size: 14px;
+ font-weight: 700;
+ padding: 0;
+ text-decoration: underline;
+ text-decoration-color: var(--border);
+}
+.link-btn:hover { color: var(--red); text-decoration-color: var(--red); }
+
+html.dark .link-btn {
+ color: #E8C458;
+ text-decoration-color: rgba(232,196,88,.55);
+}
+html.dark .link-btn:hover {
+ color: #F8D77A;
+ text-decoration-color: #F8D77A;
+}
+html.dark .badge-ci-github {
+ color: #F5E4C8;
+ border-color: #C09870;
+ background: rgba(192,152,112,.14);
+}
+
+.btn-rr {
+ background: var(--red);
+ color: var(--cream);
+ border: none;
+ padding: 11px 26px;
+ font-family: var(--deco);
+ font-size: 15px;
+ letter-spacing: 1.5px;
+ cursor: pointer;
+ box-shadow: var(--shadow);
+ transition: background .15s, box-shadow .08s, transform .08s;
+ display: inline-block;
+}
+.btn-rr:hover { background: var(--red-dark); box-shadow: 2px 2px 0 rgba(92,58,30,.18); transform: translate(1px,1px); }
+.btn-rr:disabled { opacity: .4; cursor: not-allowed; transform: none; }
+
+.back-link {
+ color: var(--text-mid);
+ text-decoration: none;
+ font-family: var(--deco);
+ font-size: 13px;
+ letter-spacing: 1.5px;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ margin-bottom: 24px;
+}
+.back-link:hover { color: var(--red); }
+
+.rr-input {
background: var(--paper);
border: 1px solid var(--border);
border-bottom: 2px solid var(--border-dark);
@@ -280,43 +300,284 @@ tr:last-child td { border-bottom: none; }
font-family: var(--sans);
font-size: 14px;
outline: none;
+ width: 100%;
}
-.filter-bar select {
- appearance: none;
- -webkit-appearance: none;
- padding-right: 30px;
+.rr-input::placeholder { color: var(--text-light); }
+.rr-input:focus { border-color: var(--gold); box-shadow: 0 2px 0 var(--gold); }
+
+.rr-select {
+ appearance: none; -webkit-appearance: none;
+ background: var(--paper);
+ border: 1px solid var(--border);
+ border-bottom: 2px solid var(--border-dark);
+ color: var(--text);
+ padding: 8px 30px 8px 12px;
+ font-family: var(--sans);
+ font-size: 14px;
+ outline: none;
+ cursor: pointer;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath fill='%239A7B5A' d='M0 0l5 6 5-6z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
- cursor: pointer;
}
-.filter-bar input[type="date"] {
+.rr-select:focus { border-color: var(--gold); box-shadow: 0 2px 0 var(--gold); }
+html.dark .rr-select {
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath fill='%23C09870' d='M0 0l5 6 5-6z'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 10px center;
+}
+html.dark .rr-select option { background: #231810; color: #F5E4C8; }
+
+.rr-switch-label {
+ color: var(--text-mid);
+ font-family: var(--deco);
+ font-size: 11px;
+ letter-spacing: 1.5px;
+ text-transform: uppercase;
+}
+.rr-switch {
+ appearance: none;
+ -webkit-appearance: none;
+ width: 42px;
+ height: 24px;
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ background: var(--cream-alt);
+ padding: 2px;
+ display: inline-grid;
+ align-items: center;
cursor: pointer;
- color-scheme: light;
- font-family: var(--sans);
+ margin: 0;
+ flex: 0 0 auto;
+ transition: background-color .15s ease, border-color .15s ease, box-shadow .15s ease;
+}
+.rr-switch::before {
+ content: '';
+ width: 18px;
+ height: 18px;
+ border-radius: 999px;
+ background: var(--paper);
+ box-shadow: 0 1px 2px rgba(0,0,0,.12);
+ transform: translateX(0);
+ transition: transform .15s ease, background-color .15s ease;
}
-.filter-bar input::placeholder { color: var(--text-light); }
-.filter-bar input:focus,
-.filter-bar select:focus {
+.rr-switch:checked {
+ background: rgba(184,134,11,.2);
border-color: var(--gold);
- box-shadow: 0 2px 0 var(--gold);
}
+.rr-switch:checked::before { transform: translateX(18px); }
+.rr-switch:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; }
+html.dark .rr-switch {
+ background: #2B2017;
+ border-color: #5b4733;
+}
+html.dark .rr-switch::before {
+ background: #F5E4C8;
+}
+html.dark .rr-switch:checked {
+ background: rgba(192,152,112,.25);
+ border-color: #C09870;
+}
+html.dark .rr-switch-label { color: #C4A882; }
-/* ── Links / buttons ── */
-.link-btn {
- background: none;
- border: none;
- color: var(--navy);
+.chart-wrap { height: 240px; margin-bottom: 8px; }
+
+.date-range-picker {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-wrap: wrap;
+}
+
+.date-range-label {
+ font-family: var(--deco);
+ font-size: 12px;
+ letter-spacing: 1.5px;
+ color: var(--text-light);
+}
+
+.date-range-presets {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-wrap: wrap;
+}
+
+.date-range-preset {
+ background: transparent;
+ color: var(--text-light);
+ border: 1px solid var(--border);
+ padding: 6px 11px;
+ font-family: var(--deco);
+ font-size: 13px;
+ letter-spacing: 1px;
cursor: pointer;
- font-family: var(--sans);
+ transition: background-color .12s ease, color .12s ease, border-color .12s ease;
+ white-space: nowrap;
+}
+
+.date-range-preset:hover {
+ color: var(--text-mid);
+ border-color: var(--border-dark);
+}
+
+.date-range-preset.is-active {
+ background: var(--text);
+ color: var(--cream);
+ border-color: var(--text);
+}
+
+.date-range-custom {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ margin-left: 4px;
+ flex-wrap: wrap;
+}
+
+.date-range-input {
+ width: 168px;
+ font-size: 13px;
+ color-scheme: light;
+}
+
+.date-range-input::-webkit-calendar-picker-indicator {
+ cursor: pointer;
+ opacity: .82;
+}
+
+html.dark .date-range-input {
+ /* Use light native picker UI for better visual continuity than the default dark gray popup. */
+ color-scheme: light;
+}
+
+html.dark .date-range-input::-webkit-datetime-edit,
+html.dark .date-range-input::-webkit-datetime-edit-text,
+html.dark .date-range-input::-webkit-datetime-edit-month-field,
+html.dark .date-range-input::-webkit-datetime-edit-day-field,
+html.dark .date-range-input::-webkit-datetime-edit-year-field {
+ color: var(--text);
+}
+
+html.dark .date-range-input::-webkit-calendar-picker-indicator {
+ filter: invert(86%) sepia(14%) saturate(613%) hue-rotate(340deg) brightness(95%) contrast(92%);
+ opacity: .9;
+}
+
+.date-range-sep {
+ color: var(--text-light);
font-size: 14px;
- font-weight: 700;
- padding: 0;
- text-decoration: underline;
- text-decoration-color: var(--border);
}
-.link-btn:hover { color: var(--red); text-decoration-color: var(--red); }
+.date-range-clear {
+ background: transparent;
+ border: 1px solid var(--border);
+ color: var(--text-light);
+ padding: 5px 9px;
+ font-family: var(--deco);
+ font-size: 12px;
+ cursor: pointer;
+ line-height: 1;
+ transition: color .12s ease, border-color .12s ease, background-color .12s ease;
+}
+
+.date-range-clear:hover {
+ color: var(--text-mid);
+ border-color: var(--border-dark);
+ background: var(--cream-alt);
+}
+
+html.dark .date-range-clear:hover {
+ background: #2A1D13;
+}
+
+@media (max-width: 480px) {
+ body { font-size: 14px; }
+ h1 { font-size: 23px; margin-bottom: 16px; }
+ h2 { font-size: 17px; }
+ h3 { font-size: 15px; }
+
+ .rr-card { padding: 16px 14px; margin-bottom: 16px; }
+ .stat-card { padding: 14px 10px; }
+ .stat-value { font-size: 18px !important; }
+ .stat-unit { font-size: 11px; }
+
+ .rr-table { font-size: 12px; }
+ .rr-table th,
+ .rr-table td { padding: 7px 8px; }
+
+ .chart-wrap { height: 200px; }
+
+ .date-range-picker { align-items: stretch; gap: 8px; }
+ .date-range-presets { overflow-x: auto; padding-bottom: 2px; width: 100%; flex-wrap: nowrap; }
+ .date-range-custom { margin-left: 0; width: 100%; }
+ .date-range-custom input { flex: 1 1 0; min-width: 0; }
+ .date-range-custom button { flex: 0 0 auto; }
+}
+
+/* ── Stat cards ── */
+.stat-card {
+ background: var(--paper);
+ border: 1px solid var(--border);
+ border-top: 3px solid var(--gold);
+ padding: 16px 14px;
+ box-shadow: var(--shadow);
+}
+.stat-label {
+ font-family: var(--deco);
+ font-size: 11px;
+ color: var(--text-light);
+ margin-bottom: 8px;
+ text-transform: uppercase;
+ letter-spacing: 1.5px;
+}
+.stat-value {
+ font-family: var(--deco);
+ font-size: 28px;
+ color: var(--navy);
+ line-height: 1.1;
+}
+.stat-unit {
+ font-family: var(--deco);
+ font-size: 13px;
+ color: var(--text-light);
+ margin-left: 2px;
+}
+
+/* ── Mobile-only overrides the sidebar handles via JS ── */
+@media (max-width: 767px) {
+ .sidebar-overlay {
+ display: block;
+ position: fixed;
+ inset: 0;
+ background: rgba(0,0,0,.45);
+ z-index: 40;
+ }
+}
+
+/* ── Legacy class aliases — direct CSS (no @apply with custom classes) ── */
+.card {
+ background: var(--paper);
+ border: 1px solid var(--border);
+ padding: 24px;
+ margin-bottom: 20px;
+ box-shadow: var(--shadow);
+}
+.btn {
+ background: var(--red);
+ color: var(--cream);
+ border: none;
+ padding: 11px 26px;
+ font-family: var(--deco);
+ font-size: 15px;
+ letter-spacing: 1.5px;
+ cursor: pointer;
+ box-shadow: var(--shadow);
+ transition: background .15s, box-shadow .08s, transform .08s;
+ display: inline-block;
+}
+.btn:hover { background: var(--red-dark); box-shadow: 2px 2px 0 rgba(92,58,30,.18); transform: translate(1px,1px); }
+.btn:disabled { opacity: .4; cursor: not-allowed; transform: none; }
.back {
color: var(--text-mid);
text-decoration: none;
@@ -332,15 +593,77 @@ tr:last-child td { border-bottom: none; }
.empty {
text-align: center;
- padding: 64px;
+ padding: 64px 24px;
color: var(--text-light);
font-family: var(--serif);
font-style: italic;
font-size: 18px;
}
-/* ── Settings form ── */
+/* Table wrapper — enables horizontal scroll on small screens */
+.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
+
+/* Filter bar */
+.filter-bar {
+ display: flex;
+ gap: 10px;
+ margin-bottom: 20px;
+ flex-wrap: wrap;
+ align-items: center;
+}
+.filter-bar input,
+.filter-bar select {
+ background: var(--paper);
+ border: 1px solid var(--border);
+ border-bottom: 2px solid var(--border-dark);
+ color: var(--text);
+ padding: 8px 12px;
+ font-family: var(--sans);
+ font-size: 14px;
+ outline: none;
+ width: auto;
+ min-width: 0;
+}
+.filter-bar select {
+ appearance: none; -webkit-appearance: none;
+ padding-right: 30px;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath fill='%239A7B5A' d='M0 0l5 6 5-6z'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 10px center;
+ cursor: pointer;
+}
+.filter-bar input::placeholder { color: var(--text-light); }
+.filter-bar input:focus,
+.filter-bar select:focus { border-color: var(--gold); box-shadow: 0 2px 0 var(--gold); }
+html.dark .filter-bar select {
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath fill='%23C09870' d='M0 0l5 6 5-6z'/%3E%3C/svg%3E");
+}
+html.dark .filter-bar select option { background: #231810; color: #F5E4C8; }
+@media (max-width: 639px) {
+ .filter-bar input,
+ .filter-bar select { width: 100%; }
+}
+
+/* Stats row */
+.stats-row {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
+ gap: 14px;
+ margin-bottom: 24px;
+}
+
+/* Job-name cell sized to content */
+.td-job-name {
+ max-width: none;
+ overflow: visible;
+ text-overflow: clip;
+ white-space: nowrap;
+}
+.td-job-name .link-btn { display: block; overflow: visible; text-overflow: clip; white-space: nowrap; max-width: none; }
+
+/* Settings form */
.settings-form { max-width: 480px; }
+.settings-form.settings-form-wide { max-width: none; }
.form-group { margin-bottom: 22px; }
.form-group label {
display: block;
@@ -351,8 +674,7 @@ tr:last-child td { border-bottom: none; }
letter-spacing: 1.5px;
text-transform: uppercase;
}
-.form-group input {
- width: 100%;
+.form-group input:not([type='checkbox']):not([type='radio']) {
background: var(--paper);
border: 1px solid var(--border);
border-bottom: 2px solid var(--border-dark);
@@ -361,15 +683,48 @@ tr:last-child td { border-bottom: none; }
font-family: var(--sans);
font-size: 14px;
outline: none;
+ width: 100%;
}
-.form-group input::placeholder { color: var(--text-light); }
-.form-group input:focus {
- border-color: var(--gold);
- box-shadow: 0 2px 0 var(--gold);
+.form-group input:not([type='checkbox']):not([type='radio'])::placeholder { color: var(--text-light); }
+.form-group input:not([type='checkbox']):not([type='radio']):focus { border-color: var(--gold); box-shadow: 0 2px 0 var(--gold); }
+
+.form-group label.rr-switch-label {
+ display: inline-flex;
+ width: auto;
+ margin-bottom: 0;
}
-/* ── Button ── */
-.btn {
+.rr-switch-row {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.rr-switch-state {
+ display: inline-flex;
+ align-items: center;
+ padding: 2px 8px;
+ border-radius: 999px;
+ border: 1px solid var(--border);
+ background: var(--cream-alt);
+ color: var(--text-mid);
+ font-family: var(--deco);
+ font-size: 11px;
+ letter-spacing: 1px;
+}
+
+.rr-switch-state.on {
+ border-color: #2E7D32;
+ background: rgba(46,125,50,.12);
+ color: #2E7D32;
+}
+
+.rr-switch-state.off {
+ border-color: var(--border-dark);
+ background: rgba(154,123,90,.1);
+ color: var(--text-light);
+}
+.submit {
background: var(--red);
color: var(--cream);
border: none;
@@ -380,61 +735,8 @@ tr:last-child td { border-bottom: none; }
cursor: pointer;
box-shadow: var(--shadow);
transition: background .15s, box-shadow .08s, transform .08s;
+ display: inline-block;
+ margin-top: 8px;
}
-.btn:hover {
- background: var(--red-dark);
- box-shadow: 2px 2px 0 rgba(92,58,30,.18);
- transform: translate(1px, 1px);
-}
-.btn:disabled { opacity: .4; cursor: not-allowed; transform: none; }
-
-/* ── Chart ── */
-.chart-wrap { height: 240px; margin-bottom: 8px; }
-
-/* ── Fade-in ── */
-@keyframes fadein {
- from { opacity: 0; transform: translateY(6px); }
- to { opacity: 1; transform: translateY(0); }
-}
-.fadein { animation: fadein .3s ease-out both; }
-
-/* ── Dark mode ──────────────────────────────────────────── */
-.app.dark-mode {
- --cream: #130C05;
- --cream-alt: #1E1209;
- --paper: #231810;
- --text: #F5E4C8;
- --text-mid: #C09870;
- --text-light: #9A7B5A;
- --border: #3D2810;
- --border-dark: #5A3A18;
- --shadow: 3px 3px 0 rgba(0,0,0,.35);
- --shadow-lg: 6px 6px 0 rgba(0,0,0,.25);
-}
-
-/* Sidebar stays dark — override vars that would lighten it */
-.app.dark-mode .sidebar { background: #0A0603; }
-.app.dark-mode .logo-text { color: #F5E4C8; }
-.app.dark-mode .nav-link:hover { color: #F5E4C8; }
-.app.dark-mode .nav-link.active { color: #F5E4C8; }
-
-/* Recharts in dark mode */
-.app.dark-mode .recharts-cartesian-grid-horizontal line,
-.app.dark-mode .recharts-cartesian-grid-vertical line {
- stroke: rgba(245,228,200,.1);
-}
-.app.dark-mode .recharts-text { fill: #C09870; }
-.app.dark-mode .recharts-default-tooltip {
- background: #231810 !important;
- border-color: #3D2810 !important;
- color: #F5E4C8 !important;
-}
+.submit:hover { background: var(--red-dark); }
-/* Filter controls in dark mode */
-.app.dark-mode .filter-bar select {
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath fill='%23C09870' d='M0 0l5 6 5-6z'/%3E%3C/svg%3E");
-}
-.app.dark-mode .filter-bar select option {
- background: #231810;
- color: #F5E4C8;
-}
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 0f1beb7..3db78b8 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -1,11 +1,15 @@
-import { useState, type CSSProperties } from 'react'
+import { useEffect, useState } from 'react'
import { BrowserRouter, Routes, Route, NavLink, Link, Outlet, Navigate, useNavigate } from 'react-router-dom'
import JobsPage from './pages/JobsPage'
import JobDetailPage from './pages/JobDetailPage'
import JobGroupPage from './pages/JobGroupPage'
import CatalogPage from './pages/CatalogPage'
import SettingsPage from './pages/SettingsPage'
+import PoliciesPage from './pages/PoliciesPage'
+import AlertsPage from './pages/AlertsPage'
import LoginPage from './pages/LoginPage'
+import ReposPage from './pages/ReposPage'
+import RepoDetailPage from './pages/RepoDetailPage'
import { logout } from './api'
import LogoMark from './components/LogoMark'
import './App.css'
@@ -19,9 +23,18 @@ export default function App() {
}
function AppRoutes() {
- const [authed, setAuthed] = useState(false)
+ const [authed, setAuthed] = useState(() => {
+ if (typeof window === 'undefined') return false
+ // Dev UX: skip the login gate by default when running the Vite dev server.
+ if (import.meta.env.DEV) return true
+ return localStorage.getItem('rr-auth') === 'true'
+ })
const navigate = useNavigate()
+ useEffect(() => {
+ localStorage.setItem('rr-auth', authed ? 'true' : 'false')
+ }, [authed])
+
async function handleLogout() {
try { await logout() } catch { /* ignore */ }
setAuthed(false)
@@ -34,10 +47,11 @@ function AppRoutes() {