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(``+ - ``+ - ``+ - ``+ - ``+ - ``+ - ``+ - `%s`+ - `%s`+ - `%s`+ - `%s`+ - ``, - 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(``+ + ``+ + ``+ + ``+ + ``+ + ``+ + ``+ + `%s`+ + `%s`+ + `%s`+ + `%s`+ + ``, + 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() { { setAuthed(true); navigate('/app') }} - onBack={() => navigate('/')} - /> + authed ? : ( + { setAuthed(true); navigate('/app', { replace: true }) }} + /> + ) } /> } /> } /> } /> + } /> + } /> + } /> + } /> } /> ) } +// ── Sun / Moon icons ──────────────────────────────────────── +const SunIcon = () => ( + +) +const MoonIcon = () => ( + +) +const MenuIcon = () => ( + + + +) +const CloseIcon = () => ( + + + +) +const SignOutIcon = () => ( + +) +const ChevronLeftIcon = () => ( + +) +const ChevronRightIcon = () => ( + +) + +type NavIconProps = { className?: string } + +const JobsIcon = ({ className }: NavIconProps) => ( + +) + +const ReposIcon = ({ className }: NavIconProps) => ( + +) + +const CatalogIcon = ({ className }: NavIconProps) => ( + +) + +const PolicyIcon = ({ className }: NavIconProps) => ( + +) + +const AlertsIcon = ({ className }: NavIconProps) => ( + +) + +const SettingsIcon = ({ className }: NavIconProps) => ( + +) + +// Sidebar nav link helper +function SideLink({ + to, + end, + onClick, + collapsed, + icon: Icon, + children, +}: { + to: string + end?: boolean + onClick?: () => void + collapsed?: boolean + icon: (props: NavIconProps) => React.ReactElement + children: React.ReactNode +}) { + return ( + + [ + 'group flex items-center gap-2.5 px-3 py-2.5 rounded-md text-sm font-deco tracking-widest transition-colors border border-transparent', + collapsed ? 'md:justify-center md:px-2' : '', + 'text-[var(--sidebar-fg-muted)] hover:text-[var(--sidebar-fg)] hover:bg-[var(--sidebar-hover-bg)] hover:border-[var(--sidebar-border)]', + isActive ? '!text-[var(--sidebar-fg)] !border-[rgba(232,196,88,.32)] !bg-[rgba(232,196,88,.10)]' : '', + ].join(' ') + } + > + {({ isActive }) => ( + <> + + + + {children} + + )} + + ) +} + +// Sidebar action button +function SideBtn({ + onClick, + danger, + collapsed, + children, +}: { + onClick: () => void + danger?: boolean + collapsed?: boolean + children: React.ReactNode +}) { + return ( + + ) +} + function AppShell({ onLogout }: { onLogout: () => void }) { const [dark, setDark] = useState(() => typeof window !== 'undefined' ? localStorage.getItem('rr-theme') === 'dark' : false ) - const [sidebarOpen, setSidebarOpen] = useState(() => - typeof window !== 'undefined' ? localStorage.getItem('rr-sidebar') !== 'closed' : true + const [desktopCollapsed, setDesktopCollapsed] = useState(() => + typeof window !== 'undefined' ? localStorage.getItem('rr-sidebar') === 'collapsed' : false ) + // On desktop the sidebar is always "open"; on mobile it's a drawer. + const [mobileOpen, setMobileOpen] = useState(false) - function toggleDark() { - setDark(d => { - const next = !d - localStorage.setItem('rr-theme', next ? 'dark' : 'light') - return next - }) - } + // Sync dark-mode class to so Tailwind dark: variants work + useEffect(() => { + document.documentElement.classList.toggle('dark', dark) + localStorage.setItem('rr-theme', dark ? 'dark' : 'light') + }, [dark]) - function toggleSidebar() { - setSidebarOpen(o => { - const next = !o - localStorage.setItem('rr-sidebar', next ? 'open' : 'closed') - return next - }) - } + useEffect(() => { + localStorage.setItem('rr-sidebar', desktopCollapsed ? 'collapsed' : 'expanded') + }, [desktopCollapsed]) - const sidebarBtnStyle: CSSProperties = { - background: 'none', - border: 'none', - color: 'rgba(251,240,220,.3)', - cursor: 'pointer', - textAlign: 'left', - padding: '9px 12px', - fontFamily: "'Bebas Neue', Impact, sans-serif", - fontSize: 14, - letterSpacing: 2, - transition: 'color .15s', - display: 'flex', - alignItems: 'center', - gap: 8, - } + // Close mobile sidebar on route change + const closeMobile = () => setMobileOpen(false) return ( -
- -
- +
diff --git a/web/src/api.ts b/web/src/api.ts index 1bd9901..41ef7a5 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -1,5 +1,8 @@ import axios from 'axios' -import type { Job, MachineType, SavingsSummary } from './types' +import type { + Job, MachineType, SavingsSummary, SavingsHistoryPoint, RepoSummary, JobSummaryRow, + PolicyRule, PolicyEvaluation, NotificationSettings, DeliveryLog, OwnershipEntry, +} from './types' const api = axios.create({ baseURL: '/api/v1', @@ -7,14 +10,34 @@ const api = axios.create({ withCredentials: true, // send the HttpOnly session cookie automatically }) +// If the backend session cookie is missing/expired (common after backend restarts), +// reset client auth state and force a clean login. +api.interceptors.response.use( + (response) => response, + (error) => { + const status = error?.response?.status + const reqURL = String(error?.config?.url ?? '') + const isAuthEndpoint = reqURL.includes('/auth') + + if (status === 401 && !isAuthEndpoint && typeof window !== 'undefined' && !import.meta.env.DEV) { + localStorage.setItem('rr-auth', 'false') + if (window.location.pathname !== '/login') { + window.location.replace('/login') + } + } + + return Promise.reject(error) + }, +) + export const login = (apiKey: string): Promise => api.post('/auth', { api_key: apiKey }).then(() => undefined) export const logout = (): Promise => api.post('/auth/logout').then(() => undefined) -export const fetchJobs = (): Promise => - api.get('/jobs').then((r) => r.data ?? []) +export const fetchJobs = (repository?: string): Promise => + api.get('/jobs', { params: repository ? { repository } : {} }).then((r) => r.data ?? []) export const fetchJob = (id: number): Promise => api.get(`/jobs/${id}`).then((r) => r.data) @@ -22,5 +45,88 @@ export const fetchJob = (id: number): Promise => export const fetchCatalog = (provider?: string): Promise => api.get('/catalog', { params: provider ? { provider } : {} }).then((r) => r.data ?? []) -export const fetchSavings = (): Promise => - api.get('/savings').then((r) => r.data) +export const fetchSavings = (repository?: string): Promise => + api.get('/savings', { params: repository ? { repository } : {} }).then((r) => r.data) + +export const fetchSavingsHistory = (): Promise => + api.get('/savings/history').then((r) => r.data ?? []) + +export const fetchJobTrend = (jobId: string, window = 10): Promise => + api.get(`/jobs/${encodeURIComponent(jobId)}/trend`, { params: { window } }).then((r) => r.data) + +// ── Repo-centric API ───────────────────────────────────────────────────────── + +export const fetchRepos = (): Promise => + api.get('/repos').then((r) => r.data ?? []) + +export const fetchRepoJobs = (repository: string, includeArchived = false): Promise => + api + .get('/repo-jobs', { + params: { repository, ...(includeArchived ? { include_archived: 'true' } : {}) }, + }) + .then((r) => r.data ?? []) + +export const fetchIsolatedJobs = (includeArchived = false): Promise => + api + .get('/isolated-jobs', { + params: includeArchived ? { include_archived: 'true' } : {}, + }) + .then((r) => r.data ?? []) + +export const upsertJobMeta = (payload: { + job_id: string + repository: string + snoozed_until?: string | null + snooze_reason?: string + archived?: boolean + stale_days?: number +}): Promise => api.put('/job-meta', payload).then(() => undefined) + +export const deleteJobRuns = (jobId: string, repository: string): Promise<{ deleted_runs: number }> => + api + .delete<{ deleted_runs: number }>('/job-runs', { params: { job_id: jobId, repository } }) + .then((r) => r.data) + +export const fetchPolicies = (repository?: string): Promise => + api.get('/policies', { params: repository ? { repository } : {} }).then((r) => r.data ?? []) + +export const upsertPolicy = (payload: { + repository: string + job_id?: string + max_cost_per_hour: number + enabled?: boolean +}): Promise => api.put('/policies', payload).then(() => undefined) + +export const deletePolicy = (repository: string, jobId = ''): Promise => + api.delete('/policies', { params: { repository, job_id: jobId } }).then(() => undefined) + +export const evaluatePolicy = (payload: { + repository: string + job_id: string + detected_price_per_hour: number +}): Promise => api.post('/policies/evaluate', payload).then((r) => r.data) + +export const fetchNotificationSettings = (): Promise => + api.get('/notifications/settings').then((r) => r.data) + +export const upsertNotificationSettings = (payload: NotificationSettings): Promise => + api.put('/notifications/settings', payload).then(() => undefined) + +export const sendTestNotification = (): Promise => + api.post('/notifications/test').then(() => undefined) + +export const fetchDeliveryLogs = (ruleId?: string, limit = 50): Promise => + api + .get('/notifications/deliveries', { + params: { ...(ruleId ? { rule_id: ruleId } : {}), limit }, + }) + .then((r) => r.data ?? []) + +export const fetchOwnership = (repository?: string): Promise => + api.get('/ownership', { params: repository ? { repository } : {} }).then((r) => r.data ?? []) + +export const upsertOwnership = (payload: { repository: string; team_name: string; destination_ids: string[] }): Promise => + api.put('/ownership', payload).then(() => undefined) + +export const deleteOwnership = (repository: string, teamName: string): Promise => + api.delete('/ownership', { params: { repository, team_name: teamName } }).then(() => undefined) diff --git a/web/src/components/DateRangePicker.tsx b/web/src/components/DateRangePicker.tsx index ac6b785..41d4d10 100644 --- a/web/src/components/DateRangePicker.tsx +++ b/web/src/components/DateRangePicker.tsx @@ -56,78 +56,51 @@ export function DateRangePicker({ onChange({ ...value, [field]: val }) } - const inputStyle: React.CSSProperties = { - background: '#FFFDF7', - border: '1px solid #D4B896', - borderBottom: '2px solid #B8946A', - color: '#2C1A0E', - fontFamily: 'Lato, sans-serif', - fontSize: 13, - padding: '6px 8px', - outline: 'none', - colorScheme: 'light' as React.CSSProperties['colorScheme'], - cursor: 'pointer', - } - const hasCustom = !!(value.from || value.to) return ( -
- +
+ PERIOD - {PRESETS.map(p => { - const active = activePreset === p.key - return ( - - ) - })} +
+ {PRESETS.map(p => { + const active = activePreset === p.key + return ( + + ) + })} +
{/* Custom date inputs */} -
+
handleInput('from', e.target.value)} - style={inputStyle} + className="rr-input date-range-input" title="From date" /> - – + – handleInput('to', e.target.value)} - style={inputStyle} + className="rr-input date-range-input" title="To date" /> {hasCustom && activePreset === (null as unknown as Preset) && ( diff --git a/web/src/components/PageNav.tsx b/web/src/components/PageNav.tsx index 9cc1051..6d54863 100644 --- a/web/src/components/PageNav.tsx +++ b/web/src/components/PageNav.tsx @@ -1,11 +1,9 @@ import { Link } from 'react-router-dom' import LogoMark from './LogoMark' -import './PageNav.css' interface Props { dark: boolean onToggleDark: () => void - /** Called when Sign In is clicked. If omitted, renders a Link to /login. */ onSignIn?: () => void } @@ -16,7 +14,6 @@ function GitHubIcon() { ) } - function MoonIcon() { return ( @@ -24,52 +21,44 @@ function MoonIcon() { ) } - function SunIcon() { return ( - - - ) } export default function PageNav({ dark, onToggleDark, onSignIn }: Props) { + const linkCls = 'font-deco text-[13px] tracking-[1.5px] text-[var(--text-mid)] hover:text-[var(--text)] no-underline transition-colors' return ( -