Compare commits

...

57 commits
v2.1.0 ... main

Author SHA1 Message Date
hhftechnologies
dccad13d49 Update package.json
Some checks failed
Docker Publish / build-and-push (push) Has been cancelled
Docker Publish / commit-readme-image-size (push) Has been cancelled
2026-04-24 22:21:25 +05:30
HHF Technology
bc16708f96
Merge pull request #18 from hhftechnology/dev
Add bot service, persistent container stats, and enhanced charts UI
2026-04-24 22:04:22 +05:30
hhftechnologies
e87978fb18 Update package.json
Some checks failed
Docker Publish / build-and-push (push) Has been cancelled
Docker Publish / commit-readme-image-size (push) Has been cancelled
2026-04-24 15:21:40 +05:30
hhftechnologies
d96df77222 minor-bug-fixes 2026-04-24 15:14:57 +05:30
hhftechnologies
c42846cc89 Update command.go
Some checks are pending
Docker Publish / build-and-push (push) Waiting to run
Docker Publish / commit-readme-image-size (push) Blocked by required conditions
2026-04-23 21:18:40 +05:30
hhftechnologies
6a7a393d7f Track async container actions; UI & bot fixes
Add server-side tracking for asynchronous container operations: introduce ContainerActionJob with RecordActionJob/GetActionJob and update runAsyncContainerAction to acquire a docker client from the registry, run actions in a goroutine, and record pending/success/failure status (callers updated to accept client). Move test bot endpoints into the mutating (ReadOnly) group so they respect read-only mode. Tighten Discord token handling in settings handlers to return 400 if the secret mask is used but no stored token exists.

Improve bot/telegram robustness: fetch container stats concurrently in command handler (with synchronization), add restart mutex around bot restart/config update, and sanitize HTTP errors to mask tokens. Frontend changes: provide a richer Tabs test mock, show "N/A" when history stats are missing to avoid panics, fix uptime sort to use elapsed time, and add aria-labels to container action buttons for accessibility. Also add a test env var for Discord allowed channel in config tests.
2026-04-23 21:13:04 +05:30
hhftechnologies
4bba6c16cc fixes-ui-bot 2026-04-23 17:47:43 +05:30
hhftechnologies
81b28d62a8 discord-bot-update 2026-04-23 16:08:51 +05:30
hhftechnologies
fd455dabec UI-Container-Data-Fixes
Some checks are pending
Docker Publish / build-and-push (push) Waiting to run
Docker Publish / commit-readme-image-size (push) Blocked by required conditions
2026-04-23 12:10:01 +05:30
hhftechnologies
21a5f9e01f Update footer.tsx
Some checks are pending
Docker Publish / build-and-push (push) Waiting to run
Docker Publish / commit-readme-image-size (push) Blocked by required conditions
2026-04-22 22:32:34 +05:30
hhftechnologies
e0e2a5f43e minor-bug-fixes
Some checks are pending
Docker Publish / build-and-push (push) Waiting to run
Docker Publish / commit-readme-image-size (push) Blocked by required conditions
2026-04-22 21:56:58 +05:30
hhftechnologies
7601c95f2d bot-fixes
Some checks are pending
Docker Publish / build-and-push (push) Waiting to run
Docker Publish / commit-readme-image-size (push) Blocked by required conditions
2026-04-21 20:04:14 +05:30
hhftechnologies
aaf2d8b7fc Bug Fixes
###  Bug Fixes
2026-04-21 19:08:55 +05:30
HHF Technology
af54d6ff1f
Update Readme.md 2026-04-21 10:02:12 +05:30
HHF Technology
e09bdaf75f
Merge pull request #17 from hhftechnology/dev
Some checks failed
Docker Publish / build-and-push (push) Has been cancelled
Docker Publish / commit-readme-image-size (push) Has been cancelled
Dev
2026-04-15 05:39:25 +05:30
hhftechnologies
4c6f0ef9cd Merge branch 'dev' of https://github.com/hhftechnology/vps-monitor into dev
Some checks failed
Docker Publish / build-and-push (push) Has been cancelled
Docker Publish / commit-readme-image-size (push) Has been cancelled
2026-04-15 05:37:09 +05:30
hhftechnologies
c58c9ba618 update 2026-04-15 05:36:05 +05:30
github-actions[bot]
38fee0a7af Keep README image size in sync with published image
Automated branch-only documentation refresh after a successful image publish run.

Constraint: README updates must not block image publication
Confidence: high
Scope-risk: narrow
Directive: Keep the README marker name and workflow replacement pattern aligned
Tested: GitHub Actions branch-push README marker refresh
Not-tested: Protected branches that reject github-actions[bot] pushes
2026-04-15 00:01:01 +00:00
hhftechnologies
438b412c39 Add image size calc and README update job
Enhance CI to calculate built Docker image size and update README. Updates .github/workflows/docker-publish.yml to ignore Readme.md on triggers, emit an image_size_human output from the build job, build a linux/amd64 image locally (no push) to measure size, and add a commit-readme-image-size job that updates a placeholder marker in Readme.md with the calculated size. The workflow includes safety checks (branch-only conditions, continue-on-error for size steps, remote-branch verification) so README updates won't block image publication. Also adds a README table row containing the <!-- VPS_MONITOR_IMAGE_SIZE_START -->/<!-- VPS_MONITOR_IMAGE_SIZE_END --> markers for automated replacement.
2026-04-15 05:28:50 +05:30
hhftechnologies
dc3e081818 Bump frontend to v2, add app store links, update routes
Some checks are pending
Docker Publish / build-and-push (push) Waiting to run
Bump frontend package version to 2.0.0. Update Footer component to improve layout (wrap, spacing) and add Google Play and Apple App Store links with SVG icons and accessible labels; also tidy anchor class ordering. Regenerate/update routeTree to reorder/include the /scan-history route entries (no behavioral change expected beyond regeneration).
2026-04-14 12:36:23 +05:30
HHF Technology
41a4e02cc4
Merge pull request #16 from hhftechnology/dev
Some checks failed
Docker Publish / build-and-push (push) Has been cancelled
Implement SBOM history pages and endpoints
2026-04-09 09:16:45 +05:30
hhftechnologies
8b746a296a Observe SBOM jobs, parse filenames, add DB format
Some checks failed
Docker Publish / build-and-push (push) Has been cancelled
Add an observable SBOM job mechanism and integrate it into the UI: introduce useObservedSBOMJobs (with toast and query invalidation on completion), track active SBOM job IDs in ImagesTable, and surface job creation from SBOMDialog via onJobCreated. Make SBOM download robust by parsing Content-Disposition filenames and returning {blob, filename} from downloadSBOMHistoryFile. Improve accessibility and UX in history pages by converting row text to buttons and adding explicit sort buttons with aria-sort. Add frontend test scaffolding (vitest setup, new/updated unit tests) and adjust several React tests. Update scanner DB and handlers to include SBOM format in image SBOM state (schema change, primary key now includes format) and add migration logic; also tighten NewScanDB initialization (file: path + PRAGMAs). Update Go tests to use t and TempDir where appropriate.
2026-04-08 19:08:39 +05:30
hhftechnologies
4307e07b51 Add SBOM history & generation features
Some checks are pending
Docker Publish / build-and-push (push) Waiting to run
Add full SBOM generation and history support across frontend and backend. Frontend: add SBOM link in header, SBOM history route/page and components, SBOM dialog UI improvements (show regen-blocked state, view history), images table SBOM status indicator, API client for SBOM history, new react-query hooks (sbomHistory, sbomHistoryDetail, sbomedImages), types for SBOM results/components, and routeTree entries. Backend: register new SBOM history endpoints (list, detail, images, download, delete) and enforce a regeneration gate that returns 409 when an image is unchanged unless force=true. Wire filename selection for downloads and add handler implementations. Add tests and testdata for SBOM handlers and client changes. Also include query invalidation after SBOM generation so UI updates when new SBOMs are created.
2026-04-08 15:30:57 +05:30
hhftechnologies
9903934ca1 Surface syft stream errors; classify scan failures
Improve SBOM generation and scan error handling:

- Add syftStreamResult type and syftStreamFailureCause helper to detect stream read errors or non-empty stderr.
- Update runSBOMGeneration to concurrently wait for the syft log stream and container exit, surface stream failures as primary cause, remove partial files on failure, and mark jobs accordingly.
- Add unit tests for syftStreamFailureCause (home/internal/scanner/sbom_test.go).
- Introduce classifyScanFailure to centralize mapping of context/scan errors to job status/messages and use it in runScan; add tests for its behavior.
- Minor import and formatting tweaks.

These changes ensure streaming errors from syft are reported reliably and scan failures are classified consistently.
2026-04-08 14:21:50 +05:30
hhftechnologies
4c21ff4b95 scanner: improve IO handling, defaults and tests
Multiple fixes and enhancements across the scanner, DB, tests, and docs:

- Surface stream read errors immediately in grype/trivy/syft flows to avoid masking I/O failures behind exit-code messages.
- Improve ringBuffer to handle non-positive capacity, preallocate buffer, and copy trailing bytes to allow GC of large backing arrays.
- Use os.OpenFile with explicit flags/mode when writing log files.
- Make parseIntSetting accept a per-setting default, trim and atoi the value, and return the supplied default for missing/malformed/non-positive values; update DB to pass appropriate defaults.
- Introduce package-level heartbeatTickInterval (default 5s) so tests can shorten the cadence; update heartbeat tests to use a helper and assert progress behavior.
- Test and test helper fixes: countingWriter, file writes check errors, additional assertions, and strengthened manager_test to exercise each env var separately.
- Frontend tests updated for explicit ScannerConfig typing, rename pollInterval to pollIntervalMinutes, add onNewCVEs notification flag, and minor expectations adjustments.
- README: correct docker image reference.

These changes improve error visibility, resource handling, test robustness, and tighten parsing of numeric settings.
2026-04-08 10:28:09 +05:30
coderabbitai[bot]
61fc011c13
📝 CodeRabbit Chat: Add unit tests
Some checks are pending
Docker Publish / build-and-push (push) Waiting to run
2026-04-07 08:46:57 +00:00
HHF Technology
92d321c5e2
Delete .claude directory 2026-04-07 14:04:28 +05:30
hhftechnologies
39cfbce718 Readme.md update 2026-04-07 14:03:20 +05:30
hhftechnologies
b600fe542e Improve scanner log streaming and error messages
Add scannerContainerLogsOptions (enables Follow) and have StreamContainerStdoutToFile use it. Introduce enrichParseErrorForEmptyOutput to wrap EOF parse errors when the scanner produced an empty output file (includes a truncated stderr tail up to 512 chars). Use this helper in RunGrypeScan and RunTrivyScan to provide clearer diagnostics for empty/no-output parser failures. Add tests and helpers (writeDockerLogFrame, delayed-frame test, options test, and enrichParseError tests) and import/time adjustments to validate behavior.
2026-04-07 12:23:32 +05:30
hhftechnologies
a6aec1cd9c Add scanner timeouts and resource limits
Some checks are pending
Docker Publish / build-and-push (push) Waiting to run
Introduce configurable scanner resource limits and timeouts across frontend and backend. Adds new config fields (scanTimeoutMinutes, bulkTimeoutMinutes, scannerMemoryMB, scannerPidsLimit) with defaults (20, 120, 2048, 512) and environment/file overrides; persists settings in DB and merges file/env values. Frontend: expose controls for timeouts, memory and PID limits. Backend: add parsing, manager/file config support, model fields, and DB save/load updates. Add docker_io helpers to pull images with progress, ensure cache volumes, build host configs, stream container stdout to disk (with stderr tail capture and ring buffer) and unit tests. Refactor Grype/Trivy/Syft SBOM flows to stream outputs to files, apply resource limits, use cache volumes, and add heartbeat/progress updates to keep memory usage low for large scans.
2026-04-06 15:39:00 +05:30
HHF Technology
28160ad1d2
Merge pull request #15 from hhftechnology/dev
Some checks failed
Docker Publish / build-and-push (push) Has been cancelled
Update Readme.md
2026-04-05 14:58:49 +05:30
HHF Technology
3787161e9f
Update Readme.md
Some checks are pending
Docker Publish / build-and-push (push) Waiting to run
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-04-05 14:58:09 +05:30
hhftechnologies
1fb729fc9e Update Readme.md
Some checks are pending
Docker Publish / build-and-push (push) Waiting to run
2026-04-05 13:55:03 +05:30
HHF Technology
5b9589de7a
Merge pull request #13 from hhftechnology/dev
Some checks are pending
Docker Publish / build-and-push (push) Waiting to run
Add vulnerability scanner & SBOM features
2026-04-05 13:38:24 +05:30
hhftechnologies
87b0c1fca6 Add scan history export/delete and SBOM UI
Frontend: add exportScanHistory/deleteScanHistory API calls, wired UI actions (export + delete buttons) in scan history table, and enhance SBOM dialog to fetch, parse and display SBOM components with export. Update route tree to remove trailing slashes. Hooks: add useDeleteScanHistory mutation and integrate exports.

Backend: register new routes for exporting and deleting scan history; implement ExportScanHistory (CSV attachment) and DeleteScanHistory handlers and DB DeleteScanResult. Scanner: add gcWorker to purge old jobs, return copies from getters to avoid races, and remove unused Trivy SBOM helper. Config: change scanner config env parsing to use strconv.ParseBool with safer defaults and remove deprecated helper code/tests.

Overall: enables CSV export and deletion of scan results, improves SBOM UX, tightens routing, and adds server-side cleanup and safety improvements.
2026-04-05 13:25:51 +05:30
hhftechnologies
bf9f1fe3d7 test: Added tests from PR #14 2026-04-05 06:58:59 +05:30
hhftechnologies
2a5150534f bug-fixes 2026-04-05 06:51:10 +05:30
hhftechnologies
6be667a277 Improve scanner robustness and filtering
Some checks are pending
Docker Publish / build-and-push (push) Waiting to run
Frontend: add explicit severity options/type and update severity filter handling so the UI sends min_severity only when a real severity is selected.

Backend: validate and return 400 for invalid query params (page, page_size, start_date, end_date) to avoid silent parsing errors; add new ScanJobStatus "expired"; include SCANNER_SYFT_ARGS in config env detection; make autoscanner Start idempotent (initialize stop channel and skip if already enabled) and set image last_scan_at to current time when scheduling scans; stop autoScanner when autoscan disabled in main.go.

Scanner/store updates: make ScanResultStore.Add return an error and handle/store.Add failures in runScan (log on failure); tighten CancelJob to only mark jobs cancelled if they are pending/pulling/scanning; include Negligible and Unknown counts in anomaly messages.

Overall: fixes for parameter validation, safer autoscan lifecycle, more accurate scan state recording, and clearer severity filtering.
2026-04-04 20:01:47 +05:30
HHF Technology
65413ccf44
Delete .claude/tsc-cache/5eb5e100-0df0-41c5-9449-b967d2d89308 directory 2026-04-04 19:42:44 +05:30
hhftechnologies
91e6558385 Handle rescan-blocked images and show history
Detect and surface scans that are blocked because the image is unchanged: introduce RescanBlockedError (thrown on 409 from start-scan), handle it in ScanDialog to show an "already scanned" UI with a link to scan history, and reset state on close. Add useScannedImages usage in ImagesTable to show a green check / link to /scan-history for already-scanned images instead of the scan button. In bulk scans, surface a banner for skipped (unchanged) images and mark those jobs with a "Skipped" badge. Imports and small UI/icon updates added accordingly.
2026-04-04 17:44:15 +05:30
hhftechnologies
c715d70387 Update db.go 2026-04-04 17:08:15 +05:30
hhftechnologies
a40a261f75 Update db.go 2026-04-04 17:05:42 +05:30
hhftechnologies
29ae03bdfa Add scan history UI and autoscan backend
Introduce scan history browsing and persistent autoscan support.

Frontend: add Scan History page, route and navigation link; implement API client (get-scan-history), React Query hooks (use-scan-query), types, and UI components for listing, filtering, pagination and detail dialog. Update generated route tree and add a mobile ScanHistory page.

Backend: integrate a scanner SQLite DB (configurable via SCANNER_DB_PATH), migrate/load scanner settings from DB, wire an AutoScanner service and start/stop it based on config; expose autoscan and history endpoints in the API router and pass AutoScanner to handlers. Add/model config mapping function (configToScannerConfig) and update hot-reload behavior to apply DB-backed scanner settings and autoscan toggles.

Other: bump Go toolchain and add dependencies in go.mod/go.sum required for the scanner DB and related changes.
2026-04-04 16:54:37 +05:30
hhftechnologies
7d104afa81 Create embed.go
Some checks are pending
Docker Publish / build-and-push (push) Waiting to run
2026-04-04 05:03:32 +05:30
hhftechnologies
a6a2505d6e Escape backslashes, drop embed.go, fix CPU counts
Frontend: escape backslashes as well as pipe characters when building Markdown table cells in scan-results-export to avoid breaking table formatting for values containing backslashes.

Remove home/internal/static/embed.go which provided the embedded dist FS and SPA handler (file deleted).

Backend: tighten error handling in home/internal/system/stats.go by capturing the physical CPU count error separately and only setting cachedCPU values when both logical and physical counts succeeded; also removed an unused comment line. These changes prevent incorrect CPU metadata from being cached when one of the syscalls fails.
2026-04-04 04:51:20 +05:30
hhftechnologies
e7ed5f0286 Improve scanner workflows, validations & fixes
Multiple fixes and improvements across scanner, API, frontend and system code:

- Frontend: escape pipe characters when exporting scan results to Markdown to avoid table breakage.
- Server: extract buildScannerConfig helper in main to centralize scanner config construction.
- API: validate requested scanner values and notification severity; check SBOM file existence before download; return persisted config in UpdateScannerConfig response; import os.
- Scanner models: add ScanJobExpired status for expired/cleaned up SBOMs.
- SBOM generation: clear job file path and mark job as expired when the temporary file is removed.
- Grype integration: use shlex.Split to robustly parse custom Grype args (supports quoted args/placeholders).
- Notifier: disable automatic HTTP redirects for webhook calls and adjust Discord embed color logic to treat Negligible/Unknown as yellow; improve webhook client settings.
- Scanner service: handle empty bulk scans, tighten CancelJob to only cancel active jobs, ensure cancel contexts are cleaned up, and skip sending bulk notifications when there are no eligible jobs.
- System stats: simplify CPU counts caching with a mutex and lazy initialization (remove sync.Once and associated loader).
- Config: bump Syft image version.

Overall these changes increase robustness, prevent invalid inputs, ensure proper cleanup and make external command parsing and notifications more reliable.
2026-04-04 04:26:18 +05:30
hhftechnologies
02ba3e440b Update bun.lock
Some checks are pending
Docker Publish / build-and-push (push) Waiting to run
2026-04-03 19:34:41 +05:30
hhftechnologies
d7cd90a3ab Scanner: API query params, webhook validation & fixes
Switch scan results endpoints to use query parameters (image & host) and update router/handlers accordingly. Add webhook URL validation to prevent unsafe/localhost targets and improve error logging for scan/SBOM operations. Filter bulk notifications by minimum severity before sending. Fix Trivy arg parsing using google/shlex and return errors; propagate docker log read errors in grype demux. Improve SBOM cleanup scheduling, CSV escaping on export, vulnerability link handling (CVE vs GHSA), and add accessibility ids for selects. Use fast-deep-equal for scanner config comparisons and prevent UI edits when scanner configured via environment variables. Also refactor CPU stats caching with mutexes. Update go.mod/frontend deps for shlex and fast-deep-equal.
2026-04-03 19:31:01 +05:30
hhftechnologies
9821b94b68 Add vulnerability scanner & SBOM features
Introduce a full scanner subsystem (vulnerability scanning and SBOM generation) across frontend, backend, and mobile.

Frontend:
- Add scanner UI components: ScanDialog, BulkScanDialog, SBOMDialog, ScanResultsTable, ScanResultsSummary, ScanResultsExport and hooks/use-scan-query.
- Add API clients for scan operations (start-scan, start-bulk-scan, get-scan-jobs, get-scan-results, generate-sbom, scanner-config).
- Integrate scan/SBOM controls into ImagesTable (scan per-image, bulk scan, SBOM generation, download/export).
- Update settings UI to surface scanner configuration.

Backend (home service):
- Add scanner models, handlers and scanner implementations (Grype/Trivy), notifier, SBOM support, and persistent store scaffolding for scan jobs/results.
- Add scan-related API handlers and wire routes into router.go and handlers.go; adjust main.go and config/manager to support scanner settings.
- Tests updated for auth service where required.

Mobile:
- Add scanner feature support (API, hooks, components, types, utils) and a Settings page entry.

Why: Enable users to run vulnerability scans (single and bulk), monitor job progress, retrieve and export scan results, and generate/download SBOMs for container images.
2026-04-03 16:09:59 +05:30
HHF Technology
f9aba773fa
Merge pull request #11 from hhftechnology/dev
Some checks failed
Docker Publish / build-and-push (push) Has been cancelled
Add CPU topology fields and improve settings validation
2026-04-03 10:43:30 +05:30
HHF Technology
df9ee7110b
Merge pull request #12 from hhftechnology/coderabbitai/utg/bbd4a09
Some checks are pending
Docker Publish / build-and-push (push) Waiting to run
CodeRabbit Generated Unit Tests: Add unit tests for PR changes
2026-04-03 08:46:21 +05:30
HHF Technology
6bbec26f5a
Merge branch 'dev' into coderabbitai/utg/bbd4a09 2026-04-03 08:28:20 +05:30
coderabbitai[bot]
ccef23d065
CodeRabbit Generated Unit Tests: Add unit tests for PR changes 2026-04-03 01:39:47 +00:00
HHF Technology
31242dfda5
Merge branch 'main' into dev 2026-04-03 07:00:08 +05:30
hhftechnologies
bbd4a09888 Improve bcrypt, Coolify ID and config validation
Some checks are pending
Docker Publish / build-and-push (push) Waiting to run
Add stricter validation and safety checks across auth, config, frontend, and the Coolify client. isBcryptHash now fully validates bcrypt prefix and cost range and includes tests. Introduce ErrEnvironmentConfigured and strengthen UpdateCoolifyHosts to detect env-configured Coolify hosts with proper locking to avoid races. Add regex-based safe identifier checks for resource and env UUIDs in the Coolify client (used in SyncEnvVars and deleteEnvVar) and unit tests to reject malformed IDs. Also trim edited Coolify host fields in the frontend host edit flow.
2026-04-03 03:20:34 +05:30
hhftechnologies
07be599ec6 Sanitize host inputs and refactor Coolify sync
Some checks are pending
Docker Publish / build-and-push (push) Waiting to run
Trim and validate host/auth inputs in the frontend and backend, and refactor Coolify env-sync logic. Frontend: trim host/name/token fields and update auth state after changes. Backend: add coolifyEnvSyncer interface and applyCoolifyEnvSync helper to centralize sync behavior (skip DB resources, propagate errors, and set response flags); add safe identifier validation in Coolify client deleteEnvVar to prevent invalid IDs; simplify auth initialization logic. Config: introduce ErrEnvironmentConfigured and wrap environment-configured errors so handlers can detect them with errors.Is. Auth: improve bcrypt hash format validation and add related tests. Add unit tests for settings handlers and Coolify client validations.
2026-04-03 01:51:06 +05:30
hhftechnologies
087b24762a Update stats.go 2026-04-03 01:35:46 +05:30
136 changed files with 21857 additions and 2127 deletions

View file

@ -4,11 +4,18 @@ on:
push:
branches: [main, dev]
tags: ["v*"]
paths-ignore:
- Readme.md
workflow_dispatch:
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
image_size_human: ${{ steps.image_size.outputs.size_human }}
steps:
- uses: actions/checkout@v4
@ -24,11 +31,20 @@ jobs:
username: hhftechnology
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: hhftechnology/vps-monitor
images: |
hhftechnology/vps-monitor
ghcr.io/hhftechnology/vps-monitor
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
@ -47,3 +63,95 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build image for size calculation (linux/amd64)
if: github.event_name == 'push' && startsWith(github.ref, 'refs/heads/')
continue-on-error: true
uses: docker/build-push-action@v6
with:
context: .
file: ./home/Dockerfile
platforms: linux/amd64
push: false
load: true
tags: local/vps-monitor:size-${{ github.sha }}
cache-from: type=gha
- name: Calculate image size
if: github.event_name == 'push' && startsWith(github.ref, 'refs/heads/')
id: image_size
continue-on-error: true
shell: bash
run: |
IMAGE_REF="local/vps-monitor:size-${{ github.sha }}"
SIZE_BYTES="$(docker image inspect "${IMAGE_REF}" --format='{{.Size}}' 2>/dev/null || true)"
if [ -n "${SIZE_BYTES}" ]; then
SIZE_HUMAN="$(numfmt --to=iec --suffix=B "${SIZE_BYTES}")"
else
SIZE_HUMAN=""
fi
if [ -n "${SIZE_HUMAN}" ]; then
echo "size_human=${SIZE_HUMAN}" >> "${GITHUB_OUTPUT}"
else
echo "Image size calculation skipped or failed"
fi
commit-readme-image-size:
runs-on: ubuntu-latest
needs: [build-and-push]
if: github.event_name == 'push' && startsWith(github.ref, 'refs/heads/')
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.ref_name }}
- name: Update README image size
shell: bash
run: |
IMAGE_SIZE="${{ needs.build-and-push.outputs.image_size_human }}"
if [ -z "${IMAGE_SIZE}" ]; then
echo "No image size output available"
exit 0
fi
sed -i -E "s|(<!-- VPS_MONITOR_IMAGE_SIZE_START -->)[^<]*(<!-- VPS_MONITOR_IMAGE_SIZE_END -->)|\1${IMAGE_SIZE}\2|" Readme.md
- name: Commit README image size update
shell: bash
run: |
git fetch origin "${{ github.ref_name }}"
REMOTE_SHA="$(git rev-parse FETCH_HEAD)"
if [ "${REMOTE_SHA}" != "${{ github.sha }}" ]; then
echo "Remote branch moved; refusing stale README push"
exit 0
fi
if git diff --quiet -- Readme.md; then
echo "README image size is already up to date"
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
cat > /tmp/readme-image-size-commit-message.txt <<'EOF'
Keep README image size in sync with published image
Automated branch-only documentation refresh after a successful image publish run.
Constraint: README updates must not block image publication
Confidence: high
Scope-risk: narrow
Directive: Keep the README marker name and workflow replacement pattern aligned
Tested: GitHub Actions branch-push README marker refresh
Not-tested: Protected branches that reject github-actions[bot] pushes
EOF
git add Readme.md
git commit -F /tmp/readme-image-size-commit-message.txt
git push origin HEAD:${{ github.ref_name }}

BIN
.github/workflows/logotype-dark.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

BIN
.github/workflows/logotype-light.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

View file

@ -1,3 +1,11 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="/.github/logotype-dark.png">
<source media="(prefers-color-scheme: light)" srcset="/.github/logotype-light.png">
<img src="/.github/logotype-dark.png" width="400" alt="VPS Monitor">
</picture>
</div>
<div align="center">
<h1>VPS-Monitor</h1>
<p>VPS-Monitor is an open-source, high-performance Docker container monitoring and management tool. Built for speed and ease of use, it provides real-time log streaming, container stats, image management, network visualization, alerting, and multi-host support through a clean, modern interface.</p>
@ -7,10 +15,25 @@
[![Discord](https://img.shields.io/discord/994247717368909884?logo=discord&style=flat-square)](https://discord.gg/HDCt9MjyMJ)
</div>
<div align="center">
<a href="https://apps.apple.com/us/app/#"><img width="135" height="39" alt="appstore" src="https://github.com/user-attachments/assets/45e31a11-cf6b-40a2-a083-6dc8d1f01291" /></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<a href="https://play.google.com/store/apps/details?id=com.vps.monitor.mobile"><img width="135" height="39" alt="googleplay" src="https://github.com/user-attachments/assets/acbba639-858f-4c74-85c7-92a4096efbf5" /></a>
</div>
<div align="center">
| Docker Image | linux/amd64 Size |
| --- | --- |
| `hhftechnology/vps-monitor:latest` | <!-- VPS_MONITOR_IMAGE_SIZE_START -->108MB<!-- VPS_MONITOR_IMAGE_SIZE_END --> |
</div>
<div align="center">
<img width="1735" height="1058" alt="image" src="https://github.com/user-attachments/assets/35241a4e-d523-40eb-9455-ed33ab837b66" />
<img width="1735" height="1467" alt="image" src="https://github.com/user-attachments/assets/ccb23590-8ecd-4c89-89ea-68a24db69418" />
</div>
## Stats
<img width="1735" height="802" alt="image" src="https://github.com/user-attachments/assets/78cdc82e-9d9f-4734-aae6-592b0374ec61" />
@ -155,13 +178,17 @@ See the [Multi-Host Setup Guide](./multi-host.md) for detailed configuration.
```yaml
services:
vps-monitor:
image: ghcr.io/hhftechnology/vps-monitor:latest
image: hhftechnology/vps-monitor:latest
ports:
- "6789:6789"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /proc:/host/proc:ro
- ./data:/data
environment:
- READONLY_MODE=false
- DOCKER_HOSTS=local=unix:///var/run/docker.sock
- HOSTNAME_OVERRIDE=Pangolin Host
```
```bash
@ -212,11 +239,13 @@ For the full mobile validation flow, see:
```yaml
services:
vps-monitor:
image: ghcr.io/hhftechnology/vps-monitor:latest
image: hhftechnology/vps-monitor:latest
ports:
- "6789:6789"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /proc:/host/proc:ro
- ./data:/data
environment:
- JWT_SECRET=your-secret-key-minimum-32-characters
- ADMIN_USERNAME=admin
@ -269,8 +298,8 @@ npm run build
### Using Docker
```bash
docker pull ghcr.io/hhftechnology/vps-monitor:latest
docker run -d -p 6789:6789 -v /var/run/docker.sock:/var/run/docker.sock ghcr.io/hhftechnology/vps-monitor:latest
docker pull hhftechnology/vps-monitor:latest
docker run -d -p 6789:6789 -v /var/run/docker.sock:/var/run/docker.sock hhftechnology/vps-monitor:latest
```
## Configuration

View file

@ -29,6 +29,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"fast-deep-equal": "^3.1.3",
"lucide-react": "^0.544.0",
"next-themes": "^0.4.6",
"nuqs": "^2.7.2",
@ -627,6 +628,8 @@
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],

View file

@ -1,6 +1,6 @@
{
"name": "vps-monitor",
"version": "1.0.0",
"version": "2.3.2",
"private": true,
"description": "VPS Monitor",
"author": "HHF Technology <https://github.com/hhftechnology>",
@ -38,6 +38,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"fast-deep-equal": "^3.1.3",
"lucide-react": "^0.544.0",
"next-themes": "^0.4.6",
"nuqs": "^2.7.2",

View file

@ -3,16 +3,31 @@ export function Footer() {
return (
<footer className="border-t bg-background">
<div className="container mx-auto flex h-14 items-center justify-between px-4">
<div className="container mx-auto flex min-h-14 flex-wrap items-center justify-between gap-3 px-4 py-3">
<p className="text-sm text-muted-foreground">
{currentYear} VPS Monitor made by HHF Technology - MIT License
{currentYear} VPS Monitor made by HHF Technology - MIT License
</p>
<div className="flex items-center gap-4">
<a
href="https://play.google.com/store/apps/details?id=com.vps.monitor.mobile"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground transition-colors hover:text-foreground"
>
<svg className="size-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none">
<path
fill="currentColor"
fillRule="evenodd"
d="M3.739.505c.519-.038 1.024.15 1.55.422.53.276 1.179.692 1.996 1.216l5.433 3.483c.47.301.86.55 1.148.774.289.225.55.48.696.822.21.497.21 1.058 0 1.555-.145.343-.407.598-.696.823s-.678.473-1.148.774l-5.433 3.483c-.817.524-1.466.94-1.996 1.216-.526.272-1.031.46-1.55.422a2.68 2.68 0 0 1-1.955-1.069 2 2 0 0 1-.276-.515c-.143-.387-.202-.85-.23-1.376-.029-.53-.028-1.186-.028-1.978V5.443c0-1.336-.005-2.322.156-3.011.074-.315.189-.605.378-.858A2.68 2.68 0 0 1 3.74.504M2.879 13.8c.247.265.585.428.95.454.165.012.415-.04.887-.286.468-.242 1.06-.622 1.898-1.158l3.13-2.007L7.883 9.03zm5.904-5.63 2.038 1.942 1.226-.785c.49-.315.822-.528 1.056-.709.232-.181.294-.277.314-.325a.75.75 0 0 0 0-.588c-.02-.049-.081-.144-.314-.325-.234-.181-.565-.395-1.056-.71l-1.011-.648zM3.83 1.745c-.417.03-.8.239-1.05.573a1 1 0 0 0-.05.08l5.154 4.915 2.076-1.98L6.614 3.19c-.838-.536-1.43-.916-1.898-1.158-.472-.245-.722-.298-.887-.286m-1.336 8.812c0 .783 0 1.388.025 1.871l4.465-4.257-4.477-4.269c-.011.413-.013.918-.013 1.54z"
/>
</svg>
<span className="sr-only">Google Play</span>
</a>
<a
href="https://github.com/hhftechnology/vps-monitor"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors"
className="text-muted-foreground transition-colors hover:text-foreground"
>
<svg className="size-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v 3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
@ -23,7 +38,7 @@ export function Footer() {
href="https://discord.gg/PEGcTJPfJ2"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors"
className="text-muted-foreground transition-colors hover:text-foreground"
>
<svg className="size-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.211.375-.444.864-.607 1.25a18.27 18.27 0 0 0-5.487 0c-.163-.386-.395-.875-.607-1.25a.077.077 0 0 0-.079-.037 19.736 19.736 0 0 0-4.778.755.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.975 14.975 0 0 0 1.293-2.1.07.07 0 0 0-.038-.098 13.11 13.11 0 0 1-1.872-.892.072.072 0 0 1-.009-.119c.125-.093.25-.19.371-.287a.075.075 0 0 1 .078-.01c3.928 1.793 8.18 1.793 12.062 0a.075.075 0 0 1 .079.009c.12.098.246.195.371.288a.072.072 0 0 1-.01.119 12.901 12.901 0 0 1-1.873.892.07.07 0 0 0-.037.099 14.993 14.993 0 0 0 1.293 2.1.078.078 0 0 0 .084.028 19.963 19.963 0 0 0 6.002-3.03.079.079 0 0 0 .033-.057c.5-4.566-.838-8.934-3.551-12.66a.071.071 0 0 0-.031-.028zM8.02 15.33c-1.183 0-2.157-.965-2.157-2.156 0-1.193.931-2.157 2.157-2.157 1.226 0 2.157.964 2.157 2.157 0 1.19-.93 2.155-2.157 2.155zm7.975 0c-1.183 0-2.157-.965-2.157-2.156 0-1.193.931-2.157 2.157-2.157 1.226 0 2.157.964 2.157 2.157 0 1.19-.931 2.155-2.157 2.155z" />

View file

@ -1,5 +1,5 @@
import { Link, useLocation } from "@tanstack/react-router";
import { ActivityIcon, BoxIcon, ImageIcon, NetworkIcon, ServerIcon } from "lucide-react";
import { ActivityIcon, BoxIcon, FileTextIcon, HistoryIcon, ImageIcon, NetworkIcon, ServerIcon } from "lucide-react";
import { ThemeToggle } from "@/components/theme-toggle";
import { Button } from "@/components/ui/button";
@ -11,6 +11,8 @@ const navLinks = [
{ to: "/stats", label: "Stats", icon: ActivityIcon },
{ to: "/images", label: "Images", icon: ImageIcon },
{ to: "/networks", label: "Networks", icon: NetworkIcon },
{ to: "/scan-history", label: "Scan History", icon: HistoryIcon },
{ to: "/sbom-history", label: "SBOMs", icon: FileTextIcon },
] as const;
export function Header() {

View file

@ -0,0 +1,169 @@
"use client";
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "@/lib/utils";
export type ChartConfig = Record<
string,
{
label?: React.ReactNode;
color?: string;
}
>;
type ChartContextValue = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextValue | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("Chart components must be used within a ChartContainer.");
}
return context;
}
interface ChartContainerProps extends React.HTMLAttributes<HTMLDivElement> {
config: ChartConfig;
children: React.ReactElement;
}
export function ChartContainer({
config,
children,
className,
style,
...props
}: ChartContainerProps) {
const chartStyle = React.useMemo(() => {
const entries = Object.entries(config).map(([key, value]) => [
`--color-${key}`,
value.color ?? "currentColor",
]);
return Object.fromEntries(entries) as React.CSSProperties;
}, [config]);
return (
<ChartContext.Provider value={{ config }}>
<div
className={cn(
"text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line]:stroke-border/60 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none",
className,
)}
style={{ ...chartStyle, ...style }}
{...props}
>
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
export const ChartTooltip = RechartsPrimitive.Tooltip;
type TooltipPayloadItem = {
color?: string;
dataKey?: string | number;
name?: string | number;
payload?: Record<string, unknown>;
value?: number | string;
};
interface ChartTooltipContentProps extends React.HTMLAttributes<HTMLDivElement> {
active?: boolean;
payload?: TooltipPayloadItem[];
label?: string | number;
hideLabel?: boolean;
formatter?: (
value: number | string,
name: string,
item: TooltipPayloadItem,
) => React.ReactNode | [React.ReactNode, React.ReactNode];
}
export const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
ChartTooltipContentProps
>(function ChartTooltipContent(
{
active,
payload,
label,
className,
hideLabel = false,
formatter,
...props
},
ref,
) {
const { config } = useChart();
if (!active || !payload?.length) {
return null;
}
return (
<div
ref={ref}
className={cn(
"grid min-w-[11rem] gap-2 rounded-lg border bg-card px-3 py-2 text-xs shadow-md",
className,
)}
{...props}
>
{!hideLabel && label !== undefined && (
<div className="font-medium text-foreground">{label}</div>
)}
<div className="grid gap-1.5">
{payload.map((item) => {
const dataKey = String(item.dataKey ?? item.name ?? "");
const itemConfig = config[dataKey];
const itemLabel = String(itemConfig?.label ?? item.name ?? dataKey);
const formatted = formatter?.(
item.value ?? "",
itemLabel,
item,
);
let valueNode: React.ReactNode = item.value ?? "—";
let labelNode: React.ReactNode = itemLabel;
if (Array.isArray(formatted)) {
valueNode = formatted[0];
labelNode = formatted[1];
} else if (formatted !== undefined) {
valueNode = formatted;
}
return (
<div
key={`${dataKey}-${itemLabel}`}
className="flex items-center justify-between gap-3"
>
<div className="flex min-w-0 items-center gap-2">
<span
className="size-2 shrink-0 rounded-full"
style={{
backgroundColor:
item.color ?? itemConfig?.color ?? `var(--color-${dataKey})`,
}}
/>
<span className="truncate text-muted-foreground">{labelNode}</span>
</div>
<span className="font-medium text-foreground">{valueNode}</span>
</div>
);
})}
</div>
</div>
);
});

View file

@ -0,0 +1,45 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { restartContainer } from "./container-actions";
const storage = new Map<string, string>();
describe("container-actions", () => {
beforeEach(() => {
vi.restoreAllMocks();
storage.clear();
vi.stubGlobal("localStorage", {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => {
storage.set(key, value);
},
removeItem: (key: string) => {
storage.delete(key);
},
});
});
it("marks 202 responses as pending", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
message: "Container restart initiated",
status: "pending",
}),
{
status: 202,
headers: { "Content-Type": "application/json" },
},
),
),
);
const result = await restartContainer("abc123", "host-a");
expect(result).toEqual({
message: "Container restart initiated",
isPending: true,
});
});
});

View file

@ -6,48 +6,54 @@ const BASE_URL = `${API_BASE_URL}/api/v1/containers`;
type ContainerAction = "start" | "stop" | "restart" | "remove";
interface ActionResponse {
message?: string;
message?: string;
status?: string;
}
export interface ActionResult {
message: string;
isPending: boolean;
}
async function performContainerAction(
id: string,
action: ContainerAction,
host: string
): Promise<string> {
const endpoint = `${BASE_URL}/${encodeURIComponent(id)}/${action}?host=${encodeURIComponent(host)}`;
const response = await authenticatedFetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
id: string,
action: ContainerAction,
host: string,
): Promise<ActionResult> {
const endpoint = `${BASE_URL}/${encodeURIComponent(id)}/${action}?host=${encodeURIComponent(host)}`;
const response = await authenticatedFetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
const message = await response.text();
throw new Error(message || `Failed to ${action} container`);
}
if (!response.ok) {
const message = await response.text();
throw new Error(message || `Failed to ${action} container`);
}
const data = (await response.json()) as ActionResponse | undefined;
const isPending = response.status === 202;
const data = (await response.json()) as ActionResponse | undefined;
if (data && typeof data.message === "string") {
return data.message;
}
return "Action completed successfully";
return {
message: data?.message || "Action completed successfully",
isPending,
};
}
export function startContainer(id: string, host: string) {
return performContainerAction(id, "start", host);
return performContainerAction(id, "start", host);
}
export function stopContainer(id: string, host: string) {
return performContainerAction(id, "stop", host);
return performContainerAction(id, "stop", host);
}
export function restartContainer(id: string, host: string) {
return performContainerAction(id, "restart", host);
return performContainerAction(id, "restart", host);
}
export function removeContainer(id: string, host: string) {
return performContainerAction(id, "remove", host);
return performContainerAction(id, "remove", host);
}

View file

@ -0,0 +1,32 @@
import { authenticatedFetch } from "@/lib/api-client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { getContainerHistory } from "./get-container-history";
vi.mock("@/lib/api-client", () => ({
authenticatedFetch: vi.fn(),
}));
const mockFetch = authenticatedFetch as ReturnType<typeof vi.fn>;
describe("getContainerHistory", () => {
afterEach(() => vi.clearAllMocks());
it("normalizes missing samples to an empty array", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
cpu_1h: 1,
memory_1h: 2,
cpu_12h: 3,
memory_12h: 4,
has_data: true,
}),
} as unknown as Response);
const history = await getContainerHistory("container-1", "local");
expect(history.samples).toEqual([]);
});
});

View file

@ -0,0 +1,30 @@
import { authenticatedFetch } from "@/lib/api-client";
import { API_BASE_URL } from "@/types/api";
import type { ContainerInfo } from "../types";
import type { ContainerStats } from "../types/stats";
export interface ContainerHistoryStats
extends NonNullable<ContainerInfo["historical_stats"]> {
has_data: boolean;
samples: ContainerStats[];
}
export async function getContainerHistory(
id: string,
host: string,
): Promise<ContainerHistoryStats> {
const endpoint = `${API_BASE_URL}/api/v1/containers/${encodeURIComponent(id)}/stats/history?host=${encodeURIComponent(host)}`;
const response = await authenticatedFetch(endpoint);
if (!response.ok) {
const message = await response.text();
throw new Error(message || `Request failed with status ${response.status}`);
}
const data = (await response.json()) as ContainerHistoryStats;
return {
...data,
samples: data.samples ?? [],
};
}

View file

@ -0,0 +1,214 @@
import type { ReactNode } from "react";
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { ContainerDetailsSheet } from "./container-details-sheet";
const mockUseContainerStats = vi.fn();
const mockUseContainerHistory = vi.fn();
vi.mock("../hooks/use-container-stats", () => ({
useContainerStats: (...args: unknown[]) => mockUseContainerStats(...args),
}));
vi.mock("../hooks/use-container-history", () => ({
useContainerHistory: (...args: unknown[]) => mockUseContainerHistory(...args),
}));
vi.mock("@/components/ui/tabs", async () => {
const React = await import("react");
const TabsContext = React.createContext<{ value: string; onValueChange: (v: string) => void }>({ value: "", onValueChange: () => {} });
return {
Tabs: ({ value, defaultValue, onValueChange, children }: any) => {
const [v, setV] = React.useState(value || defaultValue);
const handleChange = (newV: string) => {
setV(newV);
if (onValueChange) onValueChange(newV);
};
React.useEffect(() => { if (value !== undefined) setV(value); }, [value]);
return <TabsContext.Provider value={{ value: v, onValueChange: handleChange }}><div>{children}</div></TabsContext.Provider>;
},
TabsContent: ({ value, children }: any) => {
const context = React.useContext(TabsContext);
if (context.value !== value) return null;
return <div>{children}</div>;
},
TabsList: ({ children }: any) => <div>{children}</div>,
TabsTrigger: ({ value, children, disabled, ...props }: any) => {
const context = React.useContext(TabsContext);
return <button type="button" disabled={disabled} onClick={() => context.onValueChange(value)} {...props}>{children}</button>;
},
};
});
vi.mock("./environment-variables", () => ({
EnvironmentVariables: ({
onContainerIdChange,
}: {
onContainerIdChange: (containerId: string) => void;
}) => (
<button type="button" onClick={() => onContainerIdChange("container-2")}>
Update container id
</button>
),
}));
vi.mock("recharts", async () => {
const actual = await vi.importActual<typeof import("recharts")>("recharts");
return {
...actual,
ResponsiveContainer: ({ children }: { children?: ReactNode }) => (
<div style={{ width: 960, height: 320 }}>{children}</div>
),
};
});
beforeAll(() => {
class ResizeObserverMock {
disconnect() {}
observe() {}
unobserve() {}
}
vi.stubGlobal("ResizeObserver", ResizeObserverMock);
Object.defineProperty(HTMLElement.prototype, "clientWidth", {
configurable: true,
value: 960,
});
Object.defineProperty(HTMLElement.prototype, "clientHeight", {
configurable: true,
value: 320,
});
});
describe("ContainerDetailsSheet", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseContainerStats.mockReturnValue({
stats: null,
history: [],
isConnected: false,
error: null,
connect: vi.fn(),
disconnect: vi.fn(),
clearHistory: vi.fn(),
});
mockUseContainerHistory.mockReturnValue({
data: {
cpu_1h: 12,
memory_1h: 34,
cpu_12h: 18,
memory_12h: 40,
has_data: true,
samples: [
{
container_id: "container-1",
host: "local",
cpu_percent: 10,
memory_usage: 512,
memory_limit: 1024,
memory_percent: 50,
network_rx: 100,
network_tx: 80,
block_read: 20,
block_write: 10,
pids: 4,
timestamp: 1_700_000_000,
},
{
container_id: "container-1",
host: "local",
cpu_percent: 14,
memory_usage: 600,
memory_limit: 1024,
memory_percent: 58,
network_rx: 120,
network_tx: 95,
block_read: 25,
block_write: 12,
pids: 5,
timestamp: 1_700_000_030,
},
],
},
});
});
it("renders stats charts from persisted samples before live history arrives", () => {
render(
<ContainerDetailsSheet
container={{
id: "container-1",
names: ["/api"],
image: "ghcr.io/example/api:latest",
image_id: "sha256:123",
command: "node server.js",
created: 1_700_000_000,
state: "running",
status: "Up 2 hours",
host: "local",
historical_stats: {
cpu_1h: 12,
memory_1h: 34,
cpu_12h: 18,
memory_12h: 40,
},
}}
host="local"
isOpen
onOpenChange={vi.fn()}
/>,
);
expect(screen.getByText("CPU Usage (%)")).toBeInTheDocument();
expect(screen.getByText("Memory Usage (%)")).toBeInTheDocument();
expect(screen.getByText("Network I/O")).toBeInTheDocument();
expect(screen.getByText("Block I/O")).toBeInTheDocument();
});
it("keeps hooks stable with a null container and respects child container id updates", async () => {
const { rerender } = render(
<ContainerDetailsSheet
container={null}
host="local"
isOpen
onOpenChange={vi.fn()}
/>,
);
rerender(
<ContainerDetailsSheet
container={{
id: "container-1",
names: ["/api"],
image: "ghcr.io/example/api:latest",
image_id: "sha256:123",
command: "node server.js",
created: 1_700_000_000,
state: "running",
status: "Up 2 hours",
host: "local",
}}
host="local"
isOpen
onOpenChange={vi.fn()}
/>,
);
fireEvent.click(
await screen.findByRole("button", { name: /env vars/i }),
);
fireEvent.click(
await screen.findByRole("button", { name: /update container id/i }),
);
expect(mockUseContainerStats).toHaveBeenLastCalledWith(
expect.objectContaining({ containerId: "container-2" }),
);
});
});

View file

@ -1,336 +1,409 @@
import {
ActivityIcon,
CpuIcon,
HardDriveIcon,
MemoryStickIcon,
NetworkIcon,
PlayIcon,
SettingsIcon,
SquareIcon,
TerminalIcon,
ActivityIcon,
CpuIcon,
HardDriveIcon,
MemoryStickIcon,
NetworkIcon,
PlayIcon,
SettingsIcon,
SquareIcon,
TerminalIcon,
} from "lucide-react";
import { lazy, Suspense, useCallback, useMemo, useState } from "react";
import {
lazy,
Suspense,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useContainerHistory } from "../hooks/use-container-history";
import { useContainerStats } from "../hooks/use-container-stats";
import type { ContainerInfo } from "../types";
import { ContainerStatsCharts } from "./container-stats-charts";
import { EnvironmentVariables } from "./environment-variables";
import type { ContainerInfo } from "../types";
// Lazy load terminal to reduce initial bundle size
const Terminal = lazy(() =>
import("./terminal").then((module) => ({ default: module.Terminal }))
import("./terminal").then((module) => ({ default: module.Terminal })),
);
interface ContainerDetailsSheetProps {
container: ContainerInfo | null;
host: string;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
isReadOnly?: boolean;
container: ContainerInfo | null;
host: string;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
isReadOnly?: boolean;
}
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
}
function formatPercent(value: number): string {
return `${value.toFixed(1)}%`;
return `${value.toFixed(1)}%`;
}
export function ContainerDetailsSheet({
container,
host,
isOpen,
onOpenChange,
isReadOnly = false,
container,
host,
isOpen,
onOpenChange,
isReadOnly = false,
}: ContainerDetailsSheetProps) {
const [activeTab, setActiveTab] = useState("stats");
const [containerId, setContainerId] = useState(container?.id ?? "");
const [activeTab, setActiveTab] = useState("stats");
const [containerId, setContainerId] = useState(container?.id ?? "");
// Update containerId when container changes
const effectiveContainerId = container?.id ?? containerId;
useEffect(() => {
setContainerId(container?.id ?? "");
}, [container?.id]);
const {
stats,
history,
isConnected,
error,
connect,
disconnect,
clearHistory,
} = useContainerStats({
containerId: effectiveContainerId,
host,
enabled: isOpen && activeTab === "stats",
});
const effectiveContainerId = containerId || container?.id || "";
const handleContainerIdChange = useCallback((newId: string) => {
setContainerId(newId);
}, []);
const {
stats,
history,
isConnected,
error,
connect,
disconnect,
clearHistory,
} = useContainerStats({
containerId: effectiveContainerId,
host,
enabled: isOpen && activeTab === "stats",
});
const { data: persistedHistory } = useContainerHistory(
effectiveContainerId,
host,
isOpen && activeTab === "stats",
);
const handleToggleStats = useCallback(() => {
if (isConnected) {
disconnect();
} else {
clearHistory();
connect();
}
}, [isConnected, disconnect, clearHistory, connect]);
const handleContainerIdChange = useCallback((newId: string) => {
setContainerId(newId);
}, []);
const statsCards = useMemo(() => {
if (!stats) return null;
const handleToggleStats = useCallback(() => {
if (isConnected) {
disconnect();
} else {
clearHistory();
connect();
}
}, [isConnected, disconnect, clearHistory, connect]);
return [
{
label: "CPU",
value: formatPercent(stats.cpu_percent),
icon: CpuIcon,
color: stats.cpu_percent > 80 ? "text-red-500" : "text-primary",
},
{
label: "Memory",
value: `${formatBytes(stats.memory_usage)} / ${formatBytes(stats.memory_limit)}`,
subValue: formatPercent(stats.memory_percent),
icon: MemoryStickIcon,
color: stats.memory_percent > 80 ? "text-red-500" : "text-primary",
},
{
label: "Network I/O",
value: `${formatBytes(stats.network_rx)} / ${formatBytes(stats.network_tx)}`,
subLabel: "RX / TX",
icon: NetworkIcon,
color: "text-primary",
},
{
label: "Block I/O",
value: `${formatBytes(stats.block_read)} / ${formatBytes(stats.block_write)}`,
subLabel: "Read / Write",
icon: HardDriveIcon,
color: "text-primary",
},
{
label: "PIDs",
value: stats.pids.toString(),
icon: ActivityIcon,
color: "text-primary",
},
];
}, [stats]);
const statsCards = useMemo(() => {
if (!stats) return null;
if (!container) return null;
return [
{
label: "CPU",
value: formatPercent(stats.cpu_percent),
icon: CpuIcon,
color: stats.cpu_percent > 80 ? "text-red-500" : "text-primary",
},
{
label: "Memory",
value: `${formatBytes(stats.memory_usage)} / ${formatBytes(stats.memory_limit)}`,
subValue: formatPercent(stats.memory_percent),
icon: MemoryStickIcon,
color: stats.memory_percent > 80 ? "text-red-500" : "text-primary",
},
{
label: "Network I/O",
value: `${formatBytes(stats.network_rx)} / ${formatBytes(stats.network_tx)}`,
subLabel: "RX / TX",
icon: NetworkIcon,
color: "text-primary",
},
{
label: "Block I/O",
value: `${formatBytes(stats.block_read)} / ${formatBytes(stats.block_write)}`,
subLabel: "Read / Write",
icon: HardDriveIcon,
color: "text-primary",
},
{
label: "PIDs",
value: stats.pids.toString(),
icon: ActivityIcon,
color: "text-primary",
},
];
}, [stats]);
const containerName = container.names?.[0]?.replace(/^\//, "") || container.id.slice(0, 12);
const isRunning = container.state.toLowerCase() === "running";
const chartHistory = useMemo(() => {
const persistedSamples = persistedHistory?.samples ?? [];
if (persistedSamples.length === 0) {
return history;
}
return (
<Sheet open={isOpen} onOpenChange={onOpenChange}>
<SheetContent className="sm:max-w-2xl w-full overflow-y-auto p-0">
<SheetHeader className="px-6 pt-6">
<SheetTitle className="flex items-center gap-2">
{containerName}
<Badge
variant={isRunning ? "default" : "secondary"}
className="text-xs"
>
{container.state}
</Badge>
</SheetTitle>
<SheetDescription className="text-xs font-mono truncate">
{container.image}
</SheetDescription>
</SheetHeader>
const merged = new Map<number, (typeof persistedSamples)[number]>();
for (const sample of persistedSamples) {
merged.set(sample.timestamp, sample);
}
for (const sample of history) {
merged.set(sample.timestamp, sample);
}
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex-1 flex flex-col px-6 pb-6"
>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="stats" className="flex items-center gap-2">
<ActivityIcon className="size-4" />
Stats
</TabsTrigger>
<TabsTrigger
value="terminal"
className="flex items-center gap-2"
disabled={!isRunning}
>
<TerminalIcon className="size-4" />
Terminal
</TabsTrigger>
<TabsTrigger value="env" className="flex items-center gap-2">
<SettingsIcon className="size-4" />
Env Vars
</TabsTrigger>
</TabsList>
return Array.from(merged.values())
.sort((a, b) => a.timestamp - b.timestamp)
.slice(-60);
}, [history, persistedHistory?.samples]);
<TabsContent value="stats" className="space-y-6 mt-4">
{/* Stats Controls */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<Badge variant={isConnected ? "default" : "secondary"}>
{isConnected ? "Live" : "Disconnected"}
</Badge>
{history.length > 0 && (
<span className="text-xs text-muted-foreground">
{history.length} data points
</span>
)}
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={isConnected ? "default" : "outline"}
size="sm"
onClick={handleToggleStats}
disabled={!isRunning}
>
{isConnected ? (
<>
<SquareIcon className="mr-2 size-4" />
Stop
</>
) : (
<>
<PlayIcon className="mr-2 size-4" />
Start
</>
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{!isRunning
? "Container must be running"
: isConnected
? "Stop streaming stats"
: "Start streaming stats"}
</TooltipContent>
</Tooltip>
</div>
if (!container) return null;
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
const containerName =
container.names?.[0]?.replace(/^\//, "") || container.id.slice(0, 12);
const isRunning = container.state.toLowerCase() === "running";
const historyStats = container.historical_stats;
{!isRunning && (
<Card>
<CardContent className="py-8 text-center text-muted-foreground text-sm">
Container is not running. Start the container to view stats.
</CardContent>
</Card>
)}
return (
<Sheet open={isOpen} onOpenChange={onOpenChange}>
<SheetContent className="w-full overflow-y-auto p-0 sm:max-w-4xl">
<SheetHeader className="px-6 pt-6">
<SheetTitle className="flex items-center gap-2">
{containerName}
<Badge
variant={isRunning ? "default" : "secondary"}
className="text-xs"
>
{container.state}
</Badge>
</SheetTitle>
<SheetDescription className="text-xs font-mono truncate">
{container.image}
</SheetDescription>
</SheetHeader>
{isRunning && isConnected && !stats && (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Spinner className="mr-2 size-4" />
Connecting...
</div>
)}
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex flex-1 flex-col px-6 pb-6"
>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="stats" className="flex items-center gap-2">
<ActivityIcon className="size-4" />
Stats
</TabsTrigger>
<TabsTrigger
value="terminal"
className="flex items-center gap-2"
disabled={!isRunning}
>
<TerminalIcon className="size-4" />
Terminal
</TabsTrigger>
<TabsTrigger value="env" className="flex items-center gap-2">
<SettingsIcon className="size-4" />
Env Vars
</TabsTrigger>
</TabsList>
{isRunning && !isConnected && !stats && (
<Card>
<CardContent className="py-8 text-center text-muted-foreground text-sm">
Click "Start" to stream real-time container stats
</CardContent>
</Card>
)}
<TabsContent value="stats" className="mt-4 flex flex-col gap-6">
{historyStats && (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Card>
<CardContent className="p-3 text-sm">
1h CPU
<div className="font-semibold">
{historyStats.cpu_1h != null ? `${historyStats.cpu_1h.toFixed(1)}%` : "N/A"}
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-3 text-sm">
1h RAM
<div className="font-semibold">
{historyStats.memory_1h != null ? `${historyStats.memory_1h.toFixed(1)}%` : "N/A"}
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-3 text-sm">
12h CPU
<div className="font-semibold">
{historyStats.cpu_12h != null ? `${historyStats.cpu_12h.toFixed(1)}%` : "N/A"}
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-3 text-sm">
12h RAM
<div className="font-semibold">
{historyStats.memory_12h != null ? `${historyStats.memory_12h.toFixed(1)}%` : "N/A"}
</div>
</CardContent>
</Card>
</div>
)}
{/* Stats Controls */}
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-2">
<Badge variant={isConnected ? "default" : "secondary"}>
{isConnected ? "Live" : "Disconnected"}
</Badge>
{history.length > 0 && (
<span className="text-xs text-muted-foreground">
{history.length} data points
</span>
)}
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={isConnected ? "default" : "outline"}
size="sm"
onClick={handleToggleStats}
disabled={!isRunning}
>
{isConnected ? (
<>
<SquareIcon className="mr-2 size-4" />
Stop
</>
) : (
<>
<PlayIcon className="mr-2 size-4" />
Start
</>
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{!isRunning
? "Container must be running"
: isConnected
? "Stop streaming stats"
: "Start streaming stats"}
</TooltipContent>
</Tooltip>
</div>
{/* Live Stats Cards */}
{statsCards && (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
{statsCards.map((card) => {
const Icon = card.icon;
return (
<Card key={card.label} className="overflow-hidden">
<CardContent className="p-3">
<div className="flex flex-col items-center text-center gap-2">
<div className={`p-2 rounded-lg bg-muted ${card.color}`}>
<Icon className="size-5" />
</div>
<div className="space-y-0.5">
<p className="text-xs text-muted-foreground font-medium">
{card.label}
</p>
<p className="text-sm font-semibold truncate max-w-full">
{card.value}
</p>
{card.subValue && (
<p className="text-xs text-muted-foreground">
{card.subValue}
</p>
)}
{card.subLabel && (
<p className="text-[10px] text-muted-foreground">
{card.subLabel}
</p>
)}
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
{/* Stats Charts */}
<ContainerStatsCharts history={history} />
</TabsContent>
{!isRunning && (
<Card>
<CardContent className="py-8 text-center text-muted-foreground text-sm">
Container is not running. Start the container to view stats.
</CardContent>
</Card>
)}
<TabsContent value="terminal" className="min-h-[400px] mt-4">
{isRunning ? (
<Suspense
fallback={
<div className="flex items-center justify-center h-[400px] text-muted-foreground">
<Spinner className="mr-2 size-4" />
Loading terminal...
</div>
}
>
<Terminal containerId={effectiveContainerId} host={host} />
</Suspense>
) : (
<Card>
<CardContent className="py-8 text-center text-muted-foreground text-sm">
Container is not running. Start the container to access terminal.
</CardContent>
</Card>
)}
</TabsContent>
{isRunning && isConnected && !stats && (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Spinner className="mr-2 size-4" />
Connecting...
</div>
)}
<TabsContent value="env" className="mt-4">
<EnvironmentVariables
containerId={effectiveContainerId}
containerHost={host}
isReadOnly={isReadOnly}
onContainerIdChange={handleContainerIdChange}
/>
</TabsContent>
</Tabs>
</SheetContent>
</Sheet>
);
{isRunning && !isConnected && !stats && (
<Card>
<CardContent className="py-8 text-center text-muted-foreground text-sm">
Click "Start" to stream real-time container stats
</CardContent>
</Card>
)}
{/* Live Stats Cards */}
{statsCards && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-5">
{statsCards.map((card) => {
const Icon = card.icon;
return (
<Card key={card.label} className="overflow-hidden">
<CardContent className="p-3">
<div className="flex flex-col items-center gap-2 text-center">
<div
className={`p-2 rounded-lg bg-muted ${card.color}`}
>
<Icon className="size-5" />
</div>
<div className="min-w-0">
<p className="text-xs text-muted-foreground font-medium">
{card.label}
</p>
<p className="break-words text-sm font-semibold leading-tight">
{card.value}
</p>
{card.subValue && (
<p className="text-xs text-muted-foreground">
{card.subValue}
</p>
)}
{card.subLabel && (
<p className="text-[10px] text-muted-foreground">
{card.subLabel}
</p>
)}
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
{/* Stats Charts */}
<ContainerStatsCharts history={chartHistory} />
</TabsContent>
<TabsContent value="terminal" className="min-h-[400px] mt-4">
{isRunning ? (
<Suspense
fallback={
<div className="flex items-center justify-center h-[400px] text-muted-foreground">
<Spinner className="mr-2 size-4" />
Loading terminal...
</div>
}
>
<Terminal containerId={effectiveContainerId} host={host} />
</Suspense>
) : (
<Card>
<CardContent className="py-8 text-center text-muted-foreground text-sm">
Container is not running. Start the container to access
terminal.
</CardContent>
</Card>
)}
</TabsContent>
<TabsContent value="env" className="mt-4">
<EnvironmentVariables
containerId={effectiveContainerId}
containerHost={host}
isReadOnly={isReadOnly}
onContainerIdChange={handleContainerIdChange}
/>
</TabsContent>
</Tabs>
</SheetContent>
</Sheet>
);
}

View file

@ -1,15 +1,19 @@
import { useMemo } from "react";
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
Line,
LineChart,
XAxis,
YAxis,
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart";
import type { ContainerStats } from "../types/stats";
@ -17,24 +21,7 @@ interface ContainerStatsChartsProps {
history: ContainerStats[];
}
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
}
function formatTime(timestamp: number): string {
const date = new Date(timestamp * 1000);
return date.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
interface ChartData {
type ChartData = {
time: string;
timestamp: number;
cpu: number;
@ -45,6 +32,106 @@ interface ChartData {
networkTx: number;
blockRead: number;
blockWrite: number;
};
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
}
function formatTime(timestamp: number): string {
return new Date(timestamp * 1000).toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
const cpuChartConfig = {
cpu: {
label: "CPU",
color: "var(--chart-1)",
},
} satisfies ChartConfig;
const memoryChartConfig = {
memory: {
label: "Memory",
color: "var(--chart-2)",
},
} satisfies ChartConfig;
const networkChartConfig = {
networkRx: {
label: "RX (Received)",
color: "var(--chart-3)",
},
networkTx: {
label: "TX (Transmitted)",
color: "var(--chart-4)",
},
} satisfies ChartConfig;
const blockChartConfig = {
blockRead: {
label: "Read",
color: "var(--chart-5)",
},
blockWrite: {
label: "Write",
color: "var(--destructive)",
},
} satisfies ChartConfig;
interface StatsChartCardProps {
title: string;
data: ChartData[];
config: ChartConfig;
children: React.ReactElement;
legend?: React.ReactNode;
}
function StatsChartCard({
title,
data,
config,
children,
legend,
}: StatsChartCardProps) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<ChartContainer config={config} className="h-[220px] w-full">
{children}
</ChartContainer>
{data.length > 0 ? legend : null}
</CardContent>
</Card>
);
}
function SeriesLegend({ config }: { config: ChartConfig }) {
return (
<div className="flex flex-wrap justify-center gap-4 text-xs text-muted-foreground">
{Object.entries(config).map(([key, value]) => (
<div key={key} className="flex items-center gap-1.5">
<span
className="size-2 rounded-full"
style={{ backgroundColor: value.color }}
/>
<span>{value.label}</span>
</div>
))}
</div>
);
}
export function ContainerStatsCharts({ history }: ContainerStatsChartsProps) {
@ -63,283 +150,176 @@ export function ContainerStatsCharts({ history }: ContainerStatsChartsProps) {
}));
}, [history]);
if (history.length === 0) {
if (chartData.length === 0) {
return (
<div className="py-8 text-center text-muted-foreground text-sm">
<div className="py-8 text-center text-sm text-muted-foreground">
No data yet. Stats will appear once streaming begins.
</div>
);
}
return (
<div className="grid gap-4 md:grid-cols-2">
{/* CPU Chart */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">CPU Usage (%)</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[200px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<defs>
<linearGradient id="cpuGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="hsl(var(--primary))" stopOpacity={0.3} />
<stop offset="95%" stopColor="hsl(var(--primary))" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="time"
tick={{ fontSize: 10 }}
tickLine={false}
axisLine={false}
className="text-muted-foreground"
/>
<YAxis
domain={[0, 100]}
tick={{ fontSize: 10 }}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `${value}%`}
className="text-muted-foreground"
/>
<Tooltip
contentStyle={{
backgroundColor: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "var(--radius)",
}}
labelStyle={{ color: "hsl(var(--foreground))" }}
formatter={(value) => [`${(value as number).toFixed(2)}%`, "CPU"]}
/>
<Area
type="monotone"
dataKey="cpu"
stroke="hsl(var(--primary))"
fill="url(#cpuGradient)"
strokeWidth={2}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
<div className="grid gap-4 xl:grid-cols-2">
<StatsChartCard title="CPU Usage (%)" data={chartData} config={cpuChartConfig}>
<LineChart accessibilityLayer data={chartData}>
<CartesianGrid vertical={false} />
<XAxis
axisLine={false}
dataKey="time"
minTickGap={32}
tickLine={false}
/>
<YAxis
axisLine={false}
domain={[0, 100]}
tickFormatter={(value) => `${value}%`}
tickLine={false}
width={44}
/>
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value) => `${Number(value).toFixed(2)}%`}
/>
}
/>
<Line
dataKey="cpu"
dot={false}
stroke="var(--color-cpu)"
strokeWidth={2}
type="monotone"
/>
</LineChart>
</StatsChartCard>
{/* Memory Chart */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Memory Usage (%)</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[200px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<defs>
<linearGradient id="memoryGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="hsl(var(--chart-2))" stopOpacity={0.3} />
<stop offset="95%" stopColor="hsl(var(--chart-2))" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="time"
tick={{ fontSize: 10 }}
tickLine={false}
axisLine={false}
className="text-muted-foreground"
/>
<YAxis
domain={[0, 100]}
tick={{ fontSize: 10 }}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `${value}%`}
className="text-muted-foreground"
/>
<Tooltip
contentStyle={{
backgroundColor: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "var(--radius)",
}}
labelStyle={{ color: "hsl(var(--foreground))" }}
formatter={(value, _name, props) => {
const payload = (props as { payload: ChartData }).payload;
return [
`${(value as number).toFixed(2)}% (${formatBytes(payload.memoryUsage)} / ${formatBytes(payload.memoryLimit)})`,
"Memory",
];
}}
/>
<Area
type="monotone"
dataKey="memory"
stroke="hsl(var(--chart-2))"
fill="url(#memoryGradient)"
strokeWidth={2}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
<StatsChartCard
title="Memory Usage (%)"
data={chartData}
config={memoryChartConfig}
>
<LineChart accessibilityLayer data={chartData}>
<CartesianGrid vertical={false} />
<XAxis
axisLine={false}
dataKey="time"
minTickGap={32}
tickLine={false}
/>
<YAxis
axisLine={false}
domain={[0, 100]}
tickFormatter={(value) => `${value}%`}
tickLine={false}
width={44}
/>
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value, _name, item) => {
const payload = item.payload as ChartData | undefined;
return `${Number(value).toFixed(2)}% (${formatBytes(payload?.memoryUsage ?? 0)} / ${formatBytes(payload?.memoryLimit ?? 0)})`;
}}
/>
}
/>
<Line
dataKey="memory"
dot={false}
stroke="var(--color-memory)"
strokeWidth={2}
type="monotone"
/>
</LineChart>
</StatsChartCard>
{/* Network I/O Chart */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Network I/O</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[200px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<defs>
<linearGradient id="rxGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="hsl(var(--chart-3))" stopOpacity={0.3} />
<stop offset="95%" stopColor="hsl(var(--chart-3))" stopOpacity={0} />
</linearGradient>
<linearGradient id="txGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="hsl(var(--chart-4))" stopOpacity={0.3} />
<stop offset="95%" stopColor="hsl(var(--chart-4))" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="time"
tick={{ fontSize: 10 }}
tickLine={false}
axisLine={false}
className="text-muted-foreground"
/>
<YAxis
tick={{ fontSize: 10 }}
tickLine={false}
axisLine={false}
tickFormatter={(value) => formatBytes(value)}
className="text-muted-foreground"
/>
<Tooltip
contentStyle={{
backgroundColor: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "var(--radius)",
}}
labelStyle={{ color: "hsl(var(--foreground))" }}
formatter={(value, name) => [
formatBytes(value as number),
name === "networkRx" ? "RX (Received)" : "TX (Transmitted)",
]}
/>
<Area
type="monotone"
dataKey="networkRx"
stroke="hsl(var(--chart-3))"
fill="url(#rxGradient)"
strokeWidth={2}
name="networkRx"
/>
<Area
type="monotone"
dataKey="networkTx"
stroke="hsl(var(--chart-4))"
fill="url(#txGradient)"
strokeWidth={2}
name="networkTx"
/>
</AreaChart>
</ResponsiveContainer>
</div>
<div className="mt-2 flex justify-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<div className="size-2 rounded-full" style={{ backgroundColor: "hsl(var(--chart-3))" }} />
<span>RX (Received)</span>
</div>
<div className="flex items-center gap-1">
<div className="size-2 rounded-full" style={{ backgroundColor: "hsl(var(--chart-4))" }} />
<span>TX (Transmitted)</span>
</div>
</div>
</CardContent>
</Card>
<StatsChartCard
title="Network I/O"
data={chartData}
config={networkChartConfig}
legend={<SeriesLegend config={networkChartConfig} />}
>
<LineChart accessibilityLayer data={chartData}>
<CartesianGrid vertical={false} />
<XAxis
axisLine={false}
dataKey="time"
minTickGap={32}
tickLine={false}
/>
<YAxis
axisLine={false}
tickFormatter={(value) => formatBytes(Number(value))}
tickLine={false}
width={64}
/>
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value) => formatBytes(Number(value))}
/>
}
/>
<Line
dataKey="networkRx"
dot={false}
stroke="var(--color-networkRx)"
strokeWidth={2}
type="monotone"
/>
<Line
dataKey="networkTx"
dot={false}
stroke="var(--color-networkTx)"
strokeWidth={2}
type="monotone"
/>
</LineChart>
</StatsChartCard>
{/* Block I/O Chart */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Block I/O</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[200px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<defs>
<linearGradient id="readGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="hsl(var(--chart-5))" stopOpacity={0.3} />
<stop offset="95%" stopColor="hsl(var(--chart-5))" stopOpacity={0} />
</linearGradient>
<linearGradient id="writeGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="hsl(var(--destructive))" stopOpacity={0.3} />
<stop offset="95%" stopColor="hsl(var(--destructive))" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="time"
tick={{ fontSize: 10 }}
tickLine={false}
axisLine={false}
className="text-muted-foreground"
/>
<YAxis
tick={{ fontSize: 10 }}
tickLine={false}
axisLine={false}
tickFormatter={(value) => formatBytes(value)}
className="text-muted-foreground"
/>
<Tooltip
contentStyle={{
backgroundColor: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "var(--radius)",
}}
labelStyle={{ color: "hsl(var(--foreground))" }}
formatter={(value, name) => [
formatBytes(value as number),
name === "blockRead" ? "Read" : "Write",
]}
/>
<Area
type="monotone"
dataKey="blockRead"
stroke="hsl(var(--chart-5))"
fill="url(#readGradient)"
strokeWidth={2}
name="blockRead"
/>
<Area
type="monotone"
dataKey="blockWrite"
stroke="hsl(var(--destructive))"
fill="url(#writeGradient)"
strokeWidth={2}
name="blockWrite"
/>
</AreaChart>
</ResponsiveContainer>
</div>
<div className="mt-2 flex justify-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<div className="size-2 rounded-full" style={{ backgroundColor: "hsl(var(--chart-5))" }} />
<span>Read</span>
</div>
<div className="flex items-center gap-1">
<div className="size-2 rounded-full" style={{ backgroundColor: "hsl(var(--destructive))" }} />
<span>Write</span>
</div>
</div>
</CardContent>
</Card>
<StatsChartCard
title="Block I/O"
data={chartData}
config={blockChartConfig}
legend={<SeriesLegend config={blockChartConfig} />}
>
<LineChart accessibilityLayer data={chartData}>
<CartesianGrid vertical={false} />
<XAxis
axisLine={false}
dataKey="time"
minTickGap={32}
tickLine={false}
/>
<YAxis
axisLine={false}
tickFormatter={(value) => formatBytes(Number(value))}
tickLine={false}
width={64}
/>
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value) => formatBytes(Number(value))}
/>
}
/>
<Line
dataKey="blockRead"
dot={false}
stroke="var(--color-blockRead)"
strokeWidth={2}
type="monotone"
/>
<Line
dataKey="blockWrite"
dot={false}
stroke="var(--color-blockWrite)"
strokeWidth={2}
type="monotone"
/>
</LineChart>
</StatsChartCard>
</div>
);
}

View file

@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { getHistoricalValue, groupByCompose } from "./container-utils";
describe("groupByCompose", () => {
it("sorts compose groups by newest container when descending", () => {
const groups = groupByCompose(
[
{
id: "1",
names: ["/web-1"],
image: "img",
image_id: "sha",
command: "run",
created: 100,
state: "running",
status: "up",
host: "host-a",
labels: { "com.docker.compose.project": "project-old" },
},
{
id: "2",
names: ["/api-1"],
image: "img",
image_id: "sha",
command: "run",
created: 200,
state: "running",
status: "up",
host: "host-a",
labels: { "com.docker.compose.project": "project-new" },
},
],
"desc",
);
expect(groups.map((group) => group.project)).toEqual([
"project-new",
"project-old",
]);
});
});
describe("getHistoricalValue", () => {
it("returns null when the selected historical field is missing", () => {
const container = {
id: "1",
names: ["/api"],
image: "img",
image_id: "sha",
command: "run",
created: 100,
state: "running",
status: "up",
host: "host-a",
historical_stats: {
cpu_1h: 12,
memory_1h: undefined,
cpu_12h: 20,
memory_12h: 30,
},
} as any;
expect(getHistoricalValue(container, "1h", "memory")).toBeNull();
});
});

View file

@ -3,111 +3,148 @@ import type { ContainerInfo } from "../types";
export type SortDirection = "asc" | "desc";
export type GroupByOption = "none" | "compose";
export type ContainerActionType = "start" | "stop" | "restart" | "remove";
export type StatsInterval = "1h" | "12h";
export type SortColumn =
| "name"
| "state"
| "uptime"
| "created"
| "cpu"
| "ram";
export interface GroupedContainers {
project: string;
items: ContainerInfo[];
project: string;
items: ContainerInfo[];
}
export interface StateCounts {
running: number;
exited: number;
paused: number;
restarting: number;
dead: number;
other: number;
running: number;
exited: number;
paused: number;
restarting: number;
dead: number;
other: number;
}
const COMPOSE_PROJECT_LABEL = "com.docker.compose.project";
export function formatContainerName(names: string[]) {
if (!names.length) {
return "—";
}
const [primary] = names;
return primary.startsWith("/") ? primary.slice(1) : primary;
if (!names.length) {
return "—";
}
const [primary] = names;
return primary.startsWith("/") ? primary.slice(1) : primary;
}
export function formatCreatedDate(createdSeconds: number) {
const createdDate = new Date(createdSeconds * 1000);
return createdDate.toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
const createdDate = new Date(createdSeconds * 1000);
return createdDate.toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
}
export function formatUptime(createdSeconds: number) {
const now = Date.now();
const createdMs = createdSeconds * 1000;
const diffMs = now - createdMs;
const now = Date.now();
const createdMs = createdSeconds * 1000;
const diffMs = now - createdMs;
const seconds = Math.floor(diffMs / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const weeks = Math.floor(days / 7);
const months = Math.floor(days / 30);
const years = Math.floor(days / 365);
const seconds = Math.floor(diffMs / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const weeks = Math.floor(days / 7);
const months = Math.floor(days / 30);
const years = Math.floor(days / 365);
if (years > 0) return `${years} year${years > 1 ? "s" : ""}`;
if (months > 0) return `${months} month${months > 1 ? "s" : ""}`;
if (weeks > 0) return `${weeks} week${weeks > 1 ? "s" : ""}`;
if (days > 0) return `${days} day${days > 1 ? "s" : ""}`;
if (hours > 0) return `${hours} hour${hours > 1 ? "s" : ""}`;
if (minutes > 0) return `${minutes} minute${minutes > 1 ? "s" : ""}`;
return `${seconds} second${seconds !== 1 ? "s" : ""}`;
if (years > 0) return `${years} year${years > 1 ? "s" : ""}`;
if (months > 0) return `${months} month${months > 1 ? "s" : ""}`;
if (weeks > 0) return `${weeks} week${weeks > 1 ? "s" : ""}`;
if (days > 0) return `${days} day${days > 1 ? "s" : ""}`;
if (hours > 0) return `${hours} hour${hours > 1 ? "s" : ""}`;
if (minutes > 0) return `${minutes} minute${minutes > 1 ? "s" : ""}`;
return `${seconds} second${seconds !== 1 ? "s" : ""}`;
}
export function toTitleCase(value: string) {
if (!value) return value;
return value.charAt(0).toUpperCase() + value.slice(1);
if (!value) return value;
return value.charAt(0).toUpperCase() + value.slice(1);
}
export function getStateBadgeClass(state: string) {
const normalized = state.toLowerCase();
switch (normalized) {
case "running":
return "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400";
case "paused":
return "bg-amber-500/10 text-amber-700 dark:text-amber-400";
case "exited":
case "dead":
return "bg-rose-500/10 text-rose-700 dark:text-rose-400";
case "restarting":
return "bg-blue-500/10 text-blue-700 dark:text-blue-400";
default:
return "bg-muted text-muted-foreground";
}
const normalized = state.toLowerCase();
switch (normalized) {
case "running":
return "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400";
case "paused":
return "bg-amber-500/10 text-amber-700 dark:text-amber-400";
case "exited":
case "dead":
return "bg-rose-500/10 text-rose-700 dark:text-rose-400";
case "restarting":
return "bg-blue-500/10 text-blue-700 dark:text-blue-400";
default:
return "bg-muted text-muted-foreground";
}
}
export function groupByCompose(
containers: ContainerInfo[]
containers: ContainerInfo[],
sortDirection: SortDirection = "desc",
): GroupedContainers[] {
const groups = new Map<string, ContainerInfo[]>();
const groups = new Map<string, ContainerInfo[]>();
containers.forEach((container) => {
const key =
container.labels?.[COMPOSE_PROJECT_LABEL]?.trim() || "Standalone";
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key)?.push(container)
});
containers.forEach((container) => {
const key =
container.labels?.[COMPOSE_PROJECT_LABEL]?.trim() || "Standalone";
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key)?.push(container);
});
return Array.from(groups.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([project, items]) => ({ project, items }));
return Array.from(groups.entries())
.sort(([, itemsA], [, itemsB]) => {
const createdA = Math.max(...itemsA.map((item) => item.created));
const createdB = Math.max(...itemsB.map((item) => item.created));
return sortDirection === "desc"
? createdB - createdA
: createdA - createdB;
})
.map(([project, items]) => ({ project, items }));
}
export function getHistoricalValue(
container: ContainerInfo,
interval: StatsInterval,
metric: "cpu" | "memory",
) {
const stats = container.historical_stats;
if (!stats) {
return null;
}
const value =
interval === "1h"
? metric === "cpu"
? stats.cpu_1h
: stats.memory_1h
: metric === "cpu"
? stats.cpu_12h
: stats.memory_12h;
return value ?? null;
}
export function getInitialStateCounts(): StateCounts {
return {
running: 0,
exited: 0,
paused: 0,
restarting: 0,
dead: 0,
other: 0,
};
return {
running: 0,
exited: 0,
paused: 0,
restarting: 0,
dead: 0,
other: 0,
};
}
/**
@ -115,10 +152,10 @@ export function getInitialStateCounts(): StateCounts {
* Falls back to container ID if no name is available
*/
export function getContainerUrlIdentifier(container: ContainerInfo): string {
if (container.names && container.names.length > 0) {
const name = container.names[0];
return name.startsWith("/") ? name.slice(1) : name;
}
// Fallback to short ID if no name
return container.id.substring(0, 12);
if (container.names && container.names.length > 0) {
const name = container.names[0];
return name.startsWith("/") ? name.slice(1) : name;
}
// Fallback to short ID if no name
return container.id.substring(0, 12);
}

View file

@ -0,0 +1,243 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ContainersDashboard } from "./containers-dashboard";
const mockUseContainersQuery = vi.fn();
const mockUseSystemStats = vi.fn();
const mockUseContainersDashboardUrlState = vi.fn();
const mockStartContainer = vi.fn();
const mockStopContainer = vi.fn();
const mockRestartContainer = vi.fn();
const mockRemoveContainer = vi.fn();
const mockContainersLogsSheet = vi.fn((_props: unknown) => (
<div data-testid="logs-sheet" />
));
const mockContainerDetailsSheet = vi.fn((_props: unknown) => (
<div data-testid="details-sheet" />
));
vi.mock("../hooks/use-containers-query", () => ({
useContainersQuery: (...args: unknown[]) => mockUseContainersQuery(...args),
}));
vi.mock("../api/container-actions", () => ({
startContainer: (...args: unknown[]) => mockStartContainer(...args),
stopContainer: (...args: unknown[]) => mockStopContainer(...args),
restartContainer: (...args: unknown[]) => mockRestartContainer(...args),
removeContainer: (...args: unknown[]) => mockRemoveContainer(...args),
}));
vi.mock("../hooks/use-system-stats", () => ({
useSystemStats: (...args: unknown[]) => mockUseSystemStats(...args),
}));
vi.mock("../hooks/use-containers-dashboard-url-state", () => ({
useContainersDashboardUrlState: (...args: unknown[]) =>
mockUseContainersDashboardUrlState(...args),
}));
vi.mock("./containers-summary-cards", () => ({
ContainersSummaryCards: () => <div>summary cards</div>,
}));
vi.mock("./containers-toolbar", () => ({
ContainersToolbar: () => <div>toolbar</div>,
}));
vi.mock("./containers-state-summary", () => ({
ContainersStateSummary: () => <div>state summary</div>,
}));
vi.mock("./containers-pagination", () => ({
ContainersPagination: () => <div>pagination</div>,
}));
vi.mock("./containers-table", () => ({
ContainersTable: ({
pageItems,
onToggleSelect,
onViewLogs,
onViewStats,
}: {
pageItems: Array<{ id: string }>;
onToggleSelect: (id: string) => void;
onViewLogs: (container: { id: string }) => void;
onViewStats: (container: { id: string }) => void;
}) => (
<div>
{pageItems.map((item) => (
<button
key={item.id}
type="button"
onClick={() => onToggleSelect(item.id)}
>
Select {item.id}
</button>
))}
<button type="button" onClick={() => onViewLogs(pageItems[0])}>
Open logs
</button>
<button type="button" onClick={() => onViewStats(pageItems[0])}>
Open stats
</button>
</div>
),
}));
vi.mock("./containers-logs-sheet", () => ({
ContainersLogsSheet: (props: unknown) => mockContainersLogsSheet(props),
}));
vi.mock("./container-details-sheet", () => ({
ContainerDetailsSheet: (props: unknown) => mockContainerDetailsSheet(props),
}));
const container = {
id: "container-1",
names: ["/api"],
image: "ghcr.io/example/api:latest",
image_id: "sha256:123",
command: "node server.js",
created: 1_700_000_000,
state: "running",
status: "Up 2 hours",
host: "local",
historical_stats: {
cpu_1h: 12,
memory_1h: 34,
cpu_12h: 20,
memory_12h: 40,
},
};
const secondContainer = {
...container,
id: "container-2",
names: ["/worker"],
image: "ghcr.io/example/worker:latest",
};
function renderDashboard() {
const queryClient = new QueryClient();
return render(
<QueryClientProvider client={queryClient}>
<ContainersDashboard />
</QueryClientProvider>,
);
}
describe("ContainersDashboard", () => {
beforeEach(() => {
vi.clearAllMocks();
mockStartContainer.mockResolvedValue({ message: "started" });
mockStopContainer.mockResolvedValue({ message: "stopped" });
mockRestartContainer.mockResolvedValue({ message: "restarted" });
mockRemoveContainer.mockResolvedValue({ message: "removed" });
mockUseContainersQuery.mockReturnValue({
data: {
containers: [container],
readOnly: false,
hosts: [{ Name: "local", Host: "unix:///var/run/docker.sock" }],
hostErrors: [],
},
error: null,
isError: false,
isFetching: false,
isLoading: false,
refetch: vi.fn(),
});
mockUseSystemStats.mockReturnValue({
data: {
hostInfo: {
hostname: "vps-1",
platform: "linux",
kernelVersion: "6.8.0",
},
usage: {
cpuPercent: 12,
memoryPercent: 40,
diskPercent: 55,
},
},
});
mockUseContainersDashboardUrlState.mockReturnValue({
searchTerm: "",
setSearchTerm: vi.fn(),
stateFilter: "all",
setStateFilter: vi.fn(),
hostFilter: "all",
setHostFilter: vi.fn(),
sortDirection: "desc",
setSortDirection: vi.fn(),
sortBy: "created",
setSortBy: vi.fn(),
groupBy: "none",
setGroupBy: vi.fn(),
statsInterval: "1h",
setStatsInterval: vi.fn(),
dateRange: undefined,
setDateRange: vi.fn(),
clearDateRange: vi.fn(),
pageSize: 10,
setPageSize: vi.fn(),
page: 1,
setPage: vi.fn(),
expandedGroups: [],
setExpandedGroups: vi.fn(),
});
});
it("renders the home dashboard without mounting closed overlay sheets", () => {
renderDashboard();
expect(screen.getByText("summary cards")).toBeInTheDocument();
expect(mockContainersLogsSheet).not.toHaveBeenCalled();
expect(mockContainerDetailsSheet).not.toHaveBeenCalled();
});
it("mounts the requested sheet only after the matching action is triggered", () => {
renderDashboard();
fireEvent.click(screen.getByRole("button", { name: "Open logs" }));
expect(mockContainersLogsSheet).toHaveBeenCalledTimes(1);
expect(screen.getByTestId("logs-sheet")).toBeInTheDocument();
expect(mockContainerDetailsSheet).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Open stats" }));
expect(mockContainerDetailsSheet).toHaveBeenCalledTimes(1);
expect(screen.getByTestId("details-sheet")).toBeInTheDocument();
});
it("confirms multi-select stop before executing actions", async () => {
mockUseContainersQuery.mockReturnValue({
data: {
containers: [container, secondContainer],
readOnly: false,
hosts: [{ Name: "local", Host: "unix:///var/run/docker.sock" }],
hostErrors: [],
},
error: null,
isError: false,
isFetching: false,
isLoading: false,
refetch: vi.fn(),
});
renderDashboard();
fireEvent.click(screen.getByRole("button", { name: "Select container-1" }));
fireEvent.click(screen.getByRole("button", { name: "Select container-2" }));
fireEvent.click(screen.getByRole("button", { name: "Stop" }));
expect(mockStopContainer).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Stop Containers" }));
await waitFor(() => expect(mockStopContainer).toHaveBeenCalledTimes(2));
});
});

View file

@ -0,0 +1,131 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ContainersTable } from "./containers-table";
const baseContainer = {
id: "container-1",
names: ["/api"],
image: "ghcr.io/example/api:latest",
image_id: "sha256:1",
command: "node server.js",
created: 1_700_000_000,
state: "running",
status: "Up 2 hours",
host: "local",
labels: {
"com.docker.compose.project": "project-alpha",
},
};
describe("ContainersTable", () => {
it('shows "Collecting" when historical stats are not available yet', () => {
render(
<ContainersTable
error={null}
expandedGroups={[]}
filteredContainers={[baseContainer]}
groupBy="none"
groupedItems={null}
isError={false}
isLoading={false}
isReadOnly={false}
onDelete={vi.fn()}
onRetry={vi.fn()}
onRestart={vi.fn()}
onSelectAll={vi.fn()}
onStart={vi.fn()}
onStop={vi.fn()}
onToggleGroup={vi.fn()}
onToggleSelect={vi.fn()}
onViewLogs={vi.fn()}
onViewStats={vi.fn()}
pageItems={[baseContainer]}
pendingAction={null}
selectedIds={[]}
statsInterval="1h"
/>,
);
expect(screen.getAllByText("Collecting")).toHaveLength(2);
});
it("keeps the compose group label together", () => {
render(
<ContainersTable
error={null}
expandedGroups={["project-alpha"]}
filteredContainers={[baseContainer, { ...baseContainer, id: "container-2", names: ["/worker"] }]}
groupBy="compose"
groupedItems={[
{
project: "project-alpha",
items: [baseContainer, { ...baseContainer, id: "container-2", names: ["/worker"] }],
},
]}
isError={false}
isLoading={false}
isReadOnly={false}
onDelete={vi.fn()}
onRetry={vi.fn()}
onRestart={vi.fn()}
onSelectAll={vi.fn()}
onStart={vi.fn()}
onStop={vi.fn()}
onToggleGroup={vi.fn()}
onToggleSelect={vi.fn()}
onViewLogs={vi.fn()}
onViewStats={vi.fn()}
pageItems={[baseContainer, { ...baseContainer, id: "container-2", names: ["/worker"] }]}
pendingAction={null}
selectedIds={[]}
statsInterval="1h"
/>,
);
expect(screen.getByRole("button", { name: /project-alpha/i }).textContent).toContain(
"project-alpha · 2 containers",
);
});
it("renders the image copy control as an accessible button", () => {
const writeText = vi.fn();
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
render(
<ContainersTable
error={null}
expandedGroups={[]}
filteredContainers={[baseContainer]}
groupBy="none"
groupedItems={null}
isError={false}
isLoading={false}
isReadOnly={false}
onDelete={vi.fn()}
onRetry={vi.fn()}
onRestart={vi.fn()}
onSelectAll={vi.fn()}
onStart={vi.fn()}
onStop={vi.fn()}
onToggleGroup={vi.fn()}
onToggleSelect={vi.fn()}
onViewLogs={vi.fn()}
onViewStats={vi.fn()}
pageItems={[baseContainer]}
pendingAction={null}
selectedIds={[]}
statsInterval="1h"
/>,
);
fireEvent.click(
screen.getByRole("button", { name: `Copy ${baseContainer.image}` }),
);
expect(writeText).toHaveBeenCalledWith(baseContainer.image);
});
});

View file

@ -1,328 +1,425 @@
import { ActivityIcon, FileTextIcon, PlayIcon, RotateCwIcon, SquareIcon, Trash2Icon } from "lucide-react";
import {
ActivityIcon,
ChevronDownIcon,
ChevronRightIcon,
FileTextIcon,
PlayIcon,
RotateCwIcon,
SquareIcon,
Trash2Icon,
} from "lucide-react";
import { Fragment } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
formatContainerName,
formatCreatedDate,
formatUptime,
getStateBadgeClass,
toTitleCase,
} from "./container-utils";
import type { ContainerInfo } from "../types";
import type {
ContainerActionType,
GroupByOption,
GroupedContainers,
ContainerActionType,
GroupByOption,
GroupedContainers,
StatsInterval,
} from "./container-utils";
import {
formatContainerName,
formatCreatedDate,
formatUptime,
getHistoricalValue,
getStateBadgeClass,
toTitleCase,
} from "./container-utils";
interface ContainersTableProps {
isLoading: boolean;
isError: boolean;
error: unknown;
groupBy: GroupByOption;
filteredContainers: ContainerInfo[];
groupedItems: GroupedContainers[] | null;
pageItems: ContainerInfo[];
pendingAction: { id: string; type: ContainerActionType } | null;
isReadOnly: boolean;
onStart: (container: ContainerInfo) => void;
onStop: (container: ContainerInfo) => void;
onRestart: (container: ContainerInfo) => void;
onDelete: (container: ContainerInfo) => void;
onViewLogs: (container: ContainerInfo) => void;
onViewStats: (container: ContainerInfo) => void;
onRetry: () => void;
isLoading: boolean;
isError: boolean;
error: unknown;
groupBy: GroupByOption;
filteredContainers: ContainerInfo[];
groupedItems: GroupedContainers[] | null;
pageItems: ContainerInfo[];
pendingAction: { id: string; type: ContainerActionType } | null;
isReadOnly: boolean;
expandedGroups: string[];
selectedIds: string[];
statsInterval: StatsInterval;
onToggleSelect: (id: string) => void;
onSelectAll: () => void;
onToggleGroup: (project: string) => void;
onStart: (container: ContainerInfo) => void;
onStop: (container: ContainerInfo) => void;
onRestart: (container: ContainerInfo) => void;
onDelete: (container: ContainerInfo) => void;
onViewLogs: (container: ContainerInfo) => void;
onViewStats: (container: ContainerInfo) => void;
onRetry: () => void;
}
export function ContainersTable({
isLoading,
isError,
error,
groupBy,
filteredContainers,
groupedItems,
pageItems,
pendingAction,
isReadOnly,
onStart,
onStop,
onRestart,
onDelete,
onViewLogs,
onViewStats,
onRetry,
isLoading,
isError,
error,
groupBy,
filteredContainers,
groupedItems,
pageItems,
pendingAction,
isReadOnly,
expandedGroups,
selectedIds,
statsInterval,
onToggleSelect,
onSelectAll,
onToggleGroup,
onStart,
onStop,
onRestart,
onDelete,
onViewLogs,
onViewStats,
onRetry,
}: ContainersTableProps) {
const isContainerActionPending = (
action: ContainerActionType,
containerId: string
) =>
pendingAction?.id === containerId && pendingAction.type === action;
const isContainerActionPending = (
action: ContainerActionType,
containerId: string,
) => pendingAction?.id === containerId && pendingAction.type === action;
const isContainerBusy = (containerId: string) =>
pendingAction?.id === containerId;
const isContainerBusy = (containerId: string) =>
pendingAction?.id === containerId;
const renderContainerRow = (container: ContainerInfo) => {
const state = container.state.toLowerCase();
const busy = isContainerBusy(container.id);
const startPending = isContainerActionPending("start", container.id);
const stopPending = isContainerActionPending("stop", container.id);
const restartPending = isContainerActionPending("restart", container.id);
const removePending = isContainerActionPending("remove", container.id);
const formatHistoricalMetric = (value: number | null) =>
value === null ? "Collecting" : `${value.toFixed(1)}%`;
return (
<TableRow key={container.id} className="hover:bg-muted/50">
<TableCell className="h-16 px-4 font-medium">
{formatContainerName(container.names)}
</TableCell>
<TableCell className="h-16 px-4 text-sm text-muted-foreground">
{container.image}
</TableCell>
<TableCell className="h-16 px-4">
<Badge
className={`${getStateBadgeClass(container.state)} border-0`}
>
{toTitleCase(container.state)}
</Badge>
</TableCell>
<TableCell className="h-16 px-4 text-sm text-muted-foreground">
{formatUptime(container.created)}
</TableCell>
<TableCell className="h-16 px-4 text-sm text-muted-foreground">
{formatCreatedDate(container.created)}
</TableCell>
<TableCell className="h-16 px-4 max-w-[300px] text-sm text-muted-foreground">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="block cursor-help truncate">
{container.command}
</span>
</TooltipTrigger>
<TooltipContent className="max-w-md">
{container.command}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</TableCell>
<TableCell className="h-16 px-4">
<TooltipProvider>
<div className="flex items-center gap-1">
{state === "exited" && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-block">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => onStart(container)}
disabled={busy || isReadOnly}
>
{startPending ? (
<Spinner className="size-4" />
) : (
<PlayIcon className="size-4" />
)}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{isReadOnly ? "Start (Read-only mode)" : "Start"}
</TooltipContent>
</Tooltip>
)}
{state === "running" && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-block">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => onStop(container)}
disabled={busy || isReadOnly}
>
{stopPending ? (
<Spinner className="size-4" />
) : (
<SquareIcon className="size-4" />
)}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{isReadOnly ? "Stop (Read-only mode)" : "Stop"}
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-block">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => onRestart(container)}
disabled={busy || isReadOnly}
>
{restartPending ? (
<Spinner className="size-4" />
) : (
<RotateCwIcon className="size-4" />
)}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{isReadOnly ? "Restart (Read-only mode)" : "Restart"}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-block">
<Button
variant="outline"
size="icon"
className="h-8 w-8 text-destructive hover:bg-destructive hover:text-white"
onClick={() => onDelete(container)}
disabled={busy || isReadOnly}
>
{removePending ? (
<Spinner className="size-4" />
) : (
<Trash2Icon className="size-4" />
)}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{isReadOnly ? "Delete (Read-only mode)" : "Delete"}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => onViewLogs(container)}
disabled={busy}
>
<FileTextIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>View Logs</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => onViewStats(container)}
disabled={busy}
>
<ActivityIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>View Stats</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</TableCell>
</TableRow>
);
};
const renderContainerRow = (container: ContainerInfo) => {
const state = container.state.toLowerCase();
const busy = isContainerBusy(container.id);
const startPending = isContainerActionPending("start", container.id);
const stopPending = isContainerActionPending("stop", container.id);
const restartPending = isContainerActionPending("restart", container.id);
const removePending = isContainerActionPending("remove", container.id);
const cpuAverage = getHistoricalValue(container, statsInterval, "cpu");
const memoryAverage = getHistoricalValue(
container,
statsInterval,
"memory",
);
return (
<div className="rounded-lg border bg-card">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent border-b">
<TableHead className="h-12 px-4 font-medium">Name</TableHead>
<TableHead className="h-12 px-4 font-medium">Image</TableHead>
<TableHead className="h-12 px-4 font-medium w-[120px]">
State
</TableHead>
<TableHead className="h-12 px-4 font-medium">Uptime</TableHead>
<TableHead className="h-12 px-4 font-medium">Created</TableHead>
<TableHead className="h-12 px-4 font-medium">Command</TableHead>
<TableHead className="h-12 px-4 font-medium w-[160px]">
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={7} className="h-32">
<div className="flex items-center justify-center text-sm text-muted-foreground">
<Spinner className="mr-2" />
Loading containers
</div>
</TableCell>
</TableRow>
) : isError ? (
<TableRow>
<TableCell colSpan={7} className="h-32">
<div className="flex flex-col items-center gap-3 text-center">
<p className="text-sm text-muted-foreground">
{(error as Error)?.message || "Unable to load containers."}
</p>
<Button size="sm" variant="outline" onClick={onRetry}>
Try again
</Button>
</div>
</TableCell>
</TableRow>
) : filteredContainers.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="h-32">
<div className="text-center text-sm text-muted-foreground">
No containers found.
</div>
</TableCell>
</TableRow>
) : groupBy === "compose" && groupedItems ? (
groupedItems.map((group) => (
<Fragment key={group.project}>
<TableRow className="bg-muted/30 hover:bg-muted/30">
<TableCell
colSpan={7}
className="h-10 px-4 text-xs font-medium text-muted-foreground"
>
{group.project} · {group.items.length} container
{group.items.length === 1 ? "" : "s"}
</TableCell>
</TableRow>
{group.items.map(renderContainerRow)}
</Fragment>
))
) : (
pageItems.map(renderContainerRow)
)}
</TableBody>
</Table>
</div>
);
return (
<TableRow key={container.id} className="hover:bg-muted/50">
<TableCell className="w-10 px-4">
<input
type="checkbox"
checked={selectedIds.includes(container.id)}
onChange={() => onToggleSelect(container.id)}
aria-label={`Select ${formatContainerName(container.names)}`}
/>
</TableCell>
<TableCell className="h-16 px-4 font-medium">
{formatContainerName(container.names)}
</TableCell>
<TableCell className="h-16 px-4 text-sm text-muted-foreground">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="block max-w-[260px] cursor-pointer truncate"
onClick={() => {
navigator.clipboard?.writeText(container.image);
}}
title="Click to copy image name"
aria-label={`Copy ${container.image}`}
>
{container.image}
</button>
</TooltipTrigger>
<TooltipContent className="max-w-md break-all">
{container.image}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</TableCell>
<TableCell className="h-16 px-4">
<Badge className={`${getStateBadgeClass(container.state)} border-0`}>
{toTitleCase(container.state)}
</Badge>
</TableCell>
<TableCell className="h-16 px-4 text-sm text-muted-foreground">
{formatUptime(container.created)}
</TableCell>
<TableCell className="h-16 px-4 text-sm text-muted-foreground">
{formatCreatedDate(container.created)}
</TableCell>
<TableCell className="h-16 px-4 text-sm text-muted-foreground">
{formatHistoricalMetric(cpuAverage)}
</TableCell>
<TableCell className="h-16 px-4 text-sm text-muted-foreground">
{formatHistoricalMetric(memoryAverage)}
</TableCell>
<TableCell className="h-16 max-w-[300px] px-4 text-sm text-muted-foreground">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="block max-w-[280px] cursor-help truncate">
{container.command}
</span>
</TooltipTrigger>
<TooltipContent className="max-w-md break-all">
{container.command}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</TableCell>
<TableCell className="h-16 px-4">
<TooltipProvider>
<div className="flex items-center gap-1">
{state === "exited" && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-block">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => onStart(container)}
disabled={busy || isReadOnly}
aria-label={`Start container ${formatContainerName(container.names)}`}
>
{startPending ? (
<Spinner className="size-4" />
) : (
<PlayIcon className="size-4" />
)}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{isReadOnly ? "Start (Read-only mode)" : "Start"}
</TooltipContent>
</Tooltip>
)}
{state === "running" && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-block">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => onStop(container)}
disabled={busy || isReadOnly}
aria-label={`Stop container ${formatContainerName(container.names)}`}
>
{stopPending ? (
<Spinner className="size-4" />
) : (
<SquareIcon className="size-4" />
)}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{isReadOnly ? "Stop (Read-only mode)" : "Stop"}
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-block">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => onRestart(container)}
disabled={busy || isReadOnly}
aria-label={`Restart container ${formatContainerName(container.names)}`}
>
{restartPending ? (
<Spinner className="size-4" />
) : (
<RotateCwIcon className="size-4" />
)}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{isReadOnly ? "Restart (Read-only mode)" : "Restart"}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-block">
<Button
variant="outline"
size="icon"
className="h-8 w-8 text-destructive hover:bg-destructive hover:text-white"
onClick={() => onDelete(container)}
disabled={busy || isReadOnly}
aria-label={`Delete container ${formatContainerName(container.names)}`}
>
{removePending ? (
<Spinner className="size-4" />
) : (
<Trash2Icon className="size-4" />
)}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{isReadOnly ? "Delete (Read-only mode)" : "Delete"}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => onViewLogs(container)}
disabled={busy}
aria-label={`View logs for container ${formatContainerName(container.names)}`}
>
<FileTextIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>View Logs</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => onViewStats(container)}
disabled={busy}
aria-label={`View stats for container ${formatContainerName(container.names)}`}
>
<ActivityIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>View Stats</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</TableCell>
</TableRow>
);
};
return (
<div className="overflow-x-auto rounded-lg border bg-card">
<Table className="min-w-[1180px]">
<TableHeader>
<TableRow className="hover:bg-transparent border-b">
<TableHead className="h-12 w-10 px-4">
<input
type="checkbox"
checked={
pageItems.length > 0 &&
selectedIds.length === pageItems.length
}
onChange={onSelectAll}
aria-label="Select all containers on this page"
/>
</TableHead>
<TableHead className="h-12 px-4 font-medium">Name</TableHead>
<TableHead className="h-12 px-4 font-medium">Image</TableHead>
<TableHead className="h-12 px-4 font-medium w-[120px]">
State
</TableHead>
<TableHead className="h-12 px-4 font-medium">Uptime</TableHead>
<TableHead className="h-12 px-4 font-medium">Created</TableHead>
<TableHead className="h-12 px-4 font-medium">
CPU {statsInterval}
</TableHead>
<TableHead className="h-12 px-4 font-medium">
RAM {statsInterval}
</TableHead>
<TableHead className="h-12 px-4 font-medium">Command</TableHead>
<TableHead className="h-12 px-4 font-medium w-[160px]">
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={10} className="h-32">
<div className="flex items-center justify-center text-sm text-muted-foreground">
<Spinner className="mr-2" />
Loading containers
</div>
</TableCell>
</TableRow>
) : isError ? (
<TableRow>
<TableCell colSpan={10} className="h-32">
<div className="flex flex-col items-center gap-3 text-center">
<p className="text-sm text-muted-foreground">
{(error as Error)?.message || "Unable to load containers."}
</p>
<Button size="sm" variant="outline" onClick={onRetry}>
Try again
</Button>
</div>
</TableCell>
</TableRow>
) : filteredContainers.length === 0 ? (
<TableRow>
<TableCell colSpan={10} className="h-32">
<div className="text-center text-sm text-muted-foreground">
No containers found.
</div>
</TableCell>
</TableRow>
) : groupBy === "compose" && groupedItems ? (
groupedItems.map((group) => (
<Fragment key={group.project}>
<TableRow className="bg-muted/30 hover:bg-muted/30">
<TableCell
colSpan={10}
className="h-10 px-4 text-xs font-medium text-muted-foreground"
>
<button
type="button"
className="inline-flex max-w-full items-center gap-2 truncate"
onClick={() => onToggleGroup(group.project)}
>
{expandedGroups.includes(group.project) ? (
<ChevronDownIcon className="size-4" />
) : (
<ChevronRightIcon className="size-4" />
)}
<span className="truncate">
{group.project} · {group.items.length}{" "}
{group.items.length === 1 ? "container" : "containers"}
</span>
</button>
</TableCell>
</TableRow>
{expandedGroups.includes(group.project)
? group.items.map(renderContainerRow)
: null}
</Fragment>
))
) : (
pageItems.map(renderContainerRow)
)}
</TableBody>
</Table>
</div>
);
}

View file

@ -1,290 +1,356 @@
import { Link, useNavigate } from "@tanstack/react-router";
import {
CalendarIcon,
ChevronDownIcon,
LogOutIcon,
RefreshCcwIcon,
SettingsIcon,
XIcon
CalendarIcon,
ChevronDownIcon,
LogOutIcon,
RefreshCcwIcon,
SettingsIcon,
XIcon,
} from "lucide-react";
import type { DateRange } from "react-day-picker";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipTrigger
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useAuth } from "@/contexts/auth-context";
import type { DockerHost } from "../types";
import type {
GroupByOption,
SortColumn,
SortDirection,
StatsInterval,
} from "./container-utils";
import { toTitleCase } from "./container-utils";
import type { DateRange } from "react-day-picker";
import type { GroupByOption, SortDirection } from "./container-utils";
import type { DockerHost } from "../types";
interface ContainersToolbarProps {
searchTerm: string;
onSearchChange: (value: string) => void;
stateFilter: string;
onStateFilterChange: (value: string) => void;
availableStates: string[];
hostFilter: string;
onHostFilterChange: (value: string) => void;
availableHosts: DockerHost[];
sortDirection: SortDirection;
onSortDirectionChange: (direction: SortDirection) => void;
groupBy: GroupByOption;
onGroupByChange: (value: GroupByOption) => void;
dateRange: DateRange | undefined;
onDateRangeChange: (range: DateRange | undefined) => void;
onDateRangeClear: () => void;
onRefresh: () => void;
isFetching: boolean;
searchTerm: string;
onSearchChange: (value: string) => void;
stateFilter: string;
onStateFilterChange: (value: string) => void;
availableStates: string[];
hostFilter: string;
onHostFilterChange: (value: string) => void;
availableHosts: DockerHost[];
sortDirection: SortDirection;
onSortDirectionChange: (direction: SortDirection) => void;
sortBy: SortColumn;
onSortByChange: (value: SortColumn) => void;
groupBy: GroupByOption;
onGroupByChange: (value: GroupByOption) => void;
statsInterval: StatsInterval;
onStatsIntervalChange: (value: StatsInterval) => void;
dateRange: DateRange | undefined;
onDateRangeChange: (range: DateRange | undefined) => void;
onDateRangeClear: () => void;
onRefresh: () => void;
isFetching: boolean;
}
export function ContainersToolbar({
searchTerm,
onSearchChange,
stateFilter,
onStateFilterChange,
availableStates,
hostFilter,
onHostFilterChange,
availableHosts,
sortDirection,
onSortDirectionChange,
groupBy,
onGroupByChange,
dateRange,
onDateRangeChange,
onDateRangeClear,
onRefresh,
isFetching,
searchTerm,
onSearchChange,
stateFilter,
onStateFilterChange,
availableStates,
hostFilter,
onHostFilterChange,
availableHosts,
sortDirection,
onSortDirectionChange,
sortBy,
onSortByChange,
groupBy,
onGroupByChange,
statsInterval,
onStatsIntervalChange,
dateRange,
onDateRangeChange,
onDateRangeClear,
onRefresh,
isFetching,
}: ContainersToolbarProps) {
const { logout, user, isAuthEnabled } = useAuth();
const navigate = useNavigate();
const { logout, user, isAuthEnabled } = useAuth();
const navigate = useNavigate();
const handleLogout = () => {
logout();
navigate({ to: "/login" });
};
const handleLogout = () => {
logout();
navigate({ to: "/login" });
};
const renderDateRange = () => {
if (!dateRange?.from) {
return <span>Date range</span>;
}
const sortLabels: Record<SortColumn, string> = {
name: "Name",
state: "State",
uptime: "Uptime",
created: "Created",
cpu: "CPU",
ram: "RAM",
};
if (dateRange.to) {
const from = dateRange.from.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
const to = dateRange.to.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
return (
<>
{from} - {to}
</>
);
}
const renderDateRange = () => {
if (!dateRange?.from) {
return <span>Date range</span>;
}
return dateRange.from.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
};
if (dateRange.to) {
const from = dateRange.from.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
const to = dateRange.to.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
return (
<>
{from} - {to}
</>
);
}
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Input
type="search"
value={searchTerm}
onChange={(event) => onSearchChange(event.target.value)}
placeholder="Search containers..."
className="sm:max-w-sm"
/>
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9">
{hostFilter === "all" ? "All hosts" : hostFilter}
<ChevronDownIcon className="ml-2 size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup
value={hostFilter}
onValueChange={onHostFilterChange}
>
<DropdownMenuRadioItem value="all">
All hosts
</DropdownMenuRadioItem>
{availableHosts.map((host) => (
<DropdownMenuRadioItem key={host.Name} value={host.Name}>
{host.Name}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
return dateRange.from.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
};
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9">
{stateFilter === "all" ? "All states" : toTitleCase(stateFilter)}
<ChevronDownIcon className="ml-2 size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup
value={stateFilter}
onValueChange={onStateFilterChange}
>
<DropdownMenuRadioItem value="all">
All states
</DropdownMenuRadioItem>
{availableStates.map((state) => (
<DropdownMenuRadioItem key={state} value={state}>
{toTitleCase(state)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Input
type="search"
value={searchTerm}
onChange={(event) => onSearchChange(event.target.value)}
placeholder="Search containers..."
className="sm:max-w-sm"
/>
<div className="flex flex-wrap items-center gap-2 md:flex-nowrap">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9">
{hostFilter === "all" ? "All hosts" : hostFilter}
<ChevronDownIcon className="ml-2 size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup
value={hostFilter}
onValueChange={onHostFilterChange}
>
<DropdownMenuRadioItem value="all">
All hosts
</DropdownMenuRadioItem>
{availableHosts.map((host) => (
<DropdownMenuRadioItem key={host.Name} value={host.Name}>
{host.Name}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9">
{sortDirection === "desc" ? "Newest" : "Oldest"}
<ChevronDownIcon className="ml-2 size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup
value={sortDirection}
onValueChange={(value) =>
onSortDirectionChange(value as SortDirection)
}
>
<DropdownMenuRadioItem value="desc">
Newest first
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="asc">
Oldest first
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9">
{stateFilter === "all" ? "All states" : toTitleCase(stateFilter)}
<ChevronDownIcon className="ml-2 size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup
value={stateFilter}
onValueChange={onStateFilterChange}
>
<DropdownMenuRadioItem value="all">
All states
</DropdownMenuRadioItem>
{availableStates.map((state) => (
<DropdownMenuRadioItem key={state} value={state}>
{toTitleCase(state)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9">
{groupBy === "compose" ? "By project" : "No grouping"}
<ChevronDownIcon className="ml-2 size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup
value={groupBy}
onValueChange={(value) => onGroupByChange(value as GroupByOption)}
>
<DropdownMenuRadioItem value="none">
No grouping
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="compose">
By compose project
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9">
Sort: {sortLabels[sortBy]}
<ChevronDownIcon className="ml-2 size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup
value={sortBy}
onValueChange={(value) => onSortByChange(value as SortColumn)}
>
<DropdownMenuRadioItem value="name">Name</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="state">State</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="uptime">
Uptime
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="created">
Created
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="cpu">CPU</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="ram">RAM</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<Popover>
<PopoverTrigger asChild>
<Button
variant={dateRange?.from ? "default" : "outline"}
size="sm"
className="h-9 justify-start text-left font-normal"
>
<CalendarIcon className="mr-2 size-4" />
{renderDateRange()}
{dateRange?.from && (
<XIcon
className="ml-2 size-4 hover:text-destructive"
onClick={(event) => {
event.stopPropagation();
onDateRangeClear();
}}
/>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="end">
<Calendar
mode="range"
defaultMonth={dateRange?.from}
selected={dateRange}
onSelect={onDateRangeChange}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9">
{sortDirection === "desc" ? "Desc" : "Asc"}
<ChevronDownIcon className="ml-2 size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup
value={sortDirection}
onValueChange={(value) =>
onSortDirectionChange(value as SortDirection)
}
>
<DropdownMenuRadioItem value="desc">
Descending
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="asc">
Ascending
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="sm"
onClick={onRefresh}
className="h-9 shrink-0"
>
<RefreshCcwIcon
className={`size-4 ${isFetching ? "animate-spin" : ""}`}
/>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9">
{groupBy === "compose" ? "By project" : "No grouping"}
<ChevronDownIcon className="ml-2 size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup
value={groupBy}
onValueChange={(value) => onGroupByChange(value as GroupByOption)}
>
<DropdownMenuRadioItem value="none">
No grouping
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="compose">
By compose project
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="sm" className="h-9 shrink-0" asChild>
<Link to="/settings" aria-label="Settings">
<SettingsIcon className="size-4" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>Settings</TooltipContent>
</Tooltip>
<div className="flex items-center rounded-md border bg-background p-1">
<Button
variant={statsInterval === "1h" ? "secondary" : "ghost"}
size="sm"
className="h-7 px-2 text-xs"
onClick={() => onStatsIntervalChange("1h")}
>
1h
</Button>
<Button
variant={statsInterval === "12h" ? "secondary" : "ghost"}
size="sm"
className="h-7 px-2 text-xs"
onClick={() => onStatsIntervalChange("12h")}
>
12h
</Button>
</div>
{isAuthEnabled && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={handleLogout}
className="h-9 shrink-0"
>
<LogOutIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
Logout {user?.username ? `(${user.username})` : ""}
</TooltipContent>
</Tooltip>
)}
</div>
</div>
);
<Popover>
<PopoverTrigger asChild>
<Button
variant={dateRange?.from ? "default" : "outline"}
size="sm"
className="h-9 justify-start text-left font-normal"
>
<CalendarIcon className="mr-2 size-4" />
{renderDateRange()}
{dateRange?.from && (
<XIcon
className="ml-2 size-4 hover:text-destructive"
onClick={(event) => {
event.stopPropagation();
onDateRangeClear();
}}
/>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="end">
<Calendar
mode="range"
defaultMonth={dateRange?.from}
selected={dateRange}
onSelect={onDateRangeChange}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
<Button
variant="ghost"
size="sm"
onClick={onRefresh}
className="h-9 shrink-0"
aria-label="Refresh"
>
<RefreshCcwIcon
className={`size-4 ${isFetching ? "animate-spin" : ""}`}
/>
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="sm" className="h-9 shrink-0" asChild>
<Link to="/settings" aria-label="Settings">
<SettingsIcon className="size-4" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>Settings</TooltipContent>
</Tooltip>
{isAuthEnabled && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={handleLogout}
className="h-9 shrink-0"
aria-label="Logout"
>
<LogOutIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
Logout {user?.username ? `(${user.username})` : ""}
</TooltipContent>
</Tooltip>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,13 @@
import { useQuery } from "@tanstack/react-query";
import { getContainerHistory } from "../api/get-container-history";
export function useContainerHistory(id: string, host: string, enabled = true) {
return useQuery({
queryKey: ["container-history", host, id],
queryFn: () => getContainerHistory(id, host),
enabled: enabled && Boolean(id) && Boolean(host),
staleTime: 60_000,
refetchInterval: 60_000,
});
}

View file

@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { hasDashboardParamChanges } from "./use-containers-dashboard-url-state";
const baseParams = {
search: "",
state: "all",
host: "all",
sort: "desc" as const,
sortBy: "created" as const,
group: "none" as const,
interval: "1h" as const,
page: 1,
pageSize: 10,
from: null,
to: null,
expanded: ["project-a"],
};
describe("hasDashboardParamChanges", () => {
it("returns false when updates do not change the current params", () => {
expect(
hasDashboardParamChanges(baseParams, {
search: "",
page: 1,
expanded: ["project-a"],
}),
).toBe(false);
});
it("compares date values by timestamp rather than object identity", () => {
const current = {
...baseParams,
from: new Date("2026-04-22T00:00:00.000Z"),
to: new Date("2026-04-23T00:00:00.000Z"),
};
expect(
hasDashboardParamChanges(current, {
from: new Date("2026-04-22T00:00:00.000Z"),
to: new Date("2026-04-23T00:00:00.000Z"),
}),
).toBe(false);
});
it("returns true when any param value actually changes", () => {
expect(
hasDashboardParamChanges(baseParams, {
host: "prod-1",
}),
).toBe(true);
});
});

View file

@ -1,186 +1,323 @@
import {
createParser,
parseAsInteger,
parseAsIsoDateTime,
parseAsString,
useQueryStates
createParser,
parseAsArrayOf,
parseAsInteger,
parseAsIsoDateTime,
parseAsString,
useQueryStates,
} from "nuqs";
import { useCallback, useMemo } from "react";
import type { DateRange } from "react-day-picker";
import type {
GroupByOption,
SortDirection,
GroupByOption,
SortColumn,
SortDirection,
StatsInterval,
} from "../components/container-utils";
// Custom parser for SortDirection
const parseAsSortDirection = createParser({
parse: (value): SortDirection | null => {
if (value === "asc" || value === "desc") {
return value;
}
return null;
},
serialize: (value: SortDirection) => value,
parse: (value): SortDirection | null => {
if (value === "asc" || value === "desc") {
return value;
}
return null;
},
serialize: (value: SortDirection) => value,
});
// Custom parser for GroupByOption
const parseAsGroupBy = createParser({
parse: (value): GroupByOption | null => {
if (value === "none" || value === "compose") {
return value;
}
return null;
},
serialize: (value: GroupByOption) => value,
parse: (value): GroupByOption | null => {
if (value === "none" || value === "compose") {
return value;
}
return null;
},
serialize: (value: GroupByOption) => value,
});
const parseAsStatsInterval = createParser({
parse: (value): StatsInterval | null => {
if (value === "1h" || value === "12h") {
return value;
}
return null;
},
serialize: (value: StatsInterval) => value,
});
const parseAsSortColumn = createParser({
parse: (value): SortColumn | null => {
if (["name", "state", "uptime", "created", "cpu", "ram"].includes(value)) {
return value as SortColumn;
}
return null;
},
serialize: (value: SortColumn) => value,
});
// Search params configuration with defaults
const searchParamsConfig = {
search: parseAsString.withDefault(""),
state: parseAsString.withDefault("all"),
host: parseAsString.withDefault("all"),
sort: parseAsSortDirection.withDefault("desc" as SortDirection),
group: parseAsGroupBy.withDefault("none" as GroupByOption),
page: parseAsInteger.withDefault(1),
pageSize: parseAsInteger.withDefault(10),
from: parseAsIsoDateTime,
to: parseAsIsoDateTime,
search: parseAsString.withDefault(""),
state: parseAsString.withDefault("all"),
host: parseAsString.withDefault("all"),
sort: parseAsSortDirection.withDefault("desc" as SortDirection),
sortBy: parseAsSortColumn.withDefault("created" as SortColumn),
group: parseAsGroupBy.withDefault("none" as GroupByOption),
interval: parseAsStatsInterval.withDefault("1h" as StatsInterval),
page: parseAsInteger.withDefault(1),
pageSize: parseAsInteger.withDefault(10),
from: parseAsIsoDateTime,
to: parseAsIsoDateTime,
expanded: parseAsArrayOf(parseAsString).withDefault([]),
};
export function useContainersDashboardUrlState() {
const [params, setParams] = useQueryStates(searchParamsConfig, {
history: "replace",
});
type DashboardUrlParams = {
search: string;
state: string;
host: string;
sort: SortDirection;
sortBy: SortColumn;
group: GroupByOption;
interval: StatsInterval;
page: number;
pageSize: number;
from: Date | null;
to: Date | null;
expanded: string[];
};
const {
search: searchTerm,
state: stateFilter,
host: hostFilter,
sort: sortDirection,
group: groupBy,
page,
pageSize,
from,
to,
} = params;
function areStringArraysEqual(left: string[], right: string[]) {
if (left.length !== right.length) {
return false;
}
// Convert from/to into DateRange format
// Supports open-ended ranges: from without to, to without from, or both
const dateRange = useMemo((): DateRange | undefined => {
if (!from && !to) {
return undefined;
}
return { from: from ?? undefined, to: to ?? undefined };
}, [from, to]);
const setSearchTerm = useCallback(
(value: string) => {
setParams({
search: value,
page: 1,
});
},
[setParams]
);
const setStateFilter = useCallback(
(value: string) => {
const normalized = value || "all";
setParams({
state: normalized,
page: 1,
});
},
[setParams]
);
const setHostFilter = useCallback(
(value: string) => {
const normalized = value || "all";
setParams({
host: normalized,
page: 1,
});
},
[setParams]
);
const setSortDirection = useCallback(
(value: SortDirection) => {
setParams({
sort: value,
});
},
[setParams]
);
const setGroupBy = useCallback(
(value: GroupByOption) => {
setParams({
group: value,
page: 1,
});
},
[setParams]
);
const setDateRange = useCallback(
(range: DateRange | undefined) => {
setParams({
from: range?.from ?? null,
to: range?.to ?? null,
page: 1,
});
},
[setParams]
);
const clearDateRange = useCallback(() => {
setParams({
from: null,
to: null,
page: 1,
});
}, [setParams]);
const setPage = useCallback(
(value: number) => {
setParams({
page: Math.max(1, Math.floor(value)),
});
},
[setParams]
);
const setPageSize = useCallback(
(value: number) => {
setParams({
pageSize: Math.max(1, Math.floor(value)),
page: 1,
});
},
[setParams]
);
return {
searchTerm,
setSearchTerm,
stateFilter,
setStateFilter,
hostFilter,
setHostFilter,
sortDirection,
setSortDirection,
groupBy,
setGroupBy,
dateRange,
setDateRange,
clearDateRange,
page,
setPage,
pageSize,
setPageSize,
};
return left.every((value, index) => value === right[index]);
}
function areDatesEqual(left: Date | null, right: Date | null) {
if (left === right) {
return true;
}
if (!left || !right) {
return false;
}
return left.getTime() === right.getTime();
}
function isDashboardParamEqual(
currentValue: DashboardUrlParams[keyof DashboardUrlParams],
nextValue: DashboardUrlParams[keyof DashboardUrlParams],
) {
if (Array.isArray(currentValue) && Array.isArray(nextValue)) {
return areStringArraysEqual(currentValue, nextValue);
}
if (
(currentValue instanceof Date || currentValue === null) &&
(nextValue instanceof Date || nextValue === null)
) {
return areDatesEqual(currentValue, nextValue);
}
return currentValue === nextValue;
}
export function hasDashboardParamChanges(
current: DashboardUrlParams,
updates: Partial<DashboardUrlParams>,
) {
return Object.entries(updates).some(([key, value]) => {
const typedKey = key as keyof DashboardUrlParams;
return !isDashboardParamEqual(
current[typedKey],
value as DashboardUrlParams[keyof DashboardUrlParams],
);
});
}
export function useContainersDashboardUrlState() {
const [params, setParams] = useQueryStates(searchParamsConfig, {
history: "replace",
});
const {
search: searchTerm,
state: stateFilter,
host: hostFilter,
sort: sortDirection,
sortBy,
group: groupBy,
interval: statsInterval,
page,
pageSize,
from,
to,
expanded: expandedGroups,
} = params;
const updateParams = useCallback(
(updates: Partial<DashboardUrlParams>) => {
if (hasDashboardParamChanges(params as DashboardUrlParams, updates)) {
setParams(updates);
}
},
[params, setParams],
);
// Convert from/to into DateRange format
// Supports open-ended ranges: from without to, to without from, or both
const dateRange = useMemo((): DateRange | undefined => {
if (!from && !to) {
return undefined;
}
return { from: from ?? undefined, to: to ?? undefined };
}, [from, to]);
const setSearchTerm = useCallback(
(value: string) => {
updateParams({
search: value,
page: 1,
});
},
[updateParams],
);
const setStateFilter = useCallback(
(value: string) => {
const normalized = value || "all";
updateParams({
state: normalized,
page: 1,
});
},
[updateParams],
);
const setHostFilter = useCallback(
(value: string) => {
const normalized = value || "all";
updateParams({
host: normalized,
page: 1,
});
},
[updateParams],
);
const setSortDirection = useCallback(
(value: SortDirection) => {
updateParams({
sort: value,
});
},
[updateParams],
);
const setSortBy = useCallback(
(value: SortColumn) => {
updateParams({
sortBy: value,
});
},
[updateParams],
);
const setGroupBy = useCallback(
(value: GroupByOption) => {
updateParams({
group: value,
page: 1,
});
},
[updateParams],
);
const setStatsInterval = useCallback(
(value: StatsInterval) => {
updateParams({
interval: value,
});
},
[updateParams],
);
const setDateRange = useCallback(
(range: DateRange | undefined) => {
updateParams({
from: range?.from ?? null,
to: range?.to ?? null,
page: 1,
});
},
[updateParams],
);
const clearDateRange = useCallback(() => {
updateParams({
from: null,
to: null,
page: 1,
});
}, [updateParams]);
const setPage = useCallback(
(value: number) => {
updateParams({
page: Math.max(1, Math.floor(value)),
});
},
[updateParams],
);
const setPageSize = useCallback(
(value: number) => {
updateParams({
pageSize: Math.max(1, Math.floor(value)),
page: 1,
});
},
[updateParams],
);
const setExpandedGroups = useCallback(
(value: string[]) => {
updateParams({
expanded: value,
});
},
[updateParams],
);
return {
searchTerm,
setSearchTerm,
stateFilter,
setStateFilter,
hostFilter,
setHostFilter,
sortDirection,
setSortDirection,
sortBy,
setSortBy,
groupBy,
setGroupBy,
statsInterval,
setStatsInterval,
dateRange,
setDateRange,
clearDateRange,
page,
setPage,
pageSize,
setPageSize,
expandedGroups,
setExpandedGroups,
};
}

View file

@ -14,12 +14,19 @@ export interface ContainerInfo {
status: string
labels?: Record<string, string>
host: string
historical_stats?: {
cpu_1h: number
memory_1h: number
cpu_12h: number
memory_12h: number
}
}
export interface ContainersQueryParams {
search?: string
state?: string
sortCreated?: "asc" | "desc"
sortBy?: "name" | "state" | "uptime" | "created" | "cpu" | "ram"
groupBy?: "none" | "compose"
host?: string
}

View file

@ -1,12 +1,21 @@
import { useMemo, useState } from "react";
import { Link } from "@tanstack/react-router";
import {
DownloadIcon,
FileCheck2Icon,
FileTextIcon,
RefreshCcwIcon,
SearchIcon,
ShieldAlertIcon,
ShieldCheckIcon,
Trash2Icon,
} from "lucide-react";
import { toast } from "sonner";
import { BulkScanDialog } from "@/features/scanner/components/bulk-scan-dialog";
import { SBOMDialog } from "@/features/scanner/components/sbom-dialog";
import { ScanDialog } from "@/features/scanner/components/scan-dialog";
import { useObservedSBOMJobs, useSBOMedImages, useScannedImages } from "@/features/scanner/hooks/use-scan-query";
import {
AlertDialog,
AlertDialogAction,
@ -37,8 +46,8 @@ import {
} from "@/components/ui/tooltip";
import { useImagesQuery, useRemoveImageMutation } from "../hooks/use-images-query";
import { ImagePullDialog } from "./image-pull-dialog";
import type { ImageInfo } from "../types";
import { ImagePullDialog } from "./image-pull-dialog";
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
@ -67,6 +76,24 @@ function getImageDisplayName(image: ImageInfo): string {
export function ImagesTable() {
const { data, isLoading, error, refetch, isRefetching } = useImagesQuery();
const removeImageMutation = useRemoveImageMutation();
const { data: scannedImages } = useScannedImages();
const { data: sbomedImages } = useSBOMedImages();
const scannedSet = useMemo(() => {
const set = new Set<string>();
for (const img of scannedImages ?? []) {
set.add(`${img.image_ref}::${img.host}`);
}
return set;
}, [scannedImages]);
const sbomedSet = useMemo(() => {
const set = new Set<string>();
for (const img of sbomedImages ?? []) {
set.add(`${img.image_ref}::${img.host}`);
}
return set;
}, [sbomedImages]);
const [searchText, setSearchText] = useState("");
const [isPullDialogOpen, setIsPullDialogOpen] = useState(false);
@ -75,21 +102,23 @@ export function ImagesTable() {
image: ImageInfo;
host: string;
} | null>(null);
const [scanImage, setScanImage] = useState<ImageInfo | null>(null);
const [sbomImage, setSbomImage] = useState<ImageInfo | null>(null);
const [activeSBOMJobIds, setActiveSBOMJobIds] = useState<string[]>([]);
const [bulkScanOpen, setBulkScanOpen] = useState(false);
// Images already come as flat array with host field
const allImages = useMemo(() => {
if (!data?.images) return [];
return data.images;
}, [data?.images]);
useObservedSBOMJobs(activeSBOMJobIds, (jobId) => {
setActiveSBOMJobIds((prev) => prev.filter((activeJobId) => activeJobId !== jobId));
});
const allImages = useMemo(() => data?.images ?? [], [data?.images]);
// Get unique hosts for pull dialog
const hosts = useMemo(() => {
if (!data?.images) return [];
const uniqueHosts = new Set(data.images.map((img) => img.host));
return Array.from(uniqueHosts);
}, [data?.images]);
// Filter images by search
const filteredImages = useMemo(() => {
if (!searchText) return allImages;
const search = searchText.toLowerCase();
@ -149,7 +178,7 @@ export function ImagesTable() {
<>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center justify-between gap-2">
<CardTitle>Docker Images</CardTitle>
<div className="flex items-center gap-2">
<Tooltip>
@ -168,10 +197,16 @@ export function ImagesTable() {
<TooltipContent>Refresh</TooltipContent>
</Tooltip>
{!data?.readOnly && (
<Button variant="default" size="sm" onClick={openPullDialog}>
<DownloadIcon className="mr-2 size-4" />
Pull Image
</Button>
<>
<Button variant="outline" size="sm" onClick={() => setBulkScanOpen(true)}>
<ShieldCheckIcon className="mr-2 size-4" />
Scan All
</Button>
<Button variant="default" size="sm" onClick={openPullDialog}>
<DownloadIcon className="mr-2 size-4" />
Pull Image
</Button>
</>
)}
</div>
</div>
@ -198,9 +233,7 @@ export function ImagesTable() {
<TableHead>Host</TableHead>
<TableHead>Size</TableHead>
<TableHead>Created</TableHead>
{!data?.readOnly && (
<TableHead className="w-[80px]">Actions</TableHead>
)}
{!data?.readOnly && <TableHead className="w-[180px]">Actions</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
@ -213,7 +246,7 @@ export function ImagesTable() {
</span>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs text-muted-foreground font-mono cursor-help">
<span className="cursor-help font-mono text-xs text-muted-foreground">
{image.id.replace("sha256:", "").slice(0, 12)}
</span>
</TooltipTrigger>
@ -230,23 +263,75 @@ export function ImagesTable() {
<TableCell>{formatDate(image.created)}</TableCell>
{!data?.readOnly && (
<TableCell>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={() =>
setImageToDelete({
image,
host: image.host,
})
}
>
<Trash2Icon className="size-4 text-destructive" />
</Button>
</TooltipTrigger>
<TooltipContent>Remove image</TooltipContent>
</Tooltip>
<div className="flex items-center gap-1">
{scannedSet.has(`${getImageDisplayName(image)}::${image.host}`) ? (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon-sm" asChild>
<Link to="/scan-history">
<ShieldCheckIcon className="size-4 text-green-500" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>View scan results</TooltipContent>
</Tooltip>
) : (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setScanImage(image)}
>
<ShieldAlertIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Scan image</TooltipContent>
</Tooltip>
)}
{sbomedSet.has(`${getImageDisplayName(image)}::${image.host}`) ? (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon-sm" asChild>
<Link to="/sbom-history">
<FileCheck2Icon className="size-4 text-green-500" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>View SBOM</TooltipContent>
</Tooltip>
) : (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setSbomImage(image)}
>
<FileTextIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Generate SBOM</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={() =>
setImageToDelete({
image,
host: image.host,
})
}
>
<Trash2Icon className="size-4 text-destructive" />
</Button>
</TooltipTrigger>
<TooltipContent>Remove image</TooltipContent>
</Tooltip>
</div>
</TableCell>
)}
</TableRow>
@ -265,6 +350,35 @@ export function ImagesTable() {
onSelectedHostsChange={setSelectedHosts}
/>
<BulkScanDialog isOpen={bulkScanOpen} onOpenChange={setBulkScanOpen} />
{scanImage && (
<ScanDialog
isOpen={!!scanImage}
onOpenChange={(open) => {
if (!open) setScanImage(null);
}}
imageRef={getImageDisplayName(scanImage)}
host={scanImage.host}
/>
)}
{sbomImage && (
<SBOMDialog
isOpen={!!sbomImage}
onOpenChange={(open) => {
if (!open) setSbomImage(null);
}}
imageRef={getImageDisplayName(sbomImage)}
host={sbomImage.host}
onJobCreated={(jobId) => {
setActiveSBOMJobIds((prev) => (
prev.includes(jobId) ? prev : [...prev, jobId]
));
}}
/>
)}
<AlertDialog
open={!!imageToDelete}
onOpenChange={(open) => !open && setImageToDelete(null)}

View file

@ -0,0 +1,157 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { downloadSBOM, generateSBOM, getSBOMDownloadURL, getSBOMJob } from "./generate-sbom";
// Mock the authenticatedFetch module so tests don't make real HTTP requests.
vi.mock("@/lib/api-client", () => ({
authenticatedFetch: vi.fn(),
}));
// Re-import after mock registration to get the mocked version.
import { authenticatedFetch } from "@/lib/api-client";
const mockFetch = authenticatedFetch as ReturnType<typeof vi.fn>;
function makeResponse(status: number, body: unknown): Response {
return {
ok: status >= 200 && status < 300,
status,
json: () => Promise.resolve(body),
text: () => Promise.resolve(typeof body === "string" ? body : JSON.stringify(body)),
blob: () => Promise.resolve(new Blob([JSON.stringify(body)])),
} as unknown as Response;
}
const sampleJob = {
id: "sbom-123",
image_ref: "nginx:latest",
host: "local",
format: "spdx-json",
status: "pending",
created_at: 1700000000,
};
describe("getSBOMDownloadURL", () => {
it("constructs the correct download URL", () => {
const url = getSBOMDownloadURL("abc-456");
expect(url).toContain("/api/v1/scan/sbom/abc-456");
expect(url).toContain("download=true");
});
it("includes the job ID in the path", () => {
const url = getSBOMDownloadURL("unique-job-id");
expect(url).toMatch(/unique-job-id/);
});
});
describe("generateSBOM", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("posts to the SBOM endpoint and returns the job", async () => {
mockFetch.mockResolvedValueOnce(makeResponse(202, { job: sampleJob }));
const result = await generateSBOM({
imageRef: "nginx:latest",
host: "local",
format: "spdx-json",
});
expect(result).toEqual(sampleJob);
expect(mockFetch).toHaveBeenCalledOnce();
const [url, opts] = mockFetch.mock.calls[0];
expect(url).toContain("/api/v1/scan/sbom");
expect(opts?.method).toBe("POST");
});
it("throws on non-ok response with server message", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
text: () => Promise.resolve("internal error"),
} as unknown as Response);
await expect(
generateSBOM({ imageRef: "nginx:latest", host: "local" })
).rejects.toThrow("internal error");
});
it("throws with status code when body is empty on error", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 503,
text: () => Promise.resolve(""),
} as unknown as Response);
await expect(
generateSBOM({ imageRef: "nginx:latest", host: "local" })
).rejects.toThrow("503");
});
it("sends format in request body", async () => {
mockFetch.mockResolvedValueOnce(makeResponse(202, { job: sampleJob }));
await generateSBOM({ imageRef: "img:tag", host: "local", format: "cyclonedx-json" });
const [, opts] = mockFetch.mock.calls[0];
const body = JSON.parse(opts?.body as string);
expect(body.format).toBe("cyclonedx-json");
});
});
describe("getSBOMJob", () => {
afterEach(() => vi.clearAllMocks());
it("fetches the job by ID and returns it", async () => {
const completedJob = { ...sampleJob, status: "complete" };
mockFetch.mockResolvedValueOnce(makeResponse(200, { job: completedJob }));
const result = await getSBOMJob("sbom-123");
expect(result.status).toBe("complete");
expect(mockFetch).toHaveBeenCalledOnce();
const [url] = mockFetch.mock.calls[0];
expect(url).toContain("sbom-123");
});
it("throws on non-ok response", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
text: () => Promise.resolve("not found"),
} as unknown as Response);
await expect(getSBOMJob("missing-id")).rejects.toThrow("not found");
});
});
describe("downloadSBOM", () => {
afterEach(() => vi.clearAllMocks());
it("fetches the download URL and returns a Blob", async () => {
const blob = new Blob(["{}"], { type: "application/json" });
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
blob: () => Promise.resolve(blob),
} as unknown as Response);
const result = await downloadSBOM("sbom-123");
expect(result).toBeInstanceOf(Blob);
expect(mockFetch).toHaveBeenCalledOnce();
const [url] = mockFetch.mock.calls[0];
expect(url).toContain("download=true");
});
it("throws on non-ok response", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
text: () => Promise.resolve("not found"),
} as unknown as Response);
await expect(downloadSBOM("bad-id")).rejects.toThrow("not found");
});
});

View file

@ -0,0 +1,71 @@
import { authenticatedFetch } from "@/lib/api-client";
import { API_BASE_URL } from "@/types/api";
import type { SBOMFormat, SBOMJob, SBOMRescanBlockedResponse } from "../types";
const SBOM_ENDPOINT = `${API_BASE_URL}/api/v1/scan/sbom`;
export interface GenerateSBOMParams {
imageRef: string;
host: string;
format?: SBOMFormat;
force?: boolean;
}
export class SBOMRegenBlockedError extends Error {
readonly data: SBOMRescanBlockedResponse;
constructor(data: SBOMRescanBlockedResponse) {
super(data.message);
this.name = "SBOMRegenBlockedError";
this.data = data;
}
}
export async function generateSBOM(params: GenerateSBOMParams): Promise<SBOMJob> {
const response = await authenticatedFetch(SBOM_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
});
if (response.status === 409) {
const data = await response.json();
throw new SBOMRegenBlockedError(data as SBOMRescanBlockedResponse);
}
if (!response.ok) {
const message = await response.text();
throw new Error(message || `Request failed with status ${response.status}`);
}
const data = await response.json();
return data.job as SBOMJob;
}
export async function getSBOMJob(id: string): Promise<SBOMJob> {
const response = await authenticatedFetch(`${SBOM_ENDPOINT}/${id}`);
if (!response.ok) {
const message = await response.text();
throw new Error(message || `Request failed with status ${response.status}`);
}
const data = await response.json();
return data.job as SBOMJob;
}
export function getSBOMDownloadURL(id: string): string {
return `${SBOM_ENDPOINT}/${id}?download=true`;
}
export async function downloadSBOM(id: string): Promise<Blob> {
const response = await authenticatedFetch(getSBOMDownloadURL(id));
if (!response.ok) {
const message = await response.text();
throw new Error(message || `Request failed with status ${response.status}`);
}
return response.blob();
}

View file

@ -0,0 +1,47 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { downloadSBOMHistoryFile } from "./get-sbom-history";
vi.mock("@/lib/api-client", () => ({
authenticatedFetch: vi.fn(),
}));
import { authenticatedFetch } from "@/lib/api-client";
const mockFetch = authenticatedFetch as ReturnType<typeof vi.fn>;
describe("downloadSBOMHistoryFile", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("uses the server-provided filename from Content-Disposition", async () => {
const blob = new Blob(["{}"], { type: "application/json" });
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers({
"Content-Disposition": 'attachment; filename="scan-result.json"',
}),
blob: () => Promise.resolve(blob),
} as unknown as Response);
const result = await downloadSBOMHistoryFile("sbom-123");
expect(result.blob).toBe(blob);
expect(result.filename).toBe("scan-result.json");
});
it("falls back to the default filename when the header is missing", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers(),
blob: () => Promise.resolve(new Blob(["{}"], { type: "application/json" })),
} as unknown as Response);
const result = await downloadSBOMHistoryFile("sbom-123");
expect(result.filename).toBe("sbom-sbom-123.json");
});
});

View file

@ -0,0 +1,102 @@
import { authenticatedFetch } from "@/lib/api-client";
import { API_BASE_URL } from "@/types/api";
import type {
SBOMHistoryPage,
SBOMHistoryQueryParams,
SBOMResult,
SBOMedImage,
} from "../types";
const SBOM_HISTORY_ENDPOINT = `${API_BASE_URL}/api/v1/scan/sbom/history`;
function parseContentDispositionFilename(contentDisposition: string | null): string | null {
if (!contentDisposition) return null;
const encodedMatch = contentDisposition.match(/filename\*\s*=\s*UTF-8''([^;]+)/i);
if (encodedMatch?.[1]) {
return decodeURIComponent(encodedMatch[1].trim().replace(/^"|"$/g, ""));
}
const filenameMatch = contentDisposition.match(/filename\s*=\s*"([^"]+)"|filename\s*=\s*([^;]+)/i);
const filename = filenameMatch?.[1] ?? filenameMatch?.[2];
return filename?.trim().replace(/^"|"$/g, "") || null;
}
export async function getSBOMHistory(params: SBOMHistoryQueryParams): Promise<SBOMHistoryPage> {
const searchParams = new URLSearchParams();
if (params.image) searchParams.set("image", params.image);
if (params.host) searchParams.set("host", params.host);
if (params.format) searchParams.set("format", params.format);
if (params.start_date) searchParams.set("start_date", String(params.start_date));
if (params.end_date) searchParams.set("end_date", String(params.end_date));
if (params.page) searchParams.set("page", String(params.page));
if (params.page_size) searchParams.set("page_size", String(params.page_size));
if (params.sort_by) searchParams.set("sort_by", params.sort_by);
if (params.sort_dir) searchParams.set("sort_dir", params.sort_dir);
const response = await authenticatedFetch(
`${SBOM_HISTORY_ENDPOINT}?${searchParams.toString()}`
);
if (!response.ok) {
const message = await response.text();
throw new Error(message || `Request failed with status ${response.status}`);
}
return response.json();
}
export async function getSBOMHistoryDetail(id: string): Promise<SBOMResult> {
const response = await authenticatedFetch(`${SBOM_HISTORY_ENDPOINT}/${id}`);
if (!response.ok) {
const message = await response.text();
throw new Error(message || `Request failed with status ${response.status}`);
}
const data = await response.json();
return data.result as SBOMResult;
}
export async function getSBOMedImages(): Promise<SBOMedImage[]> {
const response = await authenticatedFetch(`${SBOM_HISTORY_ENDPOINT}/images`);
if (!response.ok) {
const message = await response.text();
throw new Error(message || `Request failed with status ${response.status}`);
}
const data = await response.json();
return data.images as SBOMedImage[];
}
export async function deleteSBOMHistory(id: string): Promise<void> {
const response = await authenticatedFetch(`${SBOM_HISTORY_ENDPOINT}/${id}`, {
method: "DELETE",
});
if (!response.ok) {
const message = await response.text();
throw new Error(message || `Request failed with status ${response.status}`);
}
}
export async function downloadSBOMHistoryFile(
id: string
): Promise<{ blob: Blob; filename: string }> {
const response = await authenticatedFetch(`${SBOM_HISTORY_ENDPOINT}/${id}/download`);
if (!response.ok) {
const message = await response.text();
throw new Error(message || `Request failed with status ${response.status}`);
}
const blob = await response.blob();
const filename =
parseContentDispositionFilename(response.headers.get("Content-Disposition")) ||
`sbom-${id}.json`;
return { blob, filename };
}

View file

@ -0,0 +1,99 @@
import { authenticatedFetch } from "@/lib/api-client";
import { API_BASE_URL } from "@/types/api";
import type { HistoryPage, HistoryQueryParams, ScannedImage, ScanResult, AutoScanStatus } from "../types";
const HISTORY_ENDPOINT = `${API_BASE_URL}/api/v1/scan/history`;
const AUTOSCAN_ENDPOINT = `${API_BASE_URL}/api/v1/scan/autoscan/status`;
export async function getScanHistory(params: HistoryQueryParams): Promise<HistoryPage> {
const searchParams = new URLSearchParams();
if (params.image) searchParams.set("image", params.image);
if (params.host) searchParams.set("host", params.host);
if (params.min_severity) searchParams.set("min_severity", params.min_severity);
if (params.start_date) searchParams.set("start_date", String(params.start_date));
if (params.end_date) searchParams.set("end_date", String(params.end_date));
if (params.page) searchParams.set("page", String(params.page));
if (params.page_size) searchParams.set("page_size", String(params.page_size));
if (params.sort_by) searchParams.set("sort_by", params.sort_by);
if (params.sort_dir) searchParams.set("sort_dir", params.sort_dir);
const response = await authenticatedFetch(
`${HISTORY_ENDPOINT}?${searchParams.toString()}`
);
if (!response.ok) {
const message = await response.text();
throw new Error(message || `Request failed with status ${response.status}`);
}
return response.json();
}
export async function getScanHistoryDetail(id: string): Promise<ScanResult> {
const response = await authenticatedFetch(`${HISTORY_ENDPOINT}/${id}`);
if (!response.ok) {
const message = await response.text();
throw new Error(message || `Request failed with status ${response.status}`);
}
const data = await response.json();
return data.result as ScanResult;
}
export async function getScannedImages(): Promise<ScannedImage[]> {
const response = await authenticatedFetch(`${HISTORY_ENDPOINT}/images`);
if (!response.ok) {
const message = await response.text();
throw new Error(message || `Request failed with status ${response.status}`);
}
const data = await response.json();
return data.images as ScannedImage[];
}
export async function getAutoScanStatus(): Promise<AutoScanStatus> {
const response = await authenticatedFetch(AUTOSCAN_ENDPOINT);
if (!response.ok) {
const message = await response.text();
throw new Error(message || `Request failed with status ${response.status}`);
}
return response.json();
}
export async function deleteScanHistory(id: string): Promise<void> {
const response = await authenticatedFetch(`${HISTORY_ENDPOINT}/${id}`, {
method: "DELETE",
});
if (!response.ok) {
const message = await response.text();
throw new Error(message || `Request failed with status ${response.status}`);
}
}
export async function exportScanHistory(id: string): Promise<void> {
const response = await authenticatedFetch(`${HISTORY_ENDPOINT}/${id}/export`, {
method: "GET",
});
if (!response.ok) {
const message = await response.text();
throw new Error(message || `Request failed with status ${response.status}`);
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `scan_${id}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
}

View file

@ -0,0 +1,123 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cancelScanJob, getScanJob, getScanJobs } from "./get-scan-jobs";
vi.mock("@/lib/api-client", () => ({
authenticatedFetch: vi.fn(),
}));
import { authenticatedFetch } from "@/lib/api-client";
const mockFetch = authenticatedFetch as ReturnType<typeof vi.fn>;
function okResponse(body: unknown): Response {
return {
ok: true,
status: 200,
json: () => Promise.resolve(body),
text: () => Promise.resolve(JSON.stringify(body)),
} as unknown as Response;
}
function errResponse(status: number, msg: string): Response {
return {
ok: false,
status,
text: () => Promise.resolve(msg),
} as unknown as Response;
}
const sampleJob = {
id: "job-1",
image_ref: "nginx:latest",
host: "local",
scanner: "grype",
status: "complete",
created_at: 1700000000,
};
const sampleBulkJob = {
id: "bulk-1",
jobs: [sampleJob],
total_images: 1,
completed: 1,
failed: 0,
status: "complete",
created_at: 1700000000,
};
describe("getScanJobs", () => {
afterEach(() => vi.clearAllMocks());
it("returns jobs and bulkJobs arrays", async () => {
mockFetch.mockResolvedValueOnce(
okResponse({ jobs: [sampleJob], bulkJobs: [sampleBulkJob] })
);
const result = await getScanJobs();
expect(result.jobs).toHaveLength(1);
expect(result.bulkJobs).toHaveLength(1);
});
it("throws on server error", async () => {
mockFetch.mockResolvedValueOnce(errResponse(500, "server error"));
await expect(getScanJobs()).rejects.toThrow("server error");
});
it("throws with status when error body is empty", async () => {
mockFetch.mockResolvedValueOnce(errResponse(503, ""));
await expect(getScanJobs()).rejects.toThrow("503");
});
});
describe("getScanJob", () => {
afterEach(() => vi.clearAllMocks());
it("returns a regular job by ID", async () => {
mockFetch.mockResolvedValueOnce(okResponse({ job: sampleJob }));
const result = await getScanJob("job-1");
expect(result.job).toEqual(sampleJob);
const [url] = mockFetch.mock.calls[0];
expect(url).toContain("job-1");
});
it("returns a bulkJob when found", async () => {
mockFetch.mockResolvedValueOnce(okResponse({ bulkJob: sampleBulkJob }));
const result = await getScanJob("bulk-1");
expect(result.bulkJob).toEqual(sampleBulkJob);
});
it("throws on 404", async () => {
mockFetch.mockResolvedValueOnce(errResponse(404, "not found"));
await expect(getScanJob("ghost")).rejects.toThrow("not found");
});
});
describe("cancelScanJob", () => {
afterEach(() => vi.clearAllMocks());
it("sends DELETE request for the given job ID", async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 } as Response);
await cancelScanJob("job-1");
expect(mockFetch).toHaveBeenCalledOnce();
const [url, opts] = mockFetch.mock.calls[0];
expect(url).toContain("job-1");
expect(opts?.method).toBe("DELETE");
});
it("throws on non-ok response", async () => {
mockFetch.mockResolvedValueOnce(errResponse(404, "job not found"));
await expect(cancelScanJob("ghost")).rejects.toThrow("job not found");
});
});

View file

@ -0,0 +1,44 @@
import { authenticatedFetch } from "@/lib/api-client";
import { API_BASE_URL } from "@/types/api";
import type { BulkScanJob, ScanJob } from "../types";
const SCAN_JOBS_ENDPOINT = `${API_BASE_URL}/api/v1/scan/jobs`;
export interface GetScanJobsResponse {
jobs: ScanJob[];
bulkJobs: BulkScanJob[];
}
export async function getScanJobs(): Promise<GetScanJobsResponse> {
const response = await authenticatedFetch(SCAN_JOBS_ENDPOINT);
if (!response.ok) {
const message = await response.text();
throw new Error(message || `Request failed with status ${response.status}`);
}
return response.json();
}
export async function getScanJob(id: string): Promise<{ job?: ScanJob; bulkJob?: BulkScanJob }> {
const response = await authenticatedFetch(`${SCAN_JOBS_ENDPOINT}/${id}`);
if (!response.ok) {
const message = await response.text();
throw new Error(message || `Request failed with status ${response.status}`);
}
return response.json();
}
export async function cancelScanJob(id: string): Promise<void> {
const response = await authenticatedFetch(`${SCAN_JOBS_ENDPOINT}/${id}`, {
method: "DELETE",
});
if (!response.ok) {
const message = await response.text();
throw new Error(message || `Request failed with status ${response.status}`);
}
}

View file

@ -0,0 +1,115 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { getLatestScanResult, getScanResults } from "./get-scan-results";
vi.mock("@/lib/api-client", () => ({
authenticatedFetch: vi.fn(),
}));
import { authenticatedFetch } from "@/lib/api-client";
const mockFetch = authenticatedFetch as ReturnType<typeof vi.fn>;
function okResponse(body: unknown): Response {
return {
ok: true,
status: 200,
json: () => Promise.resolve(body),
text: () => Promise.resolve(JSON.stringify(body)),
} as unknown as Response;
}
function errResponse(status: number, msg: string): Response {
return {
ok: false,
status,
text: () => Promise.resolve(msg),
} as unknown as Response;
}
const sampleResult = {
id: "result-1",
image_ref: "nginx:latest",
host: "local",
scanner: "grype",
vulnerabilities: [],
summary: { critical: 0, high: 2, medium: 0, low: 0, negligible: 0, unknown: 0, total: 2 },
started_at: 1700000000,
completed_at: 1700000100,
duration_ms: 100000,
};
describe("getScanResults", () => {
afterEach(() => vi.clearAllMocks());
it("returns an array of scan results", async () => {
mockFetch.mockResolvedValueOnce(okResponse({ results: [sampleResult] }));
const results = await getScanResults("nginx:latest", "local");
expect(results).toHaveLength(1);
expect(results[0]).toEqual(sampleResult);
});
it("URL-encodes imageRef in the request path", async () => {
mockFetch.mockResolvedValueOnce(okResponse({ results: [] }));
await getScanResults("my-registry.example.com:5000/myapp:v1.0", "local");
const [url] = mockFetch.mock.calls[0];
// The image ref with colon and slash must be encoded in the URL
expect(url).toContain(encodeURIComponent("my-registry.example.com:5000/myapp:v1.0"));
});
it("includes host as a query parameter", async () => {
mockFetch.mockResolvedValueOnce(okResponse({ results: [] }));
await getScanResults("nginx:latest", "my-remote-host");
const [url] = mockFetch.mock.calls[0];
expect(url).toContain("host=my-remote-host");
});
it("throws on server error", async () => {
mockFetch.mockResolvedValueOnce(errResponse(500, "server error"));
await expect(getScanResults("nginx:latest", "local")).rejects.toThrow("server error");
});
});
describe("getLatestScanResult", () => {
afterEach(() => vi.clearAllMocks());
it("returns the latest result", async () => {
mockFetch.mockResolvedValueOnce(okResponse({ result: sampleResult }));
const result = await getLatestScanResult("nginx:latest", "local");
expect(result).toEqual(sampleResult);
const [url] = mockFetch.mock.calls[0];
expect(url).toContain("/latest");
});
it("returns null on 404", async () => {
mockFetch.mockResolvedValueOnce({ ok: false, status: 404 } as Response);
const result = await getLatestScanResult("missing:image", "local");
expect(result).toBeNull();
});
it("throws on non-404 errors", async () => {
mockFetch.mockResolvedValueOnce(errResponse(500, "internal error"));
await expect(getLatestScanResult("nginx:latest", "local")).rejects.toThrow("internal error");
});
it("encodes host in query string", async () => {
mockFetch.mockResolvedValueOnce(okResponse({ result: sampleResult }));
await getLatestScanResult("nginx:latest", "host with spaces");
const [url] = mockFetch.mock.calls[0];
expect(url).toContain(encodeURIComponent("host with spaces"));
});
});

View file

@ -0,0 +1,40 @@
import { authenticatedFetch } from "@/lib/api-client";
import { API_BASE_URL } from "@/types/api";
import type { ScanResult } from "../types";
const SCAN_RESULTS_ENDPOINT = `${API_BASE_URL}/api/v1/scan/results`;
export async function getScanResults(imageRef: string, host: string): Promise<ScanResult[]> {
const encoded = encodeURIComponent(imageRef);
const response = await authenticatedFetch(
`${SCAN_RESULTS_ENDPOINT}?image=${encoded}&host=${encodeURIComponent(host)}`
);
if (!response.ok) {
const message = await response.text();
throw new Error(message || `Request failed with status ${response.status}`);
}
const data = await response.json();
return data.results as ScanResult[];
}
export async function getLatestScanResult(imageRef: string, host: string): Promise<ScanResult | null> {
const encoded = encodeURIComponent(imageRef);
const response = await authenticatedFetch(
`${SCAN_RESULTS_ENDPOINT}/latest?image=${encoded}&host=${encodeURIComponent(host)}`
);
if (response.status === 404) {
return null;
}
if (!response.ok) {
const message = await response.text();
throw new Error(message || `Request failed with status ${response.status}`);
}
const data = await response.json();
return data.result as ScanResult;
}

View file

@ -0,0 +1,183 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ScannerConfig } from "../types";
import { getScannerConfig, testScanNotification, updateScannerConfig } from "./scanner-config";
vi.mock("@/lib/api-client", () => ({
authenticatedFetch: vi.fn(),
}));
import { authenticatedFetch } from "@/lib/api-client";
const mockFetch = authenticatedFetch as ReturnType<typeof vi.fn>;
function okResponse(body: unknown): Response {
return {
ok: true,
status: 200,
json: () => Promise.resolve(body),
text: () => Promise.resolve(JSON.stringify(body)),
} as unknown as Response;
}
function errResponse(status: number, msg: string): Response {
return {
ok: false,
status,
text: () => Promise.resolve(msg),
} as unknown as Response;
}
const sampleConfig: ScannerConfig = {
grypeImage: "anchore/grype:v0.110.0",
trivyImage: "aquasec/trivy:0.69.3",
syftImage: "anchore/syft:v1.27.1",
defaultScanner: "grype",
grypeArgs: "",
trivyArgs: "",
notifications: {
onScanComplete: true,
onBulkComplete: true,
onNewCVEs: true,
minSeverity: "High",
},
autoScan: { enabled: false, pollIntervalMinutes: 15 },
forceRescan: false,
scanTimeoutMinutes: 20,
bulkTimeoutMinutes: 120,
scannerMemoryMB: 2048,
scannerPidsLimit: 512,
};
describe("getScannerConfig", () => {
afterEach(() => vi.clearAllMocks());
it("returns the scanner config", async () => {
mockFetch.mockResolvedValueOnce(okResponse({ config: sampleConfig }));
const result = await getScannerConfig();
expect(result).toEqual(sampleConfig);
expect(mockFetch).toHaveBeenCalledOnce();
const [url] = mockFetch.mock.calls[0];
expect(url).toContain("/api/v1/settings/scan");
});
it("throws on server error", async () => {
mockFetch.mockResolvedValueOnce(errResponse(500, "error"));
await expect(getScannerConfig()).rejects.toThrow("error");
});
});
describe("updateScannerConfig", () => {
afterEach(() => vi.clearAllMocks());
it("sends a PUT request with the config and returns updated config", async () => {
const updatedConfig: ScannerConfig = { ...sampleConfig, defaultScanner: "trivy" };
mockFetch.mockResolvedValueOnce(okResponse({ config: updatedConfig }));
const result = await updateScannerConfig(updatedConfig);
expect(result.defaultScanner).toBe("trivy");
expect(mockFetch).toHaveBeenCalledOnce();
const [url, opts] = mockFetch.mock.calls[0];
expect(url).toContain("/api/v1/settings/scan");
expect(opts?.method).toBe("PUT");
const body = JSON.parse(opts?.body as string);
expect(body.defaultScanner).toBe("trivy");
});
it("throws on server error", async () => {
mockFetch.mockResolvedValueOnce(errResponse(400, "invalid config"));
await expect(updateScannerConfig(sampleConfig)).rejects.toThrow("invalid config");
});
});
describe("updateScannerConfig resource limit fields", () => {
afterEach(() => vi.clearAllMocks());
it("includes scanTimeoutMinutes in the PUT body", async () => {
const cfg = { ...sampleConfig, scanTimeoutMinutes: 45 };
mockFetch.mockResolvedValueOnce(okResponse({ config: cfg }));
await updateScannerConfig(cfg);
const [, opts] = mockFetch.mock.calls[0];
const body = JSON.parse(opts?.body as string);
expect(body.scanTimeoutMinutes).toBe(45);
});
it("includes bulkTimeoutMinutes in the PUT body", async () => {
const cfg = { ...sampleConfig, bulkTimeoutMinutes: 240 };
mockFetch.mockResolvedValueOnce(okResponse({ config: cfg }));
await updateScannerConfig(cfg);
const [, opts] = mockFetch.mock.calls[0];
const body = JSON.parse(opts?.body as string);
expect(body.bulkTimeoutMinutes).toBe(240);
});
it("includes scannerMemoryMB in the PUT body", async () => {
const cfg = { ...sampleConfig, scannerMemoryMB: 4096 };
mockFetch.mockResolvedValueOnce(okResponse({ config: cfg }));
await updateScannerConfig(cfg);
const [, opts] = mockFetch.mock.calls[0];
const body = JSON.parse(opts?.body as string);
expect(body.scannerMemoryMB).toBe(4096);
});
it("includes scannerPidsLimit in the PUT body", async () => {
const cfg = { ...sampleConfig, scannerPidsLimit: 1024 };
mockFetch.mockResolvedValueOnce(okResponse({ config: cfg }));
await updateScannerConfig(cfg);
const [, opts] = mockFetch.mock.calls[0];
const body = JSON.parse(opts?.body as string);
expect(body.scannerPidsLimit).toBe(1024);
});
it("returns the resource limit values echoed back by the server", async () => {
const cfg = {
...sampleConfig,
scanTimeoutMinutes: 30,
bulkTimeoutMinutes: 90,
scannerMemoryMB: 1024,
scannerPidsLimit: 256,
};
mockFetch.mockResolvedValueOnce(okResponse({ config: cfg }));
const result = await updateScannerConfig(cfg);
expect(result.scanTimeoutMinutes).toBe(30);
expect(result.bulkTimeoutMinutes).toBe(90);
expect(result.scannerMemoryMB).toBe(1024);
expect(result.scannerPidsLimit).toBe(256);
});
});
describe("testScanNotification", () => {
afterEach(() => vi.clearAllMocks());
it("sends a POST request to the test-notification endpoint", async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 } as Response);
await testScanNotification();
expect(mockFetch).toHaveBeenCalledOnce();
const [url, opts] = mockFetch.mock.calls[0];
expect(url).toContain("test-notification");
expect(opts?.method).toBe("POST");
});
it("throws on non-ok response", async () => {
mockFetch.mockResolvedValueOnce(errResponse(500, "notification failed"));
await expect(testScanNotification()).rejects.toThrow("notification failed");
});
});

View file

@ -0,0 +1,45 @@
import { authenticatedFetch } from "@/lib/api-client";
import { API_BASE_URL } from "@/types/api";
import type { ScannerConfig } from "../types";
const SCANNER_CONFIG_ENDPOINT = `${API_BASE_URL}/api/v1/settings/scan`;
export async function getScannerConfig(): Promise<ScannerConfig> {
const response = await authenticatedFetch(SCANNER_CONFIG_ENDPOINT);
if (!response.ok) {
const message = await response.text();
throw new Error(message || `Request failed with status ${response.status}`);
}
const data = await response.json();
return data.config as ScannerConfig;
}
export async function updateScannerConfig(config: ScannerConfig): Promise<ScannerConfig> {
const response = await authenticatedFetch(SCANNER_CONFIG_ENDPOINT, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(config),
});
if (!response.ok) {
const message = await response.text();
throw new Error(message || `Request failed with status ${response.status}`);
}
const data = await response.json();
return data.config as ScannerConfig;
}
export async function testScanNotification(): Promise<void> {
const response = await authenticatedFetch(`${SCANNER_CONFIG_ENDPOINT}/test-notification`, {
method: "POST",
});
if (!response.ok) {
const message = await response.text();
throw new Error(message || `Request failed with status ${response.status}`);
}
}

View file

@ -0,0 +1,76 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { startBulkScan } from "./start-bulk-scan";
vi.mock("@/lib/api-client", () => ({
authenticatedFetch: vi.fn(),
}));
import { authenticatedFetch } from "@/lib/api-client";
const mockFetch = authenticatedFetch as ReturnType<typeof vi.fn>;
const sampleBulkJob = {
id: "bulk-job-1",
jobs: [],
total_images: 0,
completed: 0,
failed: 0,
status: "pending",
created_at: 1700000000,
};
describe("startBulkScan", () => {
afterEach(() => vi.clearAllMocks());
it("posts to the bulk scan endpoint and returns the bulk job", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 202,
json: () => Promise.resolve({ job: sampleBulkJob }),
} as unknown as Response);
const result = await startBulkScan({ scanner: "grype" });
expect(result).toEqual(sampleBulkJob);
expect(mockFetch).toHaveBeenCalledOnce();
const [url, opts] = mockFetch.mock.calls[0];
expect(url).toContain("/api/v1/scan/bulk");
expect(opts?.method).toBe("POST");
});
it("serializes hosts filter in the request body", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 202,
json: () => Promise.resolve({ job: sampleBulkJob }),
} as unknown as Response);
await startBulkScan({ scanner: "trivy", hosts: ["host-a", "host-b"] });
const [, opts] = mockFetch.mock.calls[0];
const body = JSON.parse(opts?.body as string);
expect(body.hosts).toEqual(["host-a", "host-b"]);
expect(body.scanner).toBe("trivy");
});
it("throws on server error", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
text: () => Promise.resolve("bulk scan failed"),
} as unknown as Response);
await expect(startBulkScan({})).rejects.toThrow("bulk scan failed");
});
it("throws with status fallback when error body is empty", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
text: () => Promise.resolve(""),
} as unknown as Response);
await expect(startBulkScan({ scanner: "grype" })).rejects.toThrow("500");
});
});

View file

@ -0,0 +1,27 @@
import { authenticatedFetch } from "@/lib/api-client";
import { API_BASE_URL } from "@/types/api";
import type { BulkScanJob, ScannerType } from "../types";
const BULK_SCAN_ENDPOINT = `${API_BASE_URL}/api/v1/scan/bulk`;
export interface StartBulkScanParams {
scanner?: ScannerType;
hosts?: string[];
}
export async function startBulkScan(params: StartBulkScanParams): Promise<BulkScanJob> {
const response = await authenticatedFetch(BULK_SCAN_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
});
if (!response.ok) {
const message = await response.text();
throw new Error(message || `Request failed with status ${response.status}`);
}
const data = await response.json();
return data.job as BulkScanJob;
}

View file

@ -0,0 +1,84 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { startScan } from "./start-scan";
vi.mock("@/lib/api-client", () => ({
authenticatedFetch: vi.fn(),
}));
import { authenticatedFetch } from "@/lib/api-client";
const mockFetch = authenticatedFetch as ReturnType<typeof vi.fn>;
const sampleJob = {
id: "scan-job-1",
image_ref: "nginx:latest",
host: "local",
scanner: "grype",
status: "pending",
created_at: 1700000000,
};
describe("startScan", () => {
afterEach(() => vi.clearAllMocks());
it("posts to the scan endpoint and returns the job", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 202,
json: () => Promise.resolve({ job: sampleJob }),
} as unknown as Response);
const result = await startScan({
imageRef: "nginx:latest",
host: "local",
scanner: "grype",
});
expect(result).toEqual(sampleJob);
expect(mockFetch).toHaveBeenCalledOnce();
const [url, opts] = mockFetch.mock.calls[0];
expect(url).toContain("/api/v1/scan");
expect(opts?.method).toBe("POST");
});
it("serializes params correctly in the request body", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 202,
json: () => Promise.resolve({ job: sampleJob }),
} as unknown as Response);
await startScan({ imageRef: "redis:7", host: "remote-host", scanner: "trivy" });
const [, opts] = mockFetch.mock.calls[0];
const body = JSON.parse(opts?.body as string);
expect(body.imageRef).toBe("redis:7");
expect(body.host).toBe("remote-host");
expect(body.scanner).toBe("trivy");
});
it("throws on server error", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
text: () => Promise.resolve("scan failed"),
} as unknown as Response);
await expect(
startScan({ imageRef: "nginx:latest", host: "local" })
).rejects.toThrow("scan failed");
});
it("throws with status fallback when error body is empty", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 503,
text: () => Promise.resolve(""),
} as unknown as Response);
await expect(
startScan({ imageRef: "nginx:latest", host: "local" })
).rejects.toThrow("503");
});
});

View file

@ -0,0 +1,44 @@
import { authenticatedFetch } from "@/lib/api-client";
import { API_BASE_URL } from "@/types/api";
import type { ScanJob, ScannerType } from "../types";
const SCAN_ENDPOINT = `${API_BASE_URL}/api/v1/scan`;
export interface StartScanParams {
imageRef: string;
host: string;
scanner?: ScannerType;
}
export class RescanBlockedError extends Error {
lastScanId?: string;
lastScanAt?: number;
constructor(data: { message: string; last_scan_id?: string; last_scan_at?: number }) {
super(data.message);
this.name = "RescanBlockedError";
this.lastScanId = data.last_scan_id;
this.lastScanAt = data.last_scan_at;
}
}
export async function startScan(params: StartScanParams): Promise<ScanJob> {
const response = await authenticatedFetch(SCAN_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
});
if (response.status === 409) {
const data = await response.json();
throw new RescanBlockedError(data);
}
if (!response.ok) {
const message = await response.text();
throw new Error(message || `Request failed with status ${response.status}`);
}
const data = await response.json();
return data.job as ScanJob;
}

View file

@ -0,0 +1,267 @@
import { useMemo, useState } from "react";
import { ShieldCheckIcon, SkipForwardIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Progress } from "@/components/ui/progress";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useCancelScan, useScanJob, useStartBulkScan } from "../hooks/use-scan-query";
import { ScanResultsExport } from "./scan-results-export";
import { ScanResultsSummary } from "./scan-results-summary";
import { ScanResultsTable } from "./scan-results-table";
import type { ScanResult, ScannerType, SeveritySummary } from "../types";
interface BulkScanDialogProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
}
export function BulkScanDialog({ isOpen, onOpenChange }: BulkScanDialogProps) {
const [scanner, setScanner] = useState<ScannerType>("grype");
const [jobId, setJobId] = useState<string | null>(null);
const [started, setStarted] = useState(false);
const [selectedResult, setSelectedResult] = useState<ScanResult | null>(null);
const startBulkScanMutation = useStartBulkScan();
const cancelScanMutation = useCancelScan();
const { data: jobData } = useScanJob(jobId, started);
const bulkJob = jobData?.bulkJob;
const isScanning = bulkJob && !["complete", "failed", "cancelled"].includes(bulkJob.status);
const isComplete = bulkJob?.status === "complete";
const progress = bulkJob
? ((bulkJob.completed + bulkJob.failed) / Math.max(bulkJob.total_images, 1)) * 100
: 0;
const handleStart = async () => {
try {
const newJob = await startBulkScanMutation.mutateAsync({ scanner });
setJobId(newJob.id);
setStarted(true);
setSelectedResult(null);
} catch {
// mutation handles errors
}
};
const handleCancel = () => {
if (jobId) {
cancelScanMutation.mutate(jobId);
}
};
const handleClose = (open: boolean) => {
if (!open) {
setJobId(null);
setStarted(false);
setSelectedResult(null);
}
onOpenChange(open);
};
const aggregateSummary = useMemo(() => {
const summary: SeveritySummary = {
critical: 0,
high: 0,
medium: 0,
low: 0,
negligible: 0,
unknown: 0,
total: 0,
};
if (bulkJob?.jobs) {
for (const job of bulkJob.jobs) {
if (job.result) {
summary.critical += job.result.summary.critical;
summary.high += job.result.summary.high;
summary.medium += job.result.summary.medium;
summary.low += job.result.summary.low;
summary.negligible += job.result.summary.negligible;
summary.unknown += job.result.summary.unknown;
summary.total += job.result.summary.total;
}
}
}
return summary;
}, [bulkJob?.jobs]);
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ShieldCheckIcon className="size-5" />
Bulk vulnerability scan
</DialogTitle>
<DialogDescription>
Scan all Docker images for known vulnerabilities.
</DialogDescription>
</DialogHeader>
{!started ? (
<div className="space-y-4">
<div className="space-y-1">
<label htmlFor="scanner-select" className="text-sm font-medium">Scanner</label>
<Select value={scanner} onValueChange={(value) => setScanner(value as ScannerType)}>
<SelectTrigger id="scanner-select" className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="grype">Grype</SelectItem>
<SelectItem value="trivy">Trivy</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => handleClose(false)}>
Cancel
</Button>
<Button onClick={handleStart} disabled={startBulkScanMutation.isPending}>
{startBulkScanMutation.isPending ? (
<>
<Spinner className="mr-2 size-4" />
Starting...
</>
) : (
"Scan All Images"
)}
</Button>
</div>
</div>
) : (
<div className="space-y-4">
{isScanning && bulkJob && (
<>
<div className="flex items-center gap-3">
<Spinner className="size-5" />
<p className="font-medium">
Scanning images... ({bulkJob.completed + bulkJob.failed}/{bulkJob.total_images})
</p>
</div>
<Progress value={progress} className="h-2" />
</>
)}
{isComplete && bulkJob && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="font-medium">
Scan complete - {bulkJob.total_images} images
</p>
<div className="flex gap-2">
<Badge variant="outline">{bulkJob.completed} succeeded</Badge>
{bulkJob.failed > 0 && (
<Badge variant="destructive">{bulkJob.failed} failed</Badge>
)}
</div>
</div>
{bulkJob.jobs.filter((j) => j.error?.includes("image_unchanged")).length > 0 && (
<div className="flex items-center gap-2 rounded-md bg-muted p-2 text-sm text-muted-foreground">
<SkipForwardIcon className="size-4 shrink-0" />
{bulkJob.jobs.filter((j) => j.error?.includes("image_unchanged")).length} images skipped (unchanged since last scan)
</div>
)}
<ScanResultsSummary summary={aggregateSummary} />
</div>
)}
{bulkJob && bulkJob.jobs.length > 0 && (
<ScrollArea className="h-[300px] rounded-md border">
<div className="p-3 space-y-2">
{bulkJob.jobs.map((job) => (
<div
key={job.id}
className="flex items-center justify-between gap-3 rounded bg-muted/50 p-2"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-mono">{job.image_ref}</p>
<p className="text-xs text-muted-foreground">{job.host}</p>
</div>
<div className="ml-3 flex items-center gap-2">
{job.status === "complete" && job.result ? (
<>
<Badge
variant={job.result.summary.total > 0 ? "destructive" : "outline"}
className={job.result.summary.total === 0 ? "border-green-500 text-green-500" : ""}
>
{job.result.summary.total} vulns
</Badge>
<Button variant="ghost" size="sm" onClick={() => setSelectedResult(job.result ?? null)}>
View
</Button>
</>
) : job.status === "failed" && job.error?.includes("image_unchanged") ? (
<Badge variant="secondary">Skipped</Badge>
) : job.status === "failed" ? (
<Badge variant="destructive">Failed</Badge>
) : job.status === "cancelled" ? (
<Badge variant="secondary">Cancelled</Badge>
) : (
<Spinner className="size-4" />
)}
</div>
</div>
))}
</div>
</ScrollArea>
)}
{selectedResult && (
<div className="space-y-4 rounded-md border p-4">
<div className="flex items-center justify-between gap-3">
<div>
<p className="font-medium">{selectedResult.image_ref}</p>
<p className="text-sm text-muted-foreground">{selectedResult.host}</p>
</div>
<ScanResultsExport result={selectedResult} />
</div>
<ScanResultsSummary summary={selectedResult.summary} />
<Tabs defaultValue="results">
<TabsList>
<TabsTrigger value="results">Results</TabsTrigger>
</TabsList>
<TabsContent value="results">
<ScanResultsTable vulnerabilities={selectedResult.vulnerabilities} />
</TabsContent>
</Tabs>
</div>
)}
<div className="flex justify-end gap-2">
{isScanning && (
<Button variant="outline" onClick={handleCancel}>
Cancel
</Button>
)}
{(isComplete || bulkJob?.status === "failed" || bulkJob?.status === "cancelled") && (
<Button variant="outline" onClick={() => handleClose(false)}>
Close
</Button>
)}
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,309 @@
import { useEffect, useState } from "react";
import { Link } from "@tanstack/react-router";
import { DownloadIcon, FileCheck2Icon, FileTextIcon, HistoryIcon } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { SBOMRegenBlockedError, downloadSBOM } from "../api/generate-sbom";
import { useGenerateSBOM, useSBOMJob } from "../hooks/use-scan-query";
import type { SBOMFormat } from "../types";
interface SBOMDialogProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
imageRef: string;
host: string;
onJobCreated?: (jobId: string) => void;
}
export function SBOMDialog({ isOpen, onOpenChange, imageRef, host, onJobCreated }: SBOMDialogProps) {
const [format, setFormat] = useState<SBOMFormat>("spdx-json");
const [jobId, setJobId] = useState<string | null>(null);
const [started, setStarted] = useState(false);
const [downloading, setDownloading] = useState(false);
const [sbomData, setSbomData] = useState<any>(null);
const [regenBlocked, setRegenBlocked] = useState(false);
const generateMutation = useGenerateSBOM();
const { data: sbomJob } = useSBOMJob(jobId, started);
const isGenerating = sbomJob && !["complete", "failed", "cancelled"].includes(sbomJob.status);
const isComplete = sbomJob?.status === "complete";
const isFailed = sbomJob?.status === "failed";
useEffect(() => {
if (isComplete && !sbomData && jobId) {
downloadSBOM(jobId)
.then((blob) => blob.text())
.then((text) => JSON.parse(text))
.then((json) => setSbomData(json))
.catch(console.error);
}
}, [isComplete, sbomData, jobId]);
const getSbomComponents = () => {
if (!sbomData) return [];
if (format === "cyclonedx-json" && sbomData.components) {
return sbomData.components.map((c: any) => ({
name: c.name,
version: c.version,
type: c.type,
purl: c.purl,
}));
}
if (format === "spdx-json" && sbomData.packages) {
return sbomData.packages.map((p: any) => {
const purlRef = p.externalRefs?.find((r: any) => r.referenceType === "purl");
return {
name: p.name,
version: p.versionInfo,
type: "package",
purl: purlRef ? purlRef.referenceLocator : "",
};
});
}
return [];
};
const handleGenerate = async () => {
try {
const job = await generateMutation.mutateAsync({ imageRef, host, format });
setJobId(job.id);
setStarted(true);
onJobCreated?.(job.id);
} catch (error) {
if (error instanceof SBOMRegenBlockedError) {
setRegenBlocked(true);
return;
}
toast.error(error instanceof Error ? error.message : "Failed to generate SBOM");
}
};
const handleDownload = async () => {
if (!jobId) return;
try {
setDownloading(true);
const blob = await downloadSBOM(jobId);
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `sbom-${imageRef.replace(/[/:]/g, "_")}.json`;
link.click();
URL.revokeObjectURL(url);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to download SBOM");
} finally {
setDownloading(false);
}
};
const handleClose = (open: boolean) => {
if (!open) {
setJobId(null);
setStarted(false);
setDownloading(false);
setSbomData(null);
setRegenBlocked(false);
}
onOpenChange(open);
};
const components = getSbomComponents();
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className={isComplete ? "max-w-4xl max-h-[85vh] overflow-y-auto" : "max-w-md"}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileTextIcon className="size-5" />
Generate SBOM
</DialogTitle>
<DialogDescription>
Generate a Software Bill of Materials for{" "}
<Badge variant="outline" className="font-mono">
{imageRef}
</Badge>
</DialogDescription>
</DialogHeader>
{regenBlocked ? (
<div className="space-y-4">
<div className="flex items-center gap-3 rounded-md bg-muted p-4">
<FileCheck2Icon className="size-5 shrink-0 text-green-500" />
<div>
<p className="font-medium">Already generated</p>
<p className="text-sm text-muted-foreground">
This image hasn't changed since the last SBOM was generated.
</p>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => handleClose(false)}>
Close
</Button>
<Button asChild>
<Link to="/sbom-history">
<HistoryIcon className="mr-2 size-4" />
View SBOM History
</Link>
</Button>
</div>
</div>
) : !started ? (
<div className="space-y-4">
<div className="space-y-1">
<label htmlFor="sbom-format" className="text-sm font-medium">Format</label>
<Select value={format} onValueChange={(value) => setFormat(value as SBOMFormat)}>
<SelectTrigger id="sbom-format">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="spdx-json">SPDX (JSON)</SelectItem>
<SelectItem value="cyclonedx-json">CycloneDX (JSON)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => handleClose(false)}>
Cancel
</Button>
<Button onClick={handleGenerate} disabled={generateMutation.isPending}>
{generateMutation.isPending ? (
<>
<Spinner className="mr-2 size-4" />
Starting...
</>
) : (
"Generate"
)}
</Button>
</div>
</div>
) : isGenerating ? (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Spinner className="size-5" />
<div>
<p className="font-medium">Generating SBOM...</p>
<p className="text-sm text-muted-foreground">
This may take a minute for large images.
</p>
</div>
</div>
</div>
) : isComplete ? (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-green-700 dark:text-green-400">SBOM Details</p>
<p className="text-sm text-muted-foreground">
Format: {format === "spdx-json" ? "SPDX" : "CycloneDX"} JSON &bull; {components.length} components found
</p>
</div>
<Button onClick={handleDownload} disabled={downloading} size="sm">
<DownloadIcon className="mr-2 size-4" />
{downloading ? "Downloading..." : "Export"}
</Button>
</div>
<div className="border rounded-md overflow-hidden">
<div className="max-h-[50vh] overflow-y-auto">
<Table>
<TableHeader className="bg-muted/50 sticky top-0">
<TableRow>
<TableHead>Package</TableHead>
<TableHead>Version</TableHead>
<TableHead>Type</TableHead>
<TableHead>PURL</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{!sbomData ? (
<TableRow>
<TableCell colSpan={4} className="text-center py-8">
<Spinner className="size-5 mx-auto" />
</TableCell>
</TableRow>
) : components.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
No components found.
</TableCell>
</TableRow>
) : (
components.map((c: any, i: number) => (
<TableRow key={i}>
<TableCell className="font-medium">{c.name}</TableCell>
<TableCell>{c.version}</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{c.type || "unknown"}
</Badge>
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground break-all">
{c.purl}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => handleClose(false)}>
Close
</Button>
</div>
</div>
) : isFailed ? (
<div className="space-y-4">
<div className="rounded-md bg-destructive/10 p-4">
<p className="font-medium text-destructive">SBOM generation failed</p>
<p className="text-sm text-muted-foreground mt-1">
{sbomJob?.error || "An unknown error occurred"}
</p>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => handleClose(false)}>
Close
</Button>
<Button onClick={() => { setStarted(false); setJobId(null); }}>
Retry
</Button>
</div>
</div>
) : null}
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,94 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SBOMHistoryPage } from "./sbom-history-page";
const mockUseSBOMHistory = vi.fn();
const mockUseSBOMHistoryDetail = vi.fn();
const mockUseSBOMedImages = vi.fn();
const mockUseDeleteSBOMHistory = vi.fn();
const mockDownloadSBOMHistoryFile = vi.fn();
vi.mock("../hooks/use-scan-query", () => ({
useSBOMHistory: (...args: unknown[]) => mockUseSBOMHistory(...args),
useSBOMHistoryDetail: (...args: unknown[]) => mockUseSBOMHistoryDetail(...args),
useSBOMedImages: (...args: unknown[]) => mockUseSBOMedImages(...args),
useDeleteSBOMHistory: (...args: unknown[]) => mockUseDeleteSBOMHistory(...args),
}));
vi.mock("../api/get-sbom-history", () => ({
downloadSBOMHistoryFile: (...args: unknown[]) => mockDownloadSBOMHistoryFile(...args),
}));
const historyPage = {
results: [
{
id: "sbom-1",
image_ref: "alpine:3.18",
host: "local",
format: "spdx-json",
component_count: 5,
file_size: 512,
started_at: 100,
completed_at: 200,
duration_ms: 5000,
components: [],
},
],
total: 1,
page: 1,
page_size: 20,
total_pages: 1,
};
const detailResult = {
...historyPage.results[0],
components: [
{
name: "busybox",
version: "1.0.0",
type: "package",
purl: "pkg:apk/alpine/busybox@1.0.0",
},
],
};
describe("SBOMHistoryPage", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseSBOMHistory.mockReturnValue({ data: historyPage, isLoading: false });
mockUseSBOMHistoryDetail.mockImplementation((id: string | null) => ({
data: id ? detailResult : null,
isLoading: false,
}));
mockUseSBOMedImages.mockReturnValue({
data: [{ image_ref: "alpine:3.18", host: "local", sbom_count: 1, last_sbom_at: 200 }],
});
mockUseDeleteSBOMHistory.mockReturnValue({ isPending: false, mutate: vi.fn() });
mockDownloadSBOMHistoryFile.mockResolvedValue({
blob: new Blob(["{}"], { type: "application/json" }),
filename: "sbom-1.json",
});
});
it("renders accessible sort controls with aria-sort state", () => {
render(<SBOMHistoryPage />);
expect(screen.getByRole("columnheader", { name: /Date/i })).toHaveAttribute("aria-sort", "descending");
expect(screen.getByRole("columnheader", { name: /Components/i })).toHaveAttribute("aria-sort", "none");
fireEvent.click(screen.getByRole("button", { name: /Components/i }));
expect(screen.getByRole("columnheader", { name: /Components/i })).toHaveAttribute("aria-sort", "ascending");
expect(screen.getByRole("columnheader", { name: /Date/i })).toHaveAttribute("aria-sort", "none");
});
it("opens the SBOM details dialog from the row button", () => {
render(<SBOMHistoryPage />);
fireEvent.click(screen.getByRole("button", { name: "alpine:3.18" }));
expect(screen.getByText("busybox")).toBeInTheDocument();
expect(screen.getByText(/SBOM:/)).toBeInTheDocument();
});
});

View file

@ -0,0 +1,407 @@
import type { Dispatch, SetStateAction } from "react";
import { useState } from "react";
import { format } from "date-fns";
import {
ChevronLeftIcon,
ChevronRightIcon,
DownloadIcon,
FileTextIcon,
SearchIcon,
Trash2Icon,
XIcon,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { downloadSBOMHistoryFile } from "../api/get-sbom-history";
import {
useDeleteSBOMHistory,
useSBOMHistory,
useSBOMHistoryDetail,
useSBOMedImages,
} from "../hooks/use-scan-query";
import type { SBOMComponent, SBOMFormat, SBOMHistoryQueryParams } from "../types";
type FormatFilter = SBOMFormat | "";
function downloadBlob(blob: Blob, filename: string) {
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
}
function toggleSort(
setParams: Dispatch<SetStateAction<SBOMHistoryQueryParams>>,
sortBy: NonNullable<SBOMHistoryQueryParams["sort_by"]>
) {
setParams((prev) => ({
...prev,
sort_by: sortBy,
sort_dir: prev.sort_dir === "desc" ? "asc" : "desc",
}));
}
function getAriaSort(
params: SBOMHistoryQueryParams,
sortBy: NonNullable<SBOMHistoryQueryParams["sort_by"]>
): "none" | "ascending" | "descending" {
if (params.sort_by !== sortBy) return "none";
return params.sort_dir === "asc" ? "ascending" : "descending";
}
function ComponentsTable({ components }: { components: SBOMComponent[] }) {
return (
<div className="border rounded-md overflow-hidden">
<div className="max-h-[50vh] overflow-y-auto">
<Table>
<TableHeader className="bg-muted/50 sticky top-0">
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Version</TableHead>
<TableHead>Type</TableHead>
<TableHead>PURL</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{components.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
No components found.
</TableCell>
</TableRow>
) : (
components.map((component, index) => (
<TableRow key={`${component.name}-${component.version}-${index}`}>
<TableCell className="font-medium">{component.name}</TableCell>
<TableCell>{component.version || "-"}</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{component.type || "unknown"}
</Badge>
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground break-all">
{component.purl || "-"}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}
export function SBOMHistoryPage() {
const [params, setParams] = useState<SBOMHistoryQueryParams>({
page: 1,
page_size: 20,
sort_by: "completed_at",
sort_dir: "desc",
});
const [imageFilter, setImageFilter] = useState("");
const [hostFilter, setHostFilter] = useState("");
const [formatFilter, setFormatFilter] = useState<FormatFilter>("");
const [selectedSBOMId, setSelectedSBOMId] = useState<string | null>(null);
const { data: historyData, isLoading } = useSBOMHistory({
...params,
image: imageFilter || undefined,
host: hostFilter || undefined,
format: formatFilter || undefined,
});
const { data: sbomedImages } = useSBOMedImages();
const { data: detailResult, isLoading: isDetailLoading } = useSBOMHistoryDetail(selectedSBOMId);
const deleteMutation = useDeleteSBOMHistory();
const uniqueHosts = Array.from(new Set(sbomedImages?.map((img) => img.host) ?? []));
const hasFilters = imageFilter || hostFilter || formatFilter;
const clearFilters = () => {
setImageFilter("");
setHostFilter("");
setFormatFilter("");
setParams((prev) => ({ ...prev, page: 1 }));
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FileTextIcon className="size-6" />
<h1 className="text-2xl font-bold">SBOM History</h1>
</div>
{historyData && (
<p className="text-sm text-muted-foreground">
{historyData.total} total SBOMs
</p>
)}
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px] max-w-[300px]">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder="Filter by image..."
value={imageFilter}
onChange={(event) => {
setImageFilter(event.target.value);
setParams((prev) => ({ ...prev, page: 1 }));
}}
className="pl-9"
/>
</div>
<Select
value={hostFilter || "all"}
onValueChange={(value) => {
setHostFilter(value === "all" ? "" : value);
setParams((prev) => ({ ...prev, page: 1 }));
}}
>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="All hosts" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All hosts</SelectItem>
{uniqueHosts.map((host) => (
<SelectItem key={host} value={host}>
{host}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={formatFilter || "all"}
onValueChange={(value) => {
setFormatFilter(value === "all" ? "" : (value as SBOMFormat));
setParams((prev) => ({ ...prev, page: 1 }));
}}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="All formats" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All formats</SelectItem>
<SelectItem value="spdx-json">SPDX JSON</SelectItem>
<SelectItem value="cyclonedx-json">CycloneDX JSON</SelectItem>
</SelectContent>
</Select>
{hasFilters && (
<Button variant="ghost" size="sm" onClick={clearFilters}>
<XIcon className="size-4 mr-1" />
Clear
</Button>
)}
</div>
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead>Image</TableHead>
<TableHead>Host</TableHead>
<TableHead>Format</TableHead>
<TableHead aria-sort={getAriaSort(params, "component_count")}>
<button
type="button"
className="inline-flex items-center gap-1 font-medium"
onClick={() => toggleSort(setParams, "component_count")}
>
Components
{params.sort_by === "component_count" && (params.sort_dir === "desc" ? "\u2193" : "\u2191")}
</button>
</TableHead>
<TableHead aria-sort={getAriaSort(params, "completed_at")}>
<button
type="button"
className="inline-flex items-center gap-1 font-medium"
onClick={() => toggleSort(setParams, "completed_at")}
>
Date
{params.sort_by === "completed_at" && (params.sort_dir === "desc" ? "\u2193" : "\u2191")}
</button>
</TableHead>
<TableHead>Duration</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
Loading SBOM history...
</TableCell>
</TableRow>
) : !historyData?.results.length ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
No SBOM history found
</TableCell>
</TableRow>
) : (
historyData.results.map((result) => (
<TableRow key={result.id}>
<TableCell className="font-mono text-sm max-w-[250px] truncate">
<button
type="button"
className="max-w-full truncate text-left underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
onClick={() => setSelectedSBOMId(result.id)}
>
{result.image_ref}
</button>
</TableCell>
<TableCell>
<Badge variant="outline">{result.host}</Badge>
</TableCell>
<TableCell className="capitalize">{result.format}</TableCell>
<TableCell>{result.component_count}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{format(new Date(result.completed_at * 1000), "MMM d, yyyy HH:mm")}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{(result.duration_ms / 1000).toFixed(1)}s
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="icon"
title="Download SBOM JSON"
onClick={() => {
downloadSBOMHistoryFile(result.id)
.then(({ blob, filename }) => downloadBlob(blob, filename))
.catch((error) => {
console.error("Failed to download SBOM:", error);
});
}}
>
<DownloadIcon className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
title="Delete"
disabled={deleteMutation.isPending}
onClick={(event) => {
event.stopPropagation();
if (confirm("Are you sure you want to delete this SBOM result?")) {
deleteMutation.mutate(result.id);
}
}}
>
<Trash2Icon className="size-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{historyData && historyData.total_pages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Page {historyData.page} of {historyData.total_pages}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={historyData.page <= 1}
onClick={() => setParams((prev) => ({ ...prev, page: (prev.page ?? 1) - 1 }))}
>
<ChevronLeftIcon className="size-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={historyData.page >= historyData.total_pages}
onClick={() => setParams((prev) => ({ ...prev, page: (prev.page ?? 1) + 1 }))}
>
Next
<ChevronRightIcon className="size-4" />
</Button>
</div>
</div>
)}
<Dialog open={!!selectedSBOMId} onOpenChange={(open) => !open && setSelectedSBOMId(null)}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{detailResult ? (
<span className="flex items-center gap-2">
SBOM: <code className="text-sm">{detailResult.image_ref}</code>
<Badge variant="outline">{detailResult.host}</Badge>
</span>
) : (
"SBOM Details"
)}
</DialogTitle>
</DialogHeader>
{isDetailLoading ? (
<div className="py-8 text-center text-muted-foreground">Loading SBOM details...</div>
) : detailResult ? (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Format:</span>{" "}
<span className="capitalize">{detailResult.format}</span>
</div>
<div>
<span className="text-muted-foreground">Duration:</span>{" "}
{(detailResult.duration_ms / 1000).toFixed(1)}s
</div>
<div>
<span className="text-muted-foreground">Completed:</span>{" "}
{format(new Date(detailResult.completed_at * 1000), "MMM d, yyyy HH:mm:ss")}
</div>
<div>
<span className="text-muted-foreground">Components:</span>{" "}
{detailResult.component_count}
</div>
</div>
<ComponentsTable components={detailResult.components ?? []} />
</div>
) : null}
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -0,0 +1,241 @@
import { useState } from "react";
import { Link } from "@tanstack/react-router";
import { HistoryIcon, ShieldAlertIcon, ShieldCheckIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useStartScan, useScanJob, useCancelScan } from "../hooks/use-scan-query";
import { RescanBlockedError } from "../api/start-scan";
import { ScanResultsSummary } from "./scan-results-summary";
import { ScanResultsTable } from "./scan-results-table";
import { ScanResultsExport } from "./scan-results-export";
import type { ScannerType } from "../types";
interface ScanDialogProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
imageRef: string;
host: string;
}
export function ScanDialog({ isOpen, onOpenChange, imageRef, host }: ScanDialogProps) {
const [scanner, setScanner] = useState<ScannerType>("grype");
const [jobId, setJobId] = useState<string | null>(null);
const [started, setStarted] = useState(false);
const [rescanBlocked, setRescanBlocked] = useState(false);
const startScanMutation = useStartScan();
const cancelScanMutation = useCancelScan();
const { data: jobData } = useScanJob(jobId, started);
const job = jobData?.job;
const isScanning = job && !["complete", "failed", "cancelled"].includes(job.status);
const isComplete = job?.status === "complete";
const isFailed = job?.status === "failed" || job?.status === "cancelled";
const handleStartScan = async () => {
try {
const newJob = await startScanMutation.mutateAsync({ imageRef, host, scanner });
setJobId(newJob.id);
setStarted(true);
} catch (err) {
if (err instanceof RescanBlockedError) {
setRescanBlocked(true);
}
}
};
const handleCancel = () => {
if (jobId) {
cancelScanMutation.mutate(jobId);
}
};
const handleClose = (open: boolean) => {
if (!open) {
setJobId(null);
setStarted(false);
setRescanBlocked(false);
}
onOpenChange(open);
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-4xl max-h-[85vh] overflow-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ShieldAlertIcon className="size-5" />
Vulnerability scan
<Badge variant="outline" className="font-mono">
{imageRef}
</Badge>
</DialogTitle>
<DialogDescription>
Scan this image for known vulnerabilities using {scanner === "grype" ? "Grype" : "Trivy"}.
</DialogDescription>
</DialogHeader>
{rescanBlocked ? (
<div className="space-y-4">
<div className="flex items-center gap-3 rounded-md bg-muted p-4">
<ShieldCheckIcon className="size-5 text-green-500 shrink-0" />
<div>
<p className="font-medium">Already scanned</p>
<p className="text-sm text-muted-foreground">
This image hasn't changed since the last scan. Pull a new version to rescan, or view existing results.
</p>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => handleClose(false)}>
Close
</Button>
<Button asChild>
<Link to="/scan-history">
<HistoryIcon className="mr-2 size-4" />
View Scan History
</Link>
</Button>
</div>
</div>
) : !started ? (
<div className="space-y-4">
<div className="flex items-center gap-4">
<div className="space-y-1">
<label htmlFor="scanner-select" className="text-sm font-medium">Scanner</label>
<Select value={scanner} onValueChange={(v) => setScanner(v as ScannerType)}>
<SelectTrigger id="scanner-select" className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="grype">Grype</SelectItem>
<SelectItem value="trivy">Trivy</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => handleClose(false)}>
Cancel
</Button>
<Button onClick={handleStartScan} disabled={startScanMutation.isPending}>
{startScanMutation.isPending ? (
<>
<Spinner className="mr-2 size-4" />
Starting...
</>
) : (
"Start Scan"
)}
</Button>
</div>
</div>
) : isScanning ? (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Spinner className="size-5" />
<div>
<p className="font-medium">Scanning for vulnerabilities...</p>
<p className="text-sm text-muted-foreground">
{job?.progress || `Status: ${job?.status}`}
</p>
</div>
</div>
<div className="rounded-md bg-muted p-3">
<p className="text-sm font-mono text-muted-foreground">
{job?.progress || "Initializing scanner..."}
</p>
</div>
<div className="flex justify-end">
<Button variant="outline" onClick={handleCancel}>
Cancel Scan
</Button>
</div>
</div>
) : isFailed ? (
<div className="space-y-4">
<div className="rounded-md bg-destructive/10 p-4">
<p className="font-medium text-destructive">Scan failed</p>
<p className="text-sm text-muted-foreground mt-1">
{job?.error || "An unknown error occurred"}
</p>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => handleClose(false)}>
Close
</Button>
<Button onClick={() => { setStarted(false); setJobId(null); }}>
Retry
</Button>
</div>
</div>
) : isComplete && job?.result ? (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2">
{job.result.summary.total > 0 ? (
<Badge variant="destructive">
{job.result.summary.total} vulnerabilities
</Badge>
) : (
<Badge variant="outline" className="border-green-500 text-green-500">
No vulnerabilities
</Badge>
)}
<span className="text-sm text-muted-foreground">
{(job.result.duration_ms / 1000).toFixed(1)}s
</span>
</div>
<ScanResultsSummary summary={job.result.summary} />
</div>
<ScanResultsExport result={job.result} />
</div>
<Tabs defaultValue="results">
<TabsList>
<TabsTrigger value="results">
Scan results
{job.result.summary.total > 0 && (
<Badge variant="destructive" className="ml-2">
{job.result.summary.total}
</Badge>
)}
</TabsTrigger>
</TabsList>
<TabsContent value="results">
<ScanResultsTable vulnerabilities={job.result.vulnerabilities} />
</TabsContent>
</Tabs>
<div className="flex justify-end">
<Button variant="outline" onClick={() => handleClose(false)}>
Close
</Button>
</div>
</div>
) : null}
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,101 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ScanHistoryPage } from "./scan-history-page";
const mockUseScanHistory = vi.fn();
const mockUseScanHistoryDetail = vi.fn();
const mockUseScannedImages = vi.fn();
const mockUseDeleteScanHistory = vi.fn();
const mockExportScanHistory = vi.fn();
vi.mock("../hooks/use-scan-query", () => ({
useScanHistory: (...args: unknown[]) => mockUseScanHistory(...args),
useScanHistoryDetail: (...args: unknown[]) => mockUseScanHistoryDetail(...args),
useScannedImages: (...args: unknown[]) => mockUseScannedImages(...args),
useDeleteScanHistory: (...args: unknown[]) => mockUseDeleteScanHistory(...args),
}));
vi.mock("../api/get-scan-history", () => ({
exportScanHistory: (...args: unknown[]) => mockExportScanHistory(...args),
}));
vi.mock("./scan-results-summary", () => ({
ScanResultsSummary: () => <div>summary</div>,
}));
vi.mock("./scan-results-table", () => ({
ScanResultsTable: () => <div>table</div>,
}));
const historyPage = {
results: [
{
id: "scan-1",
image_ref: "redis:7",
host: "local",
scanner: "grype",
summary: {
critical: 0,
high: 1,
medium: 0,
low: 0,
negligible: 0,
unknown: 0,
total: 1,
},
vulnerabilities: [],
started_at: 100,
completed_at: 200,
duration_ms: 5000,
},
],
total: 1,
page: 1,
page_size: 20,
total_pages: 1,
};
describe("ScanHistoryPage", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseScanHistory.mockReturnValue({ data: historyPage, isLoading: false });
mockUseScanHistoryDetail.mockImplementation((id: string | null) => ({
data: id
? {
...historyPage.results[0],
vulnerabilities: [
{
id: "CVE-2024-0001",
severity: "High",
package: "redis",
installed_version: "1.0.0",
},
],
}
: null,
isLoading: false,
}));
mockUseScannedImages.mockReturnValue({
data: [{ image_ref: "redis:7", host: "local", scan_count: 1, last_scanned: 200 }],
});
mockUseDeleteScanHistory.mockReturnValue({ isPending: false, mutate: vi.fn() });
mockExportScanHistory.mockResolvedValue(undefined);
});
it("renders the date sort as an explicit button with aria-sort", () => {
render(<ScanHistoryPage />);
expect(screen.getByRole("columnheader", { name: /Date/i })).toHaveAttribute("aria-sort", "descending");
expect(screen.getByRole("button", { name: /Date/i })).toBeInTheDocument();
});
it("opens the scan details dialog from the row button", () => {
render(<ScanHistoryPage />);
fireEvent.click(screen.getByRole("button", { name: "redis:7" }));
expect(screen.getByText(/Scan:/)).toBeInTheDocument();
expect(screen.getByText("table")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,348 @@
import type { Dispatch, SetStateAction } from "react";
import { useState } from "react";
import { format } from "date-fns";
import {
ChevronLeftIcon,
ChevronRightIcon,
DownloadIcon,
Trash2Icon,
HistoryIcon,
SearchIcon,
XIcon,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useScanHistory, useScanHistoryDetail, useScannedImages, useDeleteScanHistory } from "../hooks/use-scan-query";
import { exportScanHistory } from "../api/get-scan-history";
import { ScanResultsSummary } from "./scan-results-summary";
import { ScanResultsTable } from "./scan-results-table";
import type { HistoryQueryParams } from "../types";
export const SEVERITY_OPTIONS = ["Critical", "High", "Medium", "Low", "Negligible", "Unknown"] as const;
export type SeverityOption = typeof SEVERITY_OPTIONS[number] | "all" | "";
function toggleSort(
setParams: Dispatch<SetStateAction<HistoryQueryParams>>,
sortBy: NonNullable<HistoryQueryParams["sort_by"]>
) {
setParams((prev) => ({
...prev,
sort_by: sortBy,
sort_dir: prev.sort_dir === "desc" ? "asc" : "desc",
}));
}
function getAriaSort(
params: HistoryQueryParams,
sortBy: NonNullable<HistoryQueryParams["sort_by"]>
): "none" | "ascending" | "descending" {
if (params.sort_by !== sortBy) return "none";
return params.sort_dir === "asc" ? "ascending" : "descending";
}
export function ScanHistoryPage() {
const [params, setParams] = useState<HistoryQueryParams>({
page: 1,
page_size: 20,
sort_by: "completed_at",
sort_dir: "desc",
});
const [imageFilter, setImageFilter] = useState("");
const [hostFilter, setHostFilter] = useState<string>("");
const [severityFilter, setSeverityFilter] = useState<SeverityOption>("");
const [selectedScanId, setSelectedScanId] = useState<string | null>(null);
const { data: historyData, isLoading } = useScanHistory({
...params,
image: imageFilter || undefined,
host: hostFilter || undefined,
min_severity: (severityFilter === "all" || severityFilter === "") ? undefined : severityFilter,
});
const { data: scannedImages } = useScannedImages();
const { data: detailResult, isLoading: isDetailLoading } = useScanHistoryDetail(selectedScanId);
const deleteMutation = useDeleteScanHistory();
const uniqueHosts = Array.from(
new Set(scannedImages?.map((img) => img.host) ?? [])
);
const clearFilters = () => {
setImageFilter("");
setHostFilter("");
setSeverityFilter("");
setParams((prev) => ({ ...prev, page: 1 }));
};
const hasFilters = imageFilter || hostFilter || severityFilter;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<HistoryIcon className="size-6" />
<h1 className="text-2xl font-bold">Scan History</h1>
</div>
{historyData && (
<p className="text-sm text-muted-foreground">
{historyData.total} total scans
</p>
)}
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px] max-w-[300px]">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder="Filter by image..."
value={imageFilter}
onChange={(e) => {
setImageFilter(e.target.value);
setParams((prev) => ({ ...prev, page: 1 }));
}}
className="pl-9"
/>
</div>
<Select value={hostFilter} onValueChange={(v) => {
setHostFilter(v === "all" ? "" : v);
setParams((prev) => ({ ...prev, page: 1 }));
}}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="All hosts" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All hosts</SelectItem>
{uniqueHosts.map((host) => (
<SelectItem key={host} value={host}>
{host}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={severityFilter} onValueChange={(v: SeverityOption) => {
setSeverityFilter(v === "all" ? "" : v);
setParams((prev) => ({ ...prev, page: 1 }));
}}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="Any severity" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Any severity</SelectItem>
<SelectItem value="Critical">Critical</SelectItem>
<SelectItem value="High">High+</SelectItem>
<SelectItem value="Medium">Medium+</SelectItem>
<SelectItem value="Low">Low+</SelectItem>
</SelectContent>
</Select>
{hasFilters && (
<Button variant="ghost" size="sm" onClick={clearFilters}>
<XIcon className="size-4 mr-1" />
Clear
</Button>
)}
</div>
{/* Results Table */}
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead>Image</TableHead>
<TableHead>Host</TableHead>
<TableHead>Scanner</TableHead>
<TableHead>Vulnerabilities</TableHead>
<TableHead aria-sort={getAriaSort(params, "completed_at")}>
<button
type="button"
className="inline-flex items-center gap-1 font-medium"
onClick={() => toggleSort(setParams, "completed_at")}
>
Date
{params.sort_by === "completed_at" && (params.sort_dir === "desc" ? "\u2193" : "\u2191")}
</button>
</TableHead>
<TableHead>Duration</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
Loading scan history...
</TableCell>
</TableRow>
) : !historyData?.results.length ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
No scan history found
</TableCell>
</TableRow>
) : (
historyData.results.map((result) => (
<TableRow key={result.id}>
<TableCell className="font-mono text-sm max-w-[250px] truncate">
<button
type="button"
className="max-w-full truncate text-left underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
onClick={() => setSelectedScanId(result.id)}
>
{result.image_ref}
</button>
</TableCell>
<TableCell>
<Badge variant="outline">{result.host}</Badge>
</TableCell>
<TableCell className="capitalize">{result.scanner}</TableCell>
<TableCell>
<ScanResultsSummary summary={result.summary} />
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{format(new Date(result.completed_at * 1000), "MMM d, yyyy HH:mm")}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{(result.duration_ms / 1000).toFixed(1)}s
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="icon"
title="Export CSV"
onClick={() => {
exportScanHistory(result.id).catch((err) => {
console.error("Failed to export:", err);
});
}}
>
<DownloadIcon className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
title="Delete"
disabled={deleteMutation.isPending}
onClick={() => {
if (confirm("Are you sure you want to delete this scan result?")) {
deleteMutation.mutate(result.id);
}
}}
>
<Trash2Icon className="size-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{historyData && historyData.total_pages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Page {historyData.page} of {historyData.total_pages}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={historyData.page <= 1}
onClick={() => setParams((prev) => ({ ...prev, page: (prev.page ?? 1) - 1 }))}
>
<ChevronLeftIcon className="size-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={historyData.page >= historyData.total_pages}
onClick={() => setParams((prev) => ({ ...prev, page: (prev.page ?? 1) + 1 }))}
>
Next
<ChevronRightIcon className="size-4" />
</Button>
</div>
</div>
)}
{/* Detail Dialog */}
<Dialog open={!!selectedScanId} onOpenChange={(open) => !open && setSelectedScanId(null)}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{detailResult ? (
<span className="flex items-center gap-2">
Scan: <code className="text-sm">{detailResult.image_ref}</code>
<Badge variant="outline">{detailResult.host}</Badge>
</span>
) : (
"Scan Details"
)}
</DialogTitle>
</DialogHeader>
{isDetailLoading ? (
<div className="py-8 text-center text-muted-foreground">Loading scan details...</div>
) : detailResult ? (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Scanner:</span>{" "}
<span className="capitalize">{detailResult.scanner}</span>
</div>
<div>
<span className="text-muted-foreground">Duration:</span>{" "}
{(detailResult.duration_ms / 1000).toFixed(1)}s
</div>
<div>
<span className="text-muted-foreground">Completed:</span>{" "}
{format(new Date(detailResult.completed_at * 1000), "MMM d, yyyy HH:mm:ss")}
</div>
<div>
<span className="text-muted-foreground">Total vulnerabilities:</span>{" "}
{detailResult.summary.total}
</div>
</div>
<ScanResultsSummary summary={detailResult.summary} />
{detailResult.vulnerabilities && detailResult.vulnerabilities.length > 0 && (
<ScanResultsTable vulnerabilities={detailResult.vulnerabilities} />
)}
</div>
) : null}
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -0,0 +1,124 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ScanResult } from "../types";
import { ScanResultsExport } from "./scan-results-export";
const createObjectURL = vi.fn(() => "blob:test");
const revokeObjectURL = vi.fn();
Object.defineProperty(URL, "createObjectURL", { value: createObjectURL, writable: true });
Object.defineProperty(URL, "revokeObjectURL", { value: revokeObjectURL, writable: true });
const sampleResult: ScanResult = {
id: "result-1",
image_ref: "nginx:latest",
host: "local",
scanner: "grype",
vulnerabilities: [
{
id: "CVE-2023-0001",
severity: "High",
package: "openssl",
installed_version: "1.1.1t",
fixed_version: "1.1.1u",
},
{
id: "CVE-2023-0002",
severity: "Medium",
package: "curl",
installed_version: "7.88.0",
},
],
summary: { critical: 0, high: 1, medium: 1, low: 0, negligible: 0, unknown: 0, total: 2 },
started_at: 1700000000,
completed_at: 1700000100,
duration_ms: 100000,
};
function openMenu() {
fireEvent.pointerDown(screen.getByRole("button", { name: /Export/i }), {
button: 0,
ctrlKey: false,
});
}
describe("ScanResultsExport", () => {
let anchorClickSpy: ReturnType<typeof vi.spyOn>;
let anchorElement: HTMLAnchorElement;
let originalCreateElement: typeof document.createElement;
beforeEach(() => {
originalCreateElement = document.createElement.bind(document);
anchorElement = originalCreateElement("a");
anchorClickSpy = vi.spyOn(anchorElement, "click").mockImplementation(() => {});
vi.spyOn(document, "createElement").mockImplementation((tagName: string, options?: ElementCreationOptions) => {
if (tagName === "a") {
return anchorElement;
}
return originalCreateElement(tagName, options);
});
});
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
});
it("renders the Export button", () => {
render(<ScanResultsExport result={sampleResult} />);
expect(screen.getByRole("button", { name: /Export/i })).toBeInTheDocument();
});
it("opens dropdown with format options when the trigger is clicked", () => {
render(<ScanResultsExport result={sampleResult} />);
openMenu();
expect(screen.getByText("Markdown report (.md)")).toBeInTheDocument();
expect(screen.getByText("CSV spreadsheet (.csv)")).toBeInTheDocument();
expect(screen.getByText("JSON data (.json)")).toBeInTheDocument();
});
it("triggers a download with .json extension when JSON is selected", () => {
render(<ScanResultsExport result={sampleResult} />);
openMenu();
fireEvent.click(screen.getByText("JSON data (.json)"));
expect(createObjectURL).toHaveBeenCalled();
expect(anchorClickSpy).toHaveBeenCalled();
expect(anchorElement.download).toMatch(/\.json$/);
});
it("triggers a download with .csv extension when CSV is selected", () => {
render(<ScanResultsExport result={sampleResult} />);
openMenu();
fireEvent.click(screen.getByText("CSV spreadsheet (.csv)"));
expect(createObjectURL).toHaveBeenCalled();
expect(anchorElement.download).toMatch(/\.csv$/);
});
it("triggers a download with .md extension when Markdown is selected", () => {
render(<ScanResultsExport result={sampleResult} />);
openMenu();
fireEvent.click(screen.getByText("Markdown report (.md)"));
expect(createObjectURL).toHaveBeenCalled();
expect(anchorElement.download).toMatch(/\.md$/);
});
it("uses image_ref in the filename with special chars replaced", () => {
render(<ScanResultsExport result={sampleResult} />);
openMenu();
fireEvent.click(screen.getByText("JSON data (.json)"));
expect(anchorElement.download).toMatch(/nginx_latest/);
});
it("revokes the object URL after triggering the download", () => {
render(<ScanResultsExport result={sampleResult} />);
openMenu();
fireEvent.click(screen.getByText("JSON data (.json)"));
expect(revokeObjectURL).toHaveBeenCalledWith("blob:test");
});
});

View file

@ -0,0 +1,116 @@
import { DownloadIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { ScanResult } from "../types";
interface ScanResultsExportProps {
result: ScanResult;
}
export function ScanResultsExport({ result }: ScanResultsExportProps) {
const downloadFile = (content: string, filename: string, mimeType: string) => {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
};
const exportJSON = () => {
downloadFile(
JSON.stringify(result, null, 2),
`scan-${result.image_ref.replace(/[/:]/g, "_")}.json`,
"application/json"
);
};
const exportCSV = () => {
const headers = ["CVE ID", "Severity", "Package", "Installed Version", "Fixed Version"];
const rows = result.vulnerabilities.map((v) => [
v.id,
v.severity,
v.package,
v.installed_version,
v.fixed_version || "",
]);
const csv = [headers, ...rows].map((row) => row.map((cell) => {
let str = String(cell ?? "");
if (/^[=+\-@\t]/.test(str)) {
str = "'" + str;
}
return `"${str.replace(/"/g, '""')}"`;
}).join(",")).join("\n");
downloadFile(csv, `scan-${result.image_ref.replace(/[/:]/g, "_")}.csv`, "text/csv");
};
const exportMarkdown = () => {
const lines = [
`# Vulnerability Scan Report`,
``,
`**Image:** ${result.image_ref}`,
`**Host:** ${result.host}`,
`**Scanner:** ${result.scanner}`,
`**Duration:** ${(result.duration_ms / 1000).toFixed(1)}s`,
`**Date:** ${new Date(result.completed_at * 1000).toLocaleString()}`,
``,
`## Summary`,
``,
`| Severity | Count |`,
`|----------|-------|`,
`| Critical | ${result.summary.critical} |`,
`| High | ${result.summary.high} |`,
`| Medium | ${result.summary.medium} |`,
`| Low | ${result.summary.low} |`,
`| Negligible | ${result.summary.negligible} |`,
`| Unknown | ${result.summary.unknown} |`,
`| **Total** | **${result.summary.total}** |`,
``,
`## Vulnerabilities`,
``,
`| CVE ID | Severity | Package | Installed | Fixed In |`,
`|--------|----------|---------|-----------|----------|`,
...result.vulnerabilities.map((v) => {
const ep = (s: string) => s.replace(/\\/g, "\\\\").replace(/\|/g, "\\|");
return `| ${ep(v.id)} | ${ep(v.severity)} | ${ep(v.package)} | ${ep(v.installed_version)} | ${ep(v.fixed_version || "-")} |`;
}),
];
downloadFile(
lines.join("\n"),
`scan-${result.image_ref.replace(/[/:]/g, "_")}.md`,
"text/markdown"
);
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<DownloadIcon className="mr-2 size-4" />
Export
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={exportMarkdown}>
Markdown report (.md)
</DropdownMenuItem>
<DropdownMenuItem onClick={exportCSV}>
CSV spreadsheet (.csv)
</DropdownMenuItem>
<DropdownMenuItem onClick={exportJSON}>
JSON data (.json)
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -0,0 +1,75 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import type { SeveritySummary } from "../types";
import { ScanResultsSummary } from "./scan-results-summary";
function makeSummary(overrides: Partial<SeveritySummary> = {}): SeveritySummary {
return {
critical: 0,
high: 0,
medium: 0,
low: 0,
negligible: 0,
unknown: 0,
total: 0,
...overrides,
};
}
describe("ScanResultsSummary", () => {
it("shows 'No vulnerabilities' badge when total is zero", () => {
render(<ScanResultsSummary summary={makeSummary({ total: 0 })} />);
expect(screen.getByText("No vulnerabilities")).toBeInTheDocument();
});
it("does not show severity badges when all counts are zero", () => {
render(<ScanResultsSummary summary={makeSummary()} />);
expect(screen.queryByText(/Critical/)).not.toBeInTheDocument();
expect(screen.queryByText(/High/)).not.toBeInTheDocument();
expect(screen.queryByText(/Medium/)).not.toBeInTheDocument();
expect(screen.queryByText(/Low/)).not.toBeInTheDocument();
});
it("shows Critical badge when critical count is non-zero", () => {
render(<ScanResultsSummary summary={makeSummary({ critical: 3, total: 3 })} />);
expect(screen.getByText("3 Critical")).toBeInTheDocument();
});
it("shows High badge when high count is non-zero", () => {
render(<ScanResultsSummary summary={makeSummary({ high: 5, total: 5 })} />);
expect(screen.getByText("5 High")).toBeInTheDocument();
});
it("shows Medium badge when medium count is non-zero", () => {
render(<ScanResultsSummary summary={makeSummary({ medium: 2, total: 2 })} />);
expect(screen.getByText("2 Medium")).toBeInTheDocument();
});
it("shows Low badge when low count is non-zero", () => {
render(<ScanResultsSummary summary={makeSummary({ low: 10, total: 10 })} />);
expect(screen.getByText("10 Low")).toBeInTheDocument();
});
it("shows multiple severity badges simultaneously", () => {
render(
<ScanResultsSummary
summary={makeSummary({ critical: 1, high: 2, medium: 3, low: 4, total: 10 })}
/>
);
expect(screen.getByText("1 Critical")).toBeInTheDocument();
expect(screen.getByText("2 High")).toBeInTheDocument();
expect(screen.getByText("3 Medium")).toBeInTheDocument();
expect(screen.getByText("4 Low")).toBeInTheDocument();
});
it("does not show 'No vulnerabilities' when total is non-zero", () => {
render(<ScanResultsSummary summary={makeSummary({ high: 1, total: 1 })} />);
expect(screen.queryByText("No vulnerabilities")).not.toBeInTheDocument();
});
it("does not show badge for zero critical count even if others are non-zero", () => {
render(<ScanResultsSummary summary={makeSummary({ high: 1, total: 1 })} />);
expect(screen.queryByText(/Critical/)).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,46 @@
import { Badge } from "@/components/ui/badge";
import type { SeveritySummary } from "../types";
interface ScanResultsSummaryProps {
summary: SeveritySummary;
}
const severityColors: Record<string, string> = {
critical: "bg-red-600 text-white hover:bg-red-600",
high: "bg-red-500 text-white hover:bg-red-500",
medium: "bg-orange-500 text-white hover:bg-orange-500",
low: "bg-yellow-500 text-white hover:bg-yellow-500",
};
export function ScanResultsSummary({ summary }: ScanResultsSummaryProps) {
return (
<div className="flex items-center gap-2 flex-wrap">
{summary.critical > 0 && (
<Badge className={severityColors.critical}>
{summary.critical} Critical
</Badge>
)}
{summary.high > 0 && (
<Badge className={severityColors.high}>
{summary.high} High
</Badge>
)}
{summary.medium > 0 && (
<Badge className={severityColors.medium}>
{summary.medium} Medium
</Badge>
)}
{summary.low > 0 && (
<Badge className={severityColors.low}>
{summary.low} Low
</Badge>
)}
{summary.total === 0 && (
<Badge variant="outline" className="border-green-500 text-green-500">
No vulnerabilities
</Badge>
)}
</div>
);
}

View file

@ -0,0 +1,127 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import type { Vulnerability } from "../types";
import { ScanResultsTable } from "./scan-results-table";
function makeVuln(overrides: Partial<Vulnerability> = {}): Vulnerability {
return {
id: "CVE-2023-0001",
severity: "High",
package: "openssl",
installed_version: "1.1.1t",
fixed_version: "1.1.1u",
...overrides,
};
}
const vulns: Vulnerability[] = [
makeVuln({ id: "CVE-2023-0001", severity: "Critical", package: "libssl" }),
makeVuln({ id: "CVE-2023-0002", severity: "High", package: "curl" }),
makeVuln({ id: "CVE-2023-0003", severity: "Medium", package: "zlib" }),
makeVuln({ id: "CVE-2023-0004", severity: "Low", package: "bash" }),
];
describe("ScanResultsTable", () => {
it("renders all vulnerabilities", () => {
render(<ScanResultsTable vulnerabilities={vulns} />);
for (const v of vulns) {
expect(screen.getByText(v.id)).toBeInTheDocument();
}
});
it("shows 'No vulnerabilities found' when list is empty", () => {
render(<ScanResultsTable vulnerabilities={[]} />);
expect(screen.getByText("No vulnerabilities found")).toBeInTheDocument();
});
it("shows total and filtered counts", () => {
render(<ScanResultsTable vulnerabilities={vulns} />);
// Should show "Showing 4 of 4 vulnerabilities"
expect(screen.getByText(/Showing 4 of 4/)).toBeInTheDocument();
});
it("filters by CVE ID when typing in search box", () => {
render(<ScanResultsTable vulnerabilities={vulns} />);
const searchInput = screen.getByPlaceholderText("Filter vulnerabilities...");
fireEvent.change(searchInput, { target: { value: "CVE-2023-0001" } });
expect(screen.getByText("CVE-2023-0001")).toBeInTheDocument();
expect(screen.queryByText("CVE-2023-0002")).not.toBeInTheDocument();
expect(screen.getByText(/Showing 1 of 4/)).toBeInTheDocument();
});
it("filters by package name when typing in search box", () => {
render(<ScanResultsTable vulnerabilities={vulns} />);
const searchInput = screen.getByPlaceholderText("Filter vulnerabilities...");
fireEvent.change(searchInput, { target: { value: "curl" } });
expect(screen.getByText("CVE-2023-0002")).toBeInTheDocument();
expect(screen.queryByText("CVE-2023-0001")).not.toBeInTheDocument();
});
it("filters by severity when typing (case-insensitive)", () => {
render(<ScanResultsTable vulnerabilities={vulns} />);
const searchInput = screen.getByPlaceholderText("Filter vulnerabilities...");
fireEvent.change(searchInput, { target: { value: "medium" } });
expect(screen.getByText("CVE-2023-0003")).toBeInTheDocument();
expect(screen.queryByText("CVE-2023-0001")).not.toBeInTheDocument();
});
it("shows 'No matching vulnerabilities' when search has no results", () => {
render(<ScanResultsTable vulnerabilities={vulns} />);
const searchInput = screen.getByPlaceholderText("Filter vulnerabilities...");
fireEvent.change(searchInput, { target: { value: "xyznotexist" } });
expect(screen.getByText("No matching vulnerabilities")).toBeInTheDocument();
});
it("renders fixed_version when available", () => {
const vuln = makeVuln({ fixed_version: "2.0.0" });
render(<ScanResultsTable vulnerabilities={[vuln]} />);
expect(screen.getByText("2.0.0")).toBeInTheDocument();
});
it("renders dash placeholder when fixed_version is absent", () => {
const vuln = makeVuln({ fixed_version: undefined });
render(<ScanResultsTable vulnerabilities={[vuln]} />);
expect(screen.getByText("-")).toBeInTheDocument();
});
it("renders CVE link pointing to NVD", () => {
render(<ScanResultsTable vulnerabilities={[makeVuln({ id: "CVE-2023-9999" })]} />);
const link = screen.getByRole("link", { name: /CVE-2023-9999/ });
expect(link).toHaveAttribute("href", expect.stringContaining("CVE-2023-9999"));
});
it("renders package name in each row", () => {
render(<ScanResultsTable vulnerabilities={vulns} />);
expect(screen.getByText("libssl")).toBeInTheDocument();
expect(screen.getByText("curl")).toBeInTheDocument();
});
it("sorts by severity ascending by default (Critical first)", () => {
const mixed = [
makeVuln({ id: "LOW-1", severity: "Low", package: "pkg-a" }),
makeVuln({ id: "CRIT-1", severity: "Critical", package: "pkg-b" }),
makeVuln({ id: "HIGH-1", severity: "High", package: "pkg-c" }),
];
render(<ScanResultsTable vulnerabilities={mixed} />);
const rows = screen.getAllByRole("row");
// Header row + data rows; first data row should be Critical
const firstDataRow = rows[1];
expect(firstDataRow.textContent).toContain("CRIT-1");
});
it("shows installed_version in each row", () => {
const vuln = makeVuln({ installed_version: "3.0.0-beta" });
render(<ScanResultsTable vulnerabilities={[vuln]} />);
expect(screen.getByText("3.0.0-beta")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,201 @@
import { useMemo, useState } from "react";
import { ArrowUpDownIcon, ExternalLinkIcon, SearchIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { SeverityLevel, Vulnerability } from "../types";
interface ScanResultsTableProps {
vulnerabilities: Vulnerability[];
}
const severityOrder: Record<SeverityLevel, number> = {
Critical: 0,
High: 1,
Medium: 2,
Low: 3,
Negligible: 4,
Unknown: 5,
};
const severityColors: Record<SeverityLevel, string> = {
Critical: "bg-red-600 text-white hover:bg-red-600",
High: "bg-red-500 text-white hover:bg-red-500",
Medium: "bg-orange-500 text-white hover:bg-orange-500",
Low: "bg-yellow-500 text-white hover:bg-yellow-500",
Negligible: "bg-gray-400 text-white hover:bg-gray-400",
Unknown: "bg-gray-300 text-gray-700 hover:bg-gray-300",
};
type SortField = "severity" | "id" | "package";
type SortDir = "asc" | "desc";
export function ScanResultsTable({ vulnerabilities }: ScanResultsTableProps) {
const [search, setSearch] = useState("");
const [sortField, setSortField] = useState<SortField>("severity");
const [sortDir, setSortDir] = useState<SortDir>("asc");
const toggleSort = (field: SortField) => {
if (sortField === field) {
setSortDir(sortDir === "asc" ? "desc" : "asc");
} else {
setSortField(field);
setSortDir("asc");
}
};
const filtered = useMemo(() => {
let items = vulnerabilities;
if (search) {
const s = search.toLowerCase();
items = items.filter(
(v) =>
v.id.toLowerCase().includes(s) ||
v.package.toLowerCase().includes(s) ||
v.severity.toLowerCase().includes(s)
);
}
items = [...items].sort((a, b) => {
let cmp = 0;
switch (sortField) {
case "severity":
cmp = severityOrder[a.severity] - severityOrder[b.severity];
break;
case "id":
cmp = a.id.localeCompare(b.id);
break;
case "package":
cmp = a.package.localeCompare(b.package);
break;
}
return sortDir === "asc" ? cmp : -cmp;
});
return items;
}, [vulnerabilities, search, sortField, sortDir]);
return (
<div className="space-y-3">
<div className="relative">
<SearchIcon className="absolute left-2 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder="Filter vulnerabilities..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8"
/>
</div>
<div className="max-h-[400px] overflow-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>
<Button
variant="ghost"
size="sm"
className="h-auto p-0 font-medium"
onClick={() => toggleSort("id")}
>
CVE ID <ArrowUpDownIcon className="ml-1 size-3" />
</Button>
</TableHead>
<TableHead>
<Button
variant="ghost"
size="sm"
className="h-auto p-0 font-medium"
onClick={() => toggleSort("severity")}
>
Severity <ArrowUpDownIcon className="ml-1 size-3" />
</Button>
</TableHead>
<TableHead>
<Button
variant="ghost"
size="sm"
className="h-auto p-0 font-medium"
onClick={() => toggleSort("package")}
>
Package <ArrowUpDownIcon className="ml-1 size-3" />
</Button>
</TableHead>
<TableHead>Installed</TableHead>
<TableHead>Fixed in</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground py-6">
{search ? "No matching vulnerabilities" : "No vulnerabilities found"}
</TableCell>
</TableRow>
) : (
filtered.map((vuln, index) => (
<TableRow key={`${vuln.id}-${vuln.package}-${index}`}>
<TableCell>
{vuln.id.startsWith("CVE-") ? (
<a
href={`https://nvd.nist.gov/vuln/detail/${vuln.id}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm hover:underline"
>
{vuln.id}
<ExternalLinkIcon className="size-3" />
</a>
) : vuln.id.startsWith("GHSA-") ? (
<a
href={`https://github.com/advisories/${vuln.id}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm hover:underline"
>
{vuln.id}
<ExternalLinkIcon className="size-3" />
</a>
) : (
<span className="inline-flex items-center text-sm">
{vuln.id}
</span>
)}
</TableCell>
<TableCell>
<Badge className={severityColors[vuln.severity]}>
{vuln.severity.toLowerCase()}
</Badge>
</TableCell>
<TableCell className="font-mono text-sm">{vuln.package}</TableCell>
<TableCell className="font-mono text-sm">{vuln.installed_version}</TableCell>
<TableCell className="font-mono text-sm">
{vuln.fixed_version ? (
<span className="text-green-600">{vuln.fixed_version}</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<p className="text-xs text-muted-foreground">
Showing {filtered.length} of {vulnerabilities.length} vulnerabilities
</p>
</div>
);
}

View file

@ -0,0 +1,69 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useObservedSBOMJobs } from "./use-scan-query";
const mockGetSBOMJob = vi.fn();
const mockToastSuccess = vi.fn();
vi.mock("../api/generate-sbom", () => ({
generateSBOM: vi.fn(),
getSBOMJob: (...args: unknown[]) => mockGetSBOMJob(...args),
}));
vi.mock("sonner", () => ({
toast: {
success: (...args: unknown[]) => mockToastSuccess(...args),
},
}));
function ObserverHarness({
jobIds,
onTerminalJob,
}: {
jobIds: string[];
onTerminalJob: (jobId: string) => void;
}) {
useObservedSBOMJobs(jobIds, onTerminalJob);
return null;
}
describe("useObservedSBOMJobs", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("fires completion side effects from the parent observer", async () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
const onTerminalJob = vi.fn();
mockGetSBOMJob.mockResolvedValue({
id: "sbom-job-1",
image_ref: "alpine:3.18",
host: "local",
format: "spdx-json",
status: "complete",
created_at: 200,
});
render(
<QueryClientProvider client={queryClient}>
<ObserverHarness jobIds={["sbom-job-1"]} onTerminalJob={onTerminalJob} />
</QueryClientProvider>
);
await waitFor(() => {
expect(onTerminalJob).toHaveBeenCalledWith("sbom-job-1");
});
expect(mockToastSuccess).toHaveBeenCalledWith("SBOM generated and saved to history");
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["sbomedImages"] });
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["sbomHistory"] });
});
});

View file

@ -0,0 +1,262 @@
import { useEffect, useRef } from "react";
import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { startScan, type StartScanParams } from "../api/start-scan";
import { startBulkScan, type StartBulkScanParams } from "../api/start-bulk-scan";
import { getScanJob, cancelScanJob } from "../api/get-scan-jobs";
import { getScanResults } from "../api/get-scan-results";
import { generateSBOM, getSBOMJob, type GenerateSBOMParams } from "../api/generate-sbom";
import {
getScannerConfig,
updateScannerConfig,
testScanNotification,
} from "../api/scanner-config";
import {
getScanHistory,
getScanHistoryDetail,
getScannedImages,
getAutoScanStatus,
deleteScanHistory,
} from "../api/get-scan-history";
import {
getSBOMHistory,
getSBOMHistoryDetail,
getSBOMedImages,
deleteSBOMHistory,
} from "../api/get-sbom-history";
import type {
ScannerConfig,
HistoryQueryParams,
SBOMHistoryQueryParams,
} from "../types";
const SCANNER_CONFIG_KEY = ["scannerConfig"] as const;
export function useScannerConfig() {
return useQuery({
queryKey: SCANNER_CONFIG_KEY,
queryFn: getScannerConfig,
staleTime: 30_000,
});
}
export function useUpdateScannerConfig() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (config: ScannerConfig) => updateScannerConfig(config),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: SCANNER_CONFIG_KEY });
},
});
}
export function useTestScanNotification() {
return useMutation({
mutationFn: () => testScanNotification(),
});
}
export function useStartScan() {
return useMutation({
mutationFn: (params: StartScanParams) => startScan(params),
});
}
export function useStartBulkScan() {
return useMutation({
mutationFn: (params: StartBulkScanParams) => startBulkScan(params),
});
}
export function useScanJob(id: string | null, enabled = true) {
return useQuery({
queryKey: ["scanJob", id],
queryFn: () => getScanJob(id!),
enabled: enabled && !!id,
refetchInterval: (query) => {
const data = query.state.data;
if (!data) return 2000;
const job = data.job || data.bulkJob;
if (!job) return false;
const status = job.status;
if (status === "complete" || status === "failed" || status === "cancelled") {
return false;
}
return 2000;
},
});
}
export function useCancelScan() {
return useMutation({
mutationFn: (id: string) => cancelScanJob(id),
});
}
export function useScanResults(imageRef: string, host: string, enabled = true) {
return useQuery({
queryKey: ["scanResults", imageRef, host],
queryFn: () => getScanResults(imageRef, host),
enabled,
staleTime: 30_000,
});
}
export function useGenerateSBOM() {
return useMutation({
mutationFn: (params: GenerateSBOMParams) => generateSBOM(params),
});
}
export function useSBOMJob(id: string | null, enabled = true) {
return useQuery({
queryKey: ["sbomJob", id],
queryFn: () => getSBOMJob(id!),
enabled: enabled && !!id,
refetchInterval: (query) => {
const data = query.state.data;
if (!data) return 2000;
if (data.status === "complete" || data.status === "failed" || data.status === "cancelled") {
return false;
}
return 2000;
},
});
}
// --- History hooks ---
export function useScanHistory(params: HistoryQueryParams) {
return useQuery({
queryKey: ["scanHistory", params],
queryFn: () => getScanHistory(params),
staleTime: 10_000,
});
}
export function useScanHistoryDetail(id: string | null) {
return useQuery({
queryKey: ["scanHistoryDetail", id],
queryFn: () => getScanHistoryDetail(id!),
enabled: !!id,
staleTime: 60_000,
});
}
export function useScannedImages() {
return useQuery({
queryKey: ["scannedImages"],
queryFn: getScannedImages,
staleTime: 30_000,
});
}
export function useAutoScanStatus() {
return useQuery({
queryKey: ["autoScanStatus"],
queryFn: getAutoScanStatus,
staleTime: 15_000,
});
}
export function useDeleteScanHistory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => deleteScanHistory(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["scanHistory"] });
queryClient.invalidateQueries({ queryKey: ["scannedImages"] });
},
});
}
export function useObservedSBOMJobs(
jobIds: string[],
onTerminalJob?: (jobId: string) => void
) {
const queryClient = useQueryClient();
const handledJobIdsRef = useRef(new Set<string>());
const jobQueries = useQueries({
queries: jobIds.map((jobId) => ({
queryKey: ["sbomJob", jobId],
queryFn: () => getSBOMJob(jobId),
enabled: !!jobId,
refetchInterval: (query: { state: { data?: Awaited<ReturnType<typeof getSBOMJob>> } }) => {
const data = query.state.data;
if (!data) return 2000;
if (data.status === "complete" || data.status === "failed" || data.status === "cancelled") {
return false;
}
return 2000;
},
})),
});
useEffect(() => {
const activeJobIDs = new Set(jobIds);
for (const handledJobID of handledJobIdsRef.current) {
if (!activeJobIDs.has(handledJobID)) {
handledJobIdsRef.current.delete(handledJobID);
}
}
}, [jobIds]);
useEffect(() => {
jobQueries.forEach((jobQuery, index) => {
const jobId = jobIds[index];
const status = jobQuery.data?.status;
if (!jobId || !status || handledJobIdsRef.current.has(jobId)) return;
if (status !== "complete" && status !== "failed" && status !== "cancelled") return;
handledJobIdsRef.current.add(jobId);
if (status === "complete") {
toast.success("SBOM generated and saved to history");
queryClient.invalidateQueries({ queryKey: ["sbomedImages"] });
queryClient.invalidateQueries({ queryKey: ["sbomHistory"] });
}
onTerminalJob?.(jobId);
});
}, [jobIds, jobQueries, onTerminalJob, queryClient]);
return jobQueries;
}
export function useSBOMHistory(params: SBOMHistoryQueryParams) {
return useQuery({
queryKey: ["sbomHistory", params],
queryFn: () => getSBOMHistory(params),
staleTime: 10_000,
});
}
export function useSBOMHistoryDetail(id: string | null) {
return useQuery({
queryKey: ["sbomHistoryDetail", id],
queryFn: () => getSBOMHistoryDetail(id!),
enabled: !!id,
staleTime: 60_000,
});
}
export function useSBOMedImages() {
return useQuery({
queryKey: ["sbomedImages"],
queryFn: getSBOMedImages,
staleTime: 30_000,
});
}
export function useDeleteSBOMHistory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => deleteSBOMHistory(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["sbomHistory"] });
queryClient.invalidateQueries({ queryKey: ["sbomedImages"] });
},
});
}

View file

@ -0,0 +1,152 @@
import { describe, expect, it } from "vitest";
import type { ScannerConfig } from "./types";
// ---------------------------------------------------------------------------
// ScannerConfig resource limit fields added in this PR
// ---------------------------------------------------------------------------
// These tests verify the runtime shape of objects that conform to the
// ScannerConfig interface, ensuring the four new fields are present and
// correctly typed. Because TypeScript interfaces are compile-time only, we
// create plain objects that satisfy the interface and assert their values.
function makeScannerConfig(overrides: Partial<ScannerConfig> = {}): ScannerConfig {
return {
grypeImage: "anchore/grype:v0.110.0",
trivyImage: "aquasec/trivy:0.69.3",
syftImage: "anchore/syft:v1.42.3",
defaultScanner: "grype",
grypeArgs: "",
trivyArgs: "",
notifications: {
onScanComplete: true,
onBulkComplete: true,
onNewCVEs: true,
minSeverity: "High",
},
autoScan: {
enabled: false,
pollIntervalMinutes: 15,
},
forceRescan: false,
scanTimeoutMinutes: 20,
bulkTimeoutMinutes: 120,
scannerMemoryMB: 2048,
scannerPidsLimit: 512,
...overrides,
};
}
describe("ScannerConfig resource limit fields", () => {
it("has numeric scanTimeoutMinutes defaulting to 20", () => {
const cfg = makeScannerConfig();
expect(typeof cfg.scanTimeoutMinutes).toBe("number");
expect(cfg.scanTimeoutMinutes).toBe(20);
});
it("has numeric bulkTimeoutMinutes defaulting to 120", () => {
const cfg = makeScannerConfig();
expect(typeof cfg.bulkTimeoutMinutes).toBe("number");
expect(cfg.bulkTimeoutMinutes).toBe(120);
});
it("has numeric scannerMemoryMB defaulting to 2048", () => {
const cfg = makeScannerConfig();
expect(typeof cfg.scannerMemoryMB).toBe("number");
expect(cfg.scannerMemoryMB).toBe(2048);
});
it("has numeric scannerPidsLimit defaulting to 512", () => {
const cfg = makeScannerConfig();
expect(typeof cfg.scannerPidsLimit).toBe("number");
expect(cfg.scannerPidsLimit).toBe(512);
});
it("accepts custom resource limit values", () => {
const cfg = makeScannerConfig({
scanTimeoutMinutes: 60,
bulkTimeoutMinutes: 240,
scannerMemoryMB: 4096,
scannerPidsLimit: 1024,
});
expect(cfg.scanTimeoutMinutes).toBe(60);
expect(cfg.bulkTimeoutMinutes).toBe(240);
expect(cfg.scannerMemoryMB).toBe(4096);
expect(cfg.scannerPidsLimit).toBe(1024);
});
it("resource limit fields are independent of other config fields", () => {
const cfg = makeScannerConfig({
defaultScanner: "trivy",
scanTimeoutMinutes: 30,
});
expect(cfg.defaultScanner).toBe("trivy");
expect(cfg.scanTimeoutMinutes).toBe(30);
// Other resource limit fields remain at defaults
expect(cfg.bulkTimeoutMinutes).toBe(120);
expect(cfg.scannerMemoryMB).toBe(2048);
expect(cfg.scannerPidsLimit).toBe(512);
});
it("minimum boundary value of 1 is valid for scanTimeoutMinutes", () => {
const cfg = makeScannerConfig({ scanTimeoutMinutes: 1 });
expect(cfg.scanTimeoutMinutes).toBe(1);
});
it("large values are representable", () => {
const cfg = makeScannerConfig({
scanTimeoutMinutes: 9999,
bulkTimeoutMinutes: 9999,
scannerMemoryMB: 65536,
scannerPidsLimit: 32768,
});
expect(cfg.scanTimeoutMinutes).toBe(9999);
expect(cfg.bulkTimeoutMinutes).toBe(9999);
expect(cfg.scannerMemoryMB).toBe(65536);
expect(cfg.scannerPidsLimit).toBe(32768);
});
});
describe("ScannerConfig updateScannerConfig round-trip resource limits", () => {
// Verify that spreading/cloning a ScannerConfig preserves the new fields,
// matching how scanner-section.tsx updates draft state via setDraft({...draft, field: value}).
it("spread preserves all resource limit fields", () => {
const original = makeScannerConfig({
scanTimeoutMinutes: 45,
bulkTimeoutMinutes: 180,
scannerMemoryMB: 8192,
scannerPidsLimit: 768,
});
const updated: ScannerConfig = { ...original, grypeImage: "anchore/grype:v2" };
expect(updated.scanTimeoutMinutes).toBe(45);
expect(updated.bulkTimeoutMinutes).toBe(180);
expect(updated.scannerMemoryMB).toBe(8192);
expect(updated.scannerPidsLimit).toBe(768);
expect(updated.grypeImage).toBe("anchore/grype:v2");
});
it("individual field override does not affect other resource limit fields", () => {
const original = makeScannerConfig();
const updated: ScannerConfig = { ...original, scanTimeoutMinutes: 99 };
expect(updated.scanTimeoutMinutes).toBe(99);
expect(updated.bulkTimeoutMinutes).toBe(original.bulkTimeoutMinutes);
expect(updated.scannerMemoryMB).toBe(original.scannerMemoryMB);
expect(updated.scannerPidsLimit).toBe(original.scannerPidsLimit);
});
it("serialises and deserialises resource limit fields via JSON", () => {
const original = makeScannerConfig({
scanTimeoutMinutes: 30,
bulkTimeoutMinutes: 90,
scannerMemoryMB: 1024,
scannerPidsLimit: 256,
});
const json = JSON.stringify(original);
const restored = JSON.parse(json) as ScannerConfig;
expect(restored.scanTimeoutMinutes).toBe(30);
expect(restored.bulkTimeoutMinutes).toBe(90);
expect(restored.scannerMemoryMB).toBe(1024);
expect(restored.scannerPidsLimit).toBe(256);
});
});

View file

@ -0,0 +1,195 @@
export type ScannerType = "grype" | "trivy";
export type SeverityLevel = "Critical" | "High" | "Medium" | "Low" | "Negligible" | "Unknown";
export type ScanJobStatus = "pending" | "pulling_scanner" | "scanning" | "complete" | "failed" | "cancelled" | "expired";
export type SBOMFormat = "spdx-json" | "cyclonedx-json";
export interface Vulnerability {
id: string;
severity: SeverityLevel;
package: string;
installed_version: string;
fixed_version?: string;
description?: string;
data_source?: string;
}
export interface SeveritySummary {
critical: number;
high: number;
medium: number;
low: number;
negligible: number;
unknown: number;
total: number;
}
export interface ScanResult {
id: string;
image_ref: string;
host: string;
scanner: ScannerType;
vulnerabilities: Vulnerability[];
summary: SeveritySummary;
started_at: number;
completed_at: number;
duration_ms: number;
error?: string;
}
export interface ScanJob {
id: string;
image_ref: string;
host: string;
scanner: ScannerType;
status: ScanJobStatus;
progress?: string;
result?: ScanResult;
created_at: number;
error?: string;
}
export interface BulkScanJob {
id: string;
jobs: ScanJob[];
total_images: number;
completed: number;
failed: number;
status: ScanJobStatus;
created_at: number;
}
export interface SBOMJob {
id: string;
image_ref: string;
host: string;
format: SBOMFormat;
status: ScanJobStatus;
result_id?: string;
created_at: number;
error?: string;
}
export interface SBOMComponent {
name: string;
version: string;
type: string;
purl: string;
}
export interface SBOMResult {
id: string;
image_ref: string;
host: string;
format: SBOMFormat;
component_count: number;
file_size: number;
started_at: number;
completed_at: number;
duration_ms: number;
error?: string;
components?: SBOMComponent[];
}
export interface NotificationConfig {
discordWebhookURL?: string;
slackWebhookURL?: string;
onScanComplete: boolean;
onBulkComplete: boolean;
onNewCVEs: boolean;
minSeverity?: SeverityLevel;
}
export interface AutoScanConfig {
enabled: boolean;
pollIntervalMinutes?: number;
}
export interface ScannerConfig {
grypeImage: string;
trivyImage: string;
syftImage: string;
defaultScanner: ScannerType;
grypeArgs: string;
trivyArgs: string;
notifications: NotificationConfig;
autoScan: AutoScanConfig;
forceRescan: boolean;
scanTimeoutMinutes: number;
bulkTimeoutMinutes: number;
scannerMemoryMB: number;
scannerPidsLimit: number;
}
export interface HistoryQueryParams {
image?: string;
host?: string;
min_severity?: SeverityLevel;
start_date?: number;
end_date?: number;
page?: number;
page_size?: number;
sort_by?: "completed_at" | "summary_total" | "summary_critical";
sort_dir?: "asc" | "desc";
}
export interface HistoryPage {
results: ScanResult[];
total: number;
page: number;
page_size: number;
total_pages: number;
}
export interface SBOMHistoryQueryParams {
image?: string;
host?: string;
format?: SBOMFormat;
start_date?: number;
end_date?: number;
page?: number;
page_size?: number;
sort_by?: "completed_at" | "component_count";
sort_dir?: "asc" | "desc";
}
export interface SBOMHistoryPage {
results: SBOMResult[];
total: number;
page: number;
page_size: number;
total_pages: number;
}
export interface ScannedImage {
image_ref: string;
host: string;
scan_count: number;
last_scanned: number;
}
export interface SBOMedImage {
image_ref: string;
host: string;
sbom_count: number;
last_sbom_at: number;
}
export interface AutoScanStatus {
enabled: boolean;
lastPollAt: number;
eventsConnected: Record<string, boolean>;
}
export interface RescanBlockedResponse {
error: "image_unchanged";
message: string;
last_scan_id?: string;
last_scan_at?: number;
}
export interface SBOMRescanBlockedResponse {
error: "image_unchanged";
message: string;
last_sbom_id?: string;
last_sbom_at?: number;
}

View file

@ -0,0 +1,37 @@
import { authenticatedFetch } from "@/lib/api-client";
import { API_BASE_URL } from "@/types/api";
const ENDPOINT = `${API_BASE_URL}/api/v1/settings/test/bot`;
export async function testBot(telegramToken: string, allowedChatId: string) {
const response = await authenticatedFetch(ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ telegramToken, allowedChatId }),
});
if (!response.ok) {
const message = await response.text();
throw new Error(message || "Failed to test bot");
}
return response.json() as Promise<{ success: boolean; message: string }>;
}
export async function testDiscordBot(botToken: string, allowedChannelId: string) {
const response = await authenticatedFetch(
`${API_BASE_URL}/api/v1/settings/test/discord-bot`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ botToken, allowedChannelId }),
},
);
if (!response.ok) {
const message = await response.text();
throw new Error(message || "Failed to test Discord bot");
}
return response.json() as Promise<{ success: boolean; message: string }>;
}

View file

@ -0,0 +1,34 @@
import { authenticatedFetch } from "@/lib/api-client";
import { API_BASE_URL } from "@/types/api";
const ENDPOINT = `${API_BASE_URL}/api/v1/settings/bot`;
export interface UpdateBotPayload {
enabled: boolean;
mode: "polling" | "jwt-relay";
telegramToken: string;
allowedChatId: string;
discord: {
enabled: boolean;
botToken: string;
applicationId: string;
guildId: string;
allowedChannelId: string;
};
}
export async function updateBot(payload: UpdateBotPayload): Promise<string> {
const response = await authenticatedFetch(ENDPOINT, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
const message = await response.text();
throw new Error(message || "Failed to update bot settings");
}
const data = (await response.json()) as { message?: string };
return data.message ?? "Bot settings updated";
}

View file

@ -56,6 +56,7 @@ export function AuthSection({ config }: AuthSectionProps) {
{
onSuccess: (msg) => {
toast.success(msg);
setUsername(trimmedUsername);
setPassword("");
},
onError: (err) => toast.error(err.message),

View file

@ -0,0 +1,43 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { BotSection } from "./bot-section";
import type { BotConfig } from "../types";
vi.mock("../hooks/use-settings", () => ({
useUpdateBot: () => ({ isPending: false, mutate: vi.fn() }),
useTestBot: () => ({ isPending: false, mutate: vi.fn() }),
useTestDiscordBot: () => ({ isPending: false, mutate: vi.fn() }),
}));
const baseConfig: BotConfig = {
source: "file",
enabled: true,
mode: "polling",
telegramTokenConfigured: true,
allowedChatId: "123",
relayPath: "/api/v1/bot/relay/command",
relayUsesAuth: true,
discord: {
enabled: false,
botToken: "",
applicationId: "",
guildId: "",
allowedChannelId: "",
},
};
describe("BotSection", () => {
it("uses a masked token placeholder when the token is configured", () => {
render(<BotSection config={baseConfig} />);
expect((screen.getByLabelText("Telegram token") as HTMLInputElement).value).toBe("••••••••");
});
it("disables controls for mixed env-backed bot config", () => {
render(<BotSection config={{ ...baseConfig, source: "mixed" }} />);
expect((screen.getByLabelText("Telegram token") as HTMLInputElement).disabled).toBe(true);
expect(screen.queryByRole("button", { name: /save changes/i })).toBeNull();
});
});

View file

@ -0,0 +1,374 @@
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import {
useTestBot,
useTestDiscordBot,
useUpdateBot,
} from "../hooks/use-settings";
import type { BotConfig } from "../types";
import { EnvBadge } from "./env-badge";
const MASKED_TOKEN = "••••••••";
interface BotSectionProps {
config: BotConfig;
disabled?: boolean;
authEnabled?: boolean;
}
export function BotSection({
config,
disabled = false,
authEnabled = false,
}: BotSectionProps) {
const isEnvBacked = config.source === "env" || config.source === "mixed";
const displayTelegramToken = config.telegramTokenConfigured ? MASKED_TOKEN : "";
const [enabled, setEnabled] = useState(config.enabled);
const [mode, setMode] = useState(config.mode);
const [telegramToken, setTelegramToken] = useState(displayTelegramToken);
const [allowedChatId, setAllowedChatId] = useState(config.allowedChatId);
const [discordEnabled, setDiscordEnabled] = useState(config.discord.enabled);
const [discordBotToken, setDiscordBotToken] = useState(config.discord.botToken);
const [discordApplicationId, setDiscordApplicationId] = useState(
config.discord.applicationId,
);
const [discordGuildId, setDiscordGuildId] = useState(config.discord.guildId);
const [discordAllowedChannelId, setDiscordAllowedChannelId] = useState(
config.discord.allowedChannelId,
);
const updateMutation = useUpdateBot();
const testMutation = useTestBot();
const discordTestMutation = useTestDiscordBot();
useEffect(() => {
setEnabled(config.enabled);
setMode(config.mode);
setTelegramToken(config.telegramTokenConfigured ? MASKED_TOKEN : "");
setAllowedChatId(config.allowedChatId);
setDiscordEnabled(config.discord.enabled);
setDiscordBotToken(config.discord.botToken);
setDiscordApplicationId(config.discord.applicationId);
setDiscordGuildId(config.discord.guildId);
setDiscordAllowedChannelId(config.discord.allowedChannelId);
}, [config]);
const hasChanges =
enabled !== config.enabled ||
mode !== config.mode ||
telegramToken !== displayTelegramToken ||
allowedChatId !== config.allowedChatId ||
discordEnabled !== config.discord.enabled ||
discordBotToken !== config.discord.botToken ||
discordApplicationId !== config.discord.applicationId ||
discordGuildId !== config.discord.guildId ||
discordAllowedChannelId !== config.discord.allowedChannelId;
const controlsDisabled =
disabled ||
isEnvBacked ||
updateMutation.isPending ||
testMutation.isPending ||
discordTestMutation.isPending;
const handleSave = () => {
updateMutation.mutate(
{
enabled,
mode,
telegramToken,
allowedChatId,
discord: {
enabled: discordEnabled,
botToken: discordBotToken,
applicationId: discordApplicationId,
guildId: discordGuildId,
allowedChannelId: discordAllowedChannelId,
},
},
{
onSuccess: (message) => toast.success(message),
onError: (error) => toast.error(error.message),
},
);
};
const handleTest = () => {
testMutation.mutate(
{ telegramToken, allowedChatId },
{
onSuccess: (result) => {
if (result.success) {
toast.success(result.message);
} else {
toast.error(result.message);
}
},
onError: (error) => toast.error(error.message),
},
);
};
const handleDiscordTest = () => {
discordTestMutation.mutate(
{
botToken: discordBotToken,
allowedChannelId: discordAllowedChannelId,
},
{
onSuccess: (result) => {
if (result.success) {
toast.success(result.message);
} else {
toast.error(result.message);
}
},
onError: (error) => toast.error(error.message),
},
);
};
return (
<>
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<CardTitle>Telegram Bot</CardTitle>
{isEnvBacked && <EnvBadge />}
</div>
<CardDescription>
Configure the Telegram bot for `/help`, `/status`, and `/critical`
commands.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-3">
<Switch
id="bot-enabled"
checked={enabled}
onCheckedChange={setEnabled}
disabled={controlsDisabled}
/>
<Label htmlFor="bot-enabled" className="cursor-pointer">
{enabled ? "Enabled" : "Disabled"}
</Label>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="bot-mode">Mode</Label>
<Select
value={mode}
onValueChange={(value) => setMode(value as BotConfig["mode"])}
disabled={controlsDisabled}
>
<SelectTrigger id="bot-mode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="polling">Polling</SelectItem>
<SelectItem value="jwt-relay" disabled={!authEnabled}>
JWT Relay
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="telegram-token">Telegram token</Label>
<Input
id="telegram-token"
value={telegramToken}
onChange={(event) => setTelegramToken(event.target.value)}
disabled={controlsDisabled}
type="password"
placeholder="123456:ABC..."
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="allowed-chat-id">Allowed chat ID</Label>
<Input
id="allowed-chat-id"
value={allowedChatId}
onChange={(event) => setAllowedChatId(event.target.value)}
disabled={controlsDisabled}
placeholder="123456789"
/>
</div>
</div>
{mode === "jwt-relay" && (
<div className="rounded-md border bg-muted/30 p-3 text-sm">
<p className="font-medium">JWT relay</p>
<p className="mt-1 text-muted-foreground">
Relay path:{" "}
<span className="font-mono">{config.relayPath}</span>
</p>
<p className="mt-1 text-muted-foreground">
Protected by existing auth:{" "}
{config.relayUsesAuth ? "yes" : "no"}
</p>
{!authEnabled && (
<p className="mt-2 text-destructive">
Enable dashboard auth before using JWT relay mode.
</p>
)}
</div>
)}
{!isEnvBacked && (
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={handleSave}
disabled={!hasChanges || controlsDisabled}
>
{updateMutation.isPending ? (
<>
<Spinner className="size-3" />
Saving...
</>
) : (
"Save changes"
)}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleTest}
disabled={controlsDisabled}
>
{testMutation.isPending ? "Testing..." : "Send test"}
</Button>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<CardTitle>Discord Bot</CardTitle>
{isEnvBacked && <EnvBadge />}
</div>
<CardDescription>
Configure Discord slash commands for `/help`, `/status`, and
`/critical`.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-3">
<Switch
id="discord-bot-enabled"
checked={discordEnabled}
onCheckedChange={setDiscordEnabled}
disabled={controlsDisabled}
/>
<Label htmlFor="discord-bot-enabled" className="cursor-pointer">
{discordEnabled ? "Enabled" : "Disabled"}
</Label>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="discord-bot-token">Bot token</Label>
<Input
id="discord-bot-token"
value={discordBotToken}
onChange={(event) => setDiscordBotToken(event.target.value)}
disabled={controlsDisabled}
type="password"
placeholder="MTA..."
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="discord-application-id">Application ID</Label>
<Input
id="discord-application-id"
value={discordApplicationId}
onChange={(event) => setDiscordApplicationId(event.target.value)}
disabled={controlsDisabled}
placeholder="123456789012345678"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="discord-guild-id">Guild ID</Label>
<Input
id="discord-guild-id"
value={discordGuildId}
onChange={(event) => setDiscordGuildId(event.target.value)}
disabled={controlsDisabled}
placeholder="Optional"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="discord-channel-id">Allowed channel ID</Label>
<Input
id="discord-channel-id"
value={discordAllowedChannelId}
onChange={(event) =>
setDiscordAllowedChannelId(event.target.value)
}
disabled={controlsDisabled}
placeholder="123456789012345678"
/>
</div>
</div>
<div className="rounded-md border bg-muted/30 p-3 text-sm text-muted-foreground">
Slash command responses are ephemeral. Set a guild ID to register
commands to one server immediately; leave it blank for global
command registration.
</div>
{!isEnvBacked && (
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={handleSave}
disabled={!hasChanges || controlsDisabled}
>
{updateMutation.isPending ? (
<>
<Spinner className="size-3" />
Saving...
</>
) : (
"Save changes"
)}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleDiscordTest}
disabled={controlsDisabled}
>
{discordTestMutation.isPending ? "Testing..." : "Send test"}
</Button>
</div>
)}
</CardContent>
</Card>
</>
);
}

View file

@ -120,7 +120,11 @@ export function CoolifyHostsSection({ config }: CoolifyHostsSectionProps) {
return;
}
const next = [...fileHosts];
next[editingIndex] = { ...editingHost };
next[editingIndex] = {
hostName: trimmedName,
apiURL: editingHost.apiURL.trim(),
apiToken: editingHost.apiToken.trim(),
};
setFileHosts(next);
setEditingIndex(null);
setEditingHost(EMPTY_HOST);

View file

@ -111,6 +111,7 @@ export function DockerHostsSection({ config }: DockerHostsSectionProps) {
return;
}
const trimmedName = editingHost.name.trim();
const trimmedHost = editingHost.host.trim();
if (envHosts.some((h) => h.name === trimmedName)) {
toast.error(`Host name "${trimmedName}" is defined via environment variable`);
return;
@ -120,7 +121,7 @@ export function DockerHostsSection({ config }: DockerHostsSectionProps) {
return;
}
const next = [...fileHosts];
next[editingIndex] = { ...editingHost };
next[editingIndex] = { name: trimmedName, host: trimmedHost };
setFileHosts(next);
setEditingIndex(null);
setEditingHost({ name: "", host: "" });

View file

@ -0,0 +1,400 @@
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import isEqual from "fast-deep-equal";
import {
useScannerConfig,
useTestScanNotification,
useUpdateScannerConfig,
} from "@/features/scanner/hooks/use-scan-query";
import type { ScannerConfig, SeverityLevel } from "@/features/scanner/types";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
interface ScannerSectionProps {
disabled?: boolean;
}
const severityOptions: SeverityLevel[] = [
"Critical",
"High",
"Medium",
"Low",
"Negligible",
"Unknown",
];
function configsMatch(a: ScannerConfig | null, b: ScannerConfig | null) {
return isEqual(a, b);
}
export function ScannerSection({ disabled = false }: ScannerSectionProps) {
const { data, isLoading, error } = useScannerConfig();
const updateMutation = useUpdateScannerConfig();
const testMutation = useTestScanNotification();
const [draft, setDraft] = useState<ScannerConfig | null>(null);
useEffect(() => {
if (data) {
setDraft(data);
}
}, [data]);
const hasChanges = useMemo(() => {
if (!data || !draft) return false;
return !configsMatch(data, draft);
}, [data, draft]);
const saveConfig = async () => {
if (!draft) return null;
const updated = await updateMutation.mutateAsync(draft);
toast.success("Scanner configuration saved");
setDraft(updated);
return updated;
};
const handleSave = async () => {
try {
await saveConfig();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to save scanner configuration");
}
};
const handleTest = async () => {
try {
if (hasChanges) {
await saveConfig();
}
await testMutation.mutateAsync();
toast.success("Test notification sent");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to send test notification");
}
};
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle>Scanner</CardTitle>
<CardDescription>Loading scanner configuration...</CardDescription>
</CardHeader>
</Card>
);
}
if (error || !draft) {
return (
<Card>
<CardHeader>
<CardTitle>Scanner</CardTitle>
<CardDescription>
Failed to load scanner settings: {error?.message ?? "Unknown error"}
</CardDescription>
</CardHeader>
</Card>
);
}
const busy = disabled || updateMutation.isPending || testMutation.isPending;
return (
<Card>
<CardHeader>
<CardTitle>Scanner</CardTitle>
<CardDescription>
Configure vulnerability scanning, SBOM generation, and completion notifications.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="grype-image">Grype image</Label>
<Input
id="grype-image"
value={draft.grypeImage}
onChange={(e) => setDraft({ ...draft, grypeImage: e.target.value })}
disabled={busy}
/>
</div>
<div className="space-y-2">
<Label htmlFor="trivy-image">Trivy image</Label>
<Input
id="trivy-image"
value={draft.trivyImage}
onChange={(e) => setDraft({ ...draft, trivyImage: e.target.value })}
disabled={busy}
/>
</div>
<div className="space-y-2">
<Label htmlFor="syft-image">Syft image</Label>
<Input
id="syft-image"
value={draft.syftImage}
onChange={(e) => setDraft({ ...draft, syftImage: e.target.value })}
disabled={busy}
/>
</div>
<div className="space-y-2">
<Label htmlFor="default-scanner">Default scanner</Label>
<Select
value={draft.defaultScanner}
onValueChange={(value) =>
setDraft({ ...draft, defaultScanner: value as ScannerConfig["defaultScanner"] })
}
disabled={busy}
>
<SelectTrigger id="default-scanner">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="grype">Grype</SelectItem>
<SelectItem value="trivy">Trivy</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="grype-args">Grype args</Label>
<Input
id="grype-args"
value={draft.grypeArgs}
onChange={(e) => setDraft({ ...draft, grypeArgs: e.target.value })}
disabled={busy}
/>
</div>
<div className="space-y-2">
<Label htmlFor="trivy-args">Trivy args</Label>
<Input
id="trivy-args"
value={draft.trivyArgs}
onChange={(e) => setDraft({ ...draft, trivyArgs: e.target.value })}
disabled={busy}
/>
</div>
</div>
<div className="space-y-4 rounded-lg border p-4">
<div>
<h3 className="font-medium">Notifications</h3>
<p className="text-sm text-muted-foreground">
Send scanner completion updates to Discord and Slack webhooks.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="discord-webhook">Discord webhook URL</Label>
<Input
id="discord-webhook"
value={draft.notifications.discordWebhookURL ?? ""}
onChange={(e) =>
setDraft({
...draft,
notifications: {
...draft.notifications,
discordWebhookURL: e.target.value,
},
})
}
disabled={busy}
/>
</div>
<div className="space-y-2">
<Label htmlFor="slack-webhook">Slack webhook URL</Label>
<Input
id="slack-webhook"
value={draft.notifications.slackWebhookURL ?? ""}
onChange={(e) =>
setDraft({
...draft,
notifications: {
...draft.notifications,
slackWebhookURL: e.target.value,
},
})
}
disabled={busy}
/>
</div>
<div className="space-y-2">
<Label htmlFor="min-severity">Minimum severity</Label>
<Select
value={draft.notifications.minSeverity || "High"}
onValueChange={(value) =>
setDraft({
...draft,
notifications: {
...draft.notifications,
minSeverity: value as SeverityLevel,
},
})
}
disabled={busy}
>
<SelectTrigger id="min-severity">
<SelectValue />
</SelectTrigger>
<SelectContent>
{severityOptions.map((severity) => (
<SelectItem key={severity} value={severity}>
{severity}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="flex items-center justify-between rounded-md border p-3">
<div>
<p className="text-sm font-medium">On scan complete</p>
<p className="text-xs text-muted-foreground">
Send a notification when a single image scan finishes.
</p>
</div>
<Switch
checked={draft.notifications.onScanComplete}
onCheckedChange={(value) =>
setDraft({
...draft,
notifications: {
...draft.notifications,
onScanComplete: value,
},
})
}
disabled={busy}
/>
</div>
<div className="flex items-center justify-between rounded-md border p-3">
<div>
<p className="text-sm font-medium">On bulk complete</p>
<p className="text-xs text-muted-foreground">
Send a notification when the bulk scan finishes.
</p>
</div>
<Switch
checked={draft.notifications.onBulkComplete}
onCheckedChange={(value) =>
setDraft({
...draft,
notifications: {
...draft.notifications,
onBulkComplete: value,
},
})
}
disabled={busy}
/>
</div>
</div>
</div>
<div className="space-y-4 rounded-lg border p-4">
<div>
<h3 className="font-medium">Resource limits</h3>
<p className="text-sm text-muted-foreground">
Tune timeouts and resource ceilings for spawned scanner containers. Increase these
for very large images or slow networks.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="scan-timeout">Scan timeout (minutes)</Label>
<Input
id="scan-timeout"
type="number"
min={1}
value={draft.scanTimeoutMinutes ?? 20}
onChange={(e) =>
setDraft({ ...draft, scanTimeoutMinutes: Number(e.target.value) || 0 })
}
disabled={busy}
/>
<p className="text-xs text-muted-foreground">Per single-image scan. Default: 20.</p>
</div>
<div className="space-y-2">
<Label htmlFor="bulk-timeout">Bulk timeout (minutes)</Label>
<Input
id="bulk-timeout"
type="number"
min={1}
value={draft.bulkTimeoutMinutes ?? 120}
onChange={(e) =>
setDraft({ ...draft, bulkTimeoutMinutes: Number(e.target.value) || 0 })
}
disabled={busy}
/>
<p className="text-xs text-muted-foreground">For full host bulk scans. Default: 120.</p>
</div>
<div className="space-y-2">
<Label htmlFor="scanner-memory">Scanner memory (MB)</Label>
<Input
id="scanner-memory"
type="number"
min={128}
value={draft.scannerMemoryMB ?? 2048}
onChange={(e) =>
setDraft({ ...draft, scannerMemoryMB: Number(e.target.value) || 0 })
}
disabled={busy}
/>
<p className="text-xs text-muted-foreground">
Memory ceiling per scanner container. Default: 2048.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="scanner-pids">PID limit</Label>
<Input
id="scanner-pids"
type="number"
min={32}
value={draft.scannerPidsLimit ?? 512}
onChange={(e) =>
setDraft({ ...draft, scannerPidsLimit: Number(e.target.value) || 0 })
}
disabled={busy}
/>
<p className="text-xs text-muted-foreground">
Max processes per scanner container. Default: 512.
</p>
</div>
</div>
</div>
{disabled && (
<p className="text-sm text-muted-foreground">
Scanner settings are disabled while the server is in read-only mode.
</p>
)}
<div className="flex flex-wrap justify-end gap-2">
<Button variant="outline" onClick={handleTest} disabled={busy}>
{testMutation.isPending ? "Testing..." : "Test Notification"}
</Button>
<Button onClick={handleSave} disabled={busy || !hasChanges}>
{updateMutation.isPending ? "Saving..." : "Save Scanner Settings"}
</Button>
</div>
</CardContent>
</Card>
);
}

View file

@ -2,47 +2,55 @@ import { Spinner } from "@/components/ui/spinner";
import { useSettings } from "../hooks/use-settings";
import { AuthSection } from "./auth-section";
import { BotSection } from "./bot-section";
import { CoolifyHostsSection } from "./coolify-hosts-section";
import { DockerHostsSection } from "./docker-hosts-section";
import { ReadOnlySection } from "./read-only-section";
import { ScannerSection } from "./scanner-section";
export function SettingsPage() {
const { data, isLoading, error } = useSettings();
const { data, isLoading, error } = useSettings();
if (isLoading) {
return (
<div className="flex items-center justify-center py-20">
<Spinner className="size-6" />
</div>
);
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-20">
<Spinner className="size-6" />
</div>
);
}
if (error) {
return (
<div className="container mx-auto max-w-3xl px-4 py-8">
<p className="text-sm text-destructive">
Failed to load settings: {error.message}
</p>
</div>
);
}
if (error) {
return (
<div className="container mx-auto max-w-3xl px-4 py-8">
<p className="text-sm text-destructive">
Failed to load settings: {error.message}
</p>
</div>
);
}
if (!data) return null;
if (!data) return null;
return (
<div className="container mx-auto max-w-3xl px-4 py-8 space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
<p className="text-sm text-muted-foreground mt-1">
Manage VPS Monitor configuration. Sections marked as set via environment
variable can only be changed by updating the environment and
restarting.
</p>
</div>
<DockerHostsSection config={data.dockerHosts} />
<CoolifyHostsSection config={data.coolifyHosts} />
<ReadOnlySection config={data.readOnly} />
<AuthSection config={data.auth} />
</div>
);
return (
<div className="container mx-auto max-w-3xl px-4 py-8 space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
<p className="text-sm text-muted-foreground mt-1">
Manage VPS Monitor configuration. Sections marked as set via
environment variable can only be changed by updating the environment
and restarting.
</p>
</div>
<DockerHostsSection config={data.dockerHosts} />
<CoolifyHostsSection config={data.coolifyHosts} />
<ReadOnlySection config={data.readOnly} />
<AuthSection config={data.auth} />
<BotSection
config={data.bot}
disabled={data.readOnly.value}
authEnabled={data.auth.enabled}
/>
<ScannerSection disabled={data.readOnly.value} />
</div>
);
}

View file

@ -1,80 +1,119 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getSettings } from "../api/get-settings";
import { testBot, testDiscordBot } from "../api/test-bot";
import { testCoolifyHost } from "../api/test-coolify-host";
import { testDockerHost } from "../api/test-docker-host";
import { updateAuth, type UpdateAuthPayload } from "../api/update-auth";
import { type UpdateAuthPayload, updateAuth } from "../api/update-auth";
import { type UpdateBotPayload, updateBot } from "../api/update-bot";
import { updateCoolifyHosts } from "../api/update-coolify-hosts";
import { updateDockerHosts } from "../api/update-docker-hosts";
import { updateReadOnly } from "../api/update-read-only";
const SETTINGS_KEY = ["settings"] as const;
export function useSettings() {
return useQuery({
queryKey: SETTINGS_KEY,
queryFn: getSettings,
staleTime: 30_000,
});
return useQuery({
queryKey: SETTINGS_KEY,
queryFn: getSettings,
staleTime: 30_000,
});
}
export function useUpdateDockerHosts() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (hosts: { name: string; host: string }[]) => updateDockerHosts(hosts),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: SETTINGS_KEY });
},
});
const queryClient = useQueryClient();
return useMutation({
mutationFn: (hosts: { name: string; host: string }[]) =>
updateDockerHosts(hosts),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: SETTINGS_KEY });
},
});
}
export function useUpdateCoolifyHosts() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (hosts: { hostName: string; apiURL: string; apiToken: string }[]) =>
updateCoolifyHosts(hosts),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: SETTINGS_KEY });
},
});
const queryClient = useQueryClient();
return useMutation({
mutationFn: (
hosts: { hostName: string; apiURL: string; apiToken: string }[],
) => updateCoolifyHosts(hosts),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: SETTINGS_KEY });
},
});
}
export function useUpdateReadOnly() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (value: boolean) => updateReadOnly(value),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: SETTINGS_KEY });
},
});
const queryClient = useQueryClient();
return useMutation({
mutationFn: (value: boolean) => updateReadOnly(value),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: SETTINGS_KEY });
},
});
}
export function useUpdateAuth() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: UpdateAuthPayload) => updateAuth(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: SETTINGS_KEY });
},
});
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: UpdateAuthPayload) => updateAuth(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: SETTINGS_KEY });
},
});
}
export function useUpdateBot() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: UpdateBotPayload) => updateBot(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: SETTINGS_KEY });
},
});
}
export function useTestDockerHost() {
return useMutation({
mutationFn: ({ name, host }: { name: string; host: string }) =>
testDockerHost(name, host),
});
return useMutation({
mutationFn: ({ name, host }: { name: string; host: string }) =>
testDockerHost(name, host),
});
}
export function useTestCoolifyHost() {
return useMutation({
mutationFn: ({
hostName,
apiURL,
apiToken,
}: {
hostName: string;
apiURL: string;
apiToken: string;
}) => testCoolifyHost(hostName, apiURL, apiToken),
});
return useMutation({
mutationFn: ({
hostName,
apiURL,
apiToken,
}: {
hostName: string;
apiURL: string;
apiToken: string;
}) => testCoolifyHost(hostName, apiURL, apiToken),
});
}
export function useTestBot() {
return useMutation({
mutationFn: ({
telegramToken,
allowedChatId,
}: {
telegramToken: string;
allowedChatId: string;
}) => testBot(telegramToken, allowedChatId),
});
}
export function useTestDiscordBot() {
return useMutation({
mutationFn: ({
botToken,
allowedChannelId,
}: {
botToken: string;
allowedChannelId: string;
}) => testDiscordBot(botToken, allowedChannelId),
});
}

View file

@ -1,49 +1,69 @@
export type ConfigSource = "file" | "env" | "default" | "mixed";
export interface DockerHost {
name: string;
host: string;
source: ConfigSource;
name: string;
host: string;
source: ConfigSource;
}
export interface CoolifyHost {
hostName: string;
apiURL: string;
apiToken: string;
source: ConfigSource;
hostName: string;
apiURL: string;
apiToken: string;
source: ConfigSource;
}
export interface DockerHostsConfig {
source: ConfigSource;
hosts: DockerHost[];
source: ConfigSource;
hosts: DockerHost[];
}
export interface CoolifyHostsConfig {
source: ConfigSource;
hosts: CoolifyHost[];
source: ConfigSource;
hosts: CoolifyHost[];
}
export interface ReadOnlyConfig {
source: ConfigSource;
value: boolean;
source: ConfigSource;
value: boolean;
}
export interface AuthConfig {
source: ConfigSource;
enabled: boolean;
adminUsername?: string;
passwordConfigured: boolean;
source: ConfigSource;
enabled: boolean;
adminUsername?: string;
passwordConfigured: boolean;
}
export interface BotConfig {
source: ConfigSource;
enabled: boolean;
mode: "polling" | "jwt-relay";
telegramTokenConfigured: boolean;
allowedChatId: string;
relayPath: string;
relayUsesAuth: boolean;
discord: DiscordBotConfig;
}
export interface DiscordBotConfig {
enabled: boolean;
botToken: string;
applicationId: string;
guildId: string;
allowedChannelId: string;
}
export interface SettingsResponse {
dockerHosts: DockerHostsConfig;
coolifyHosts: CoolifyHostsConfig;
readOnly: ReadOnlyConfig;
auth: AuthConfig;
dockerHosts: DockerHostsConfig;
coolifyHosts: CoolifyHostsConfig;
readOnly: ReadOnlyConfig;
auth: AuthConfig;
bot: BotConfig;
}
export interface TestConnectionResult {
success: boolean;
message: string;
dockerVersion?: string;
success: boolean;
message: string;
dockerVersion?: string;
}

View file

@ -13,6 +13,8 @@ import { Route as SettingsRouteImport } from './routes/settings'
import { Route as LoginRouteImport } from './routes/login'
import { Route as IndexRouteImport } from './routes/index'
import { Route as StatsIndexRouteImport } from './routes/stats/index'
import { Route as ScanHistoryIndexRouteImport } from './routes/scan-history/index'
import { Route as SbomHistoryIndexRouteImport } from './routes/sbom-history/index'
import { Route as NetworksIndexRouteImport } from './routes/networks/index'
import { Route as ImagesIndexRouteImport } from './routes/images/index'
import { Route as AlertsIndexRouteImport } from './routes/alerts/index'
@ -39,6 +41,16 @@ const StatsIndexRoute = StatsIndexRouteImport.update({
path: '/stats/',
getParentRoute: () => rootRouteImport,
} as any)
const ScanHistoryIndexRoute = ScanHistoryIndexRouteImport.update({
id: '/scan-history/',
path: '/scan-history/',
getParentRoute: () => rootRouteImport,
} as any)
const SbomHistoryIndexRoute = SbomHistoryIndexRouteImport.update({
id: '/sbom-history/',
path: '/sbom-history/',
getParentRoute: () => rootRouteImport,
} as any)
const NetworksIndexRoute = NetworksIndexRouteImport.update({
id: '/networks/',
path: '/networks/',
@ -74,6 +86,8 @@ export interface FileRoutesByFullPath {
'/alerts': typeof AlertsIndexRoute
'/images': typeof ImagesIndexRoute
'/networks': typeof NetworksIndexRoute
'/sbom-history': typeof SbomHistoryIndexRoute
'/scan-history': typeof ScanHistoryIndexRoute
'/stats': typeof StatsIndexRoute
'/containers/$containerId/logs': typeof ContainersContainerIdLogsRoute
}
@ -85,6 +99,8 @@ export interface FileRoutesByTo {
'/alerts': typeof AlertsIndexRoute
'/images': typeof ImagesIndexRoute
'/networks': typeof NetworksIndexRoute
'/sbom-history': typeof SbomHistoryIndexRoute
'/scan-history': typeof ScanHistoryIndexRoute
'/stats': typeof StatsIndexRoute
'/containers/$containerId/logs': typeof ContainersContainerIdLogsRoute
}
@ -97,6 +113,8 @@ export interface FileRoutesById {
'/alerts/': typeof AlertsIndexRoute
'/images/': typeof ImagesIndexRoute
'/networks/': typeof NetworksIndexRoute
'/sbom-history/': typeof SbomHistoryIndexRoute
'/scan-history/': typeof ScanHistoryIndexRoute
'/stats/': typeof StatsIndexRoute
'/containers/$containerId/logs': typeof ContainersContainerIdLogsRoute
}
@ -110,6 +128,8 @@ export interface FileRouteTypes {
| '/alerts'
| '/images'
| '/networks'
| '/sbom-history'
| '/scan-history'
| '/stats'
| '/containers/$containerId/logs'
fileRoutesByTo: FileRoutesByTo
@ -121,6 +141,8 @@ export interface FileRouteTypes {
| '/alerts'
| '/images'
| '/networks'
| '/sbom-history'
| '/scan-history'
| '/stats'
| '/containers/$containerId/logs'
id:
@ -132,6 +154,8 @@ export interface FileRouteTypes {
| '/alerts/'
| '/images/'
| '/networks/'
| '/sbom-history/'
| '/scan-history/'
| '/stats/'
| '/containers/$containerId/logs'
fileRoutesById: FileRoutesById
@ -144,6 +168,8 @@ export interface RootRouteChildren {
AlertsIndexRoute: typeof AlertsIndexRoute
ImagesIndexRoute: typeof ImagesIndexRoute
NetworksIndexRoute: typeof NetworksIndexRoute
SbomHistoryIndexRoute: typeof SbomHistoryIndexRoute
ScanHistoryIndexRoute: typeof ScanHistoryIndexRoute
StatsIndexRoute: typeof StatsIndexRoute
ContainersContainerIdLogsRoute: typeof ContainersContainerIdLogsRoute
}
@ -178,6 +204,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof StatsIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/scan-history/': {
id: '/scan-history/'
path: '/scan-history'
fullPath: '/scan-history'
preLoaderRoute: typeof ScanHistoryIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/sbom-history/': {
id: '/sbom-history/'
path: '/sbom-history'
fullPath: '/sbom-history'
preLoaderRoute: typeof SbomHistoryIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/networks/': {
id: '/networks/'
path: '/networks'
@ -224,6 +264,8 @@ const rootRouteChildren: RootRouteChildren = {
AlertsIndexRoute: AlertsIndexRoute,
ImagesIndexRoute: ImagesIndexRoute,
NetworksIndexRoute: NetworksIndexRoute,
SbomHistoryIndexRoute: SbomHistoryIndexRoute,
ScanHistoryIndexRoute: ScanHistoryIndexRoute,
StatsIndexRoute: StatsIndexRoute,
ContainersContainerIdLogsRoute: ContainersContainerIdLogsRoute,
}

View file

@ -6,31 +6,37 @@ import { requireAuthIfEnabled } from "@/lib/auth-guard";
// Define search params schema for the dashboard
const dashboardSearchSchema = z
.object({
search: z.string().optional(),
state: z.string().optional(),
sort: z.enum(["asc", "desc"]).optional(),
group: z.enum(["none", "compose"]).optional(),
page: z.number().optional(),
pageSize: z.number().optional(),
from: z.string().optional(),
to: z.string().optional(),
})
.passthrough()
.catch({});
.object({
search: z.string().optional(),
state: z.string().optional(),
host: z.string().optional(),
sort: z.enum(["asc", "desc"]).optional(),
sortBy: z
.enum(["name", "state", "uptime", "created", "cpu", "ram"])
.optional(),
group: z.enum(["none", "compose"]).optional(),
interval: z.enum(["1h", "12h"]).optional(),
expanded: z.array(z.string()).optional(),
page: z.number().optional(),
pageSize: z.number().optional(),
from: z.string().optional(),
to: z.string().optional(),
})
.passthrough()
.catch({});
export const Route = createFileRoute("/")({
validateSearch: dashboardSearchSchema.parse,
beforeLoad: async () => {
await requireAuthIfEnabled();
},
component: Index,
validateSearch: dashboardSearchSchema.parse,
beforeLoad: async () => {
await requireAuthIfEnabled();
},
component: Index,
});
function Index() {
return (
<main className="container mx-auto px-4 py-8">
<ContainersDashboard />
</main>
);
return (
<main className="container mx-auto px-4 py-8">
<ContainersDashboard />
</main>
);
}

View file

@ -0,0 +1,19 @@
import { createFileRoute } from "@tanstack/react-router";
import { SBOMHistoryPage } from "@/features/scanner/components/sbom-history-page";
import { requireAuthIfEnabled } from "@/lib/auth-guard";
export const Route = createFileRoute("/sbom-history/")({
beforeLoad: async () => {
await requireAuthIfEnabled();
},
component: SBOMHistoryRoute,
});
function SBOMHistoryRoute() {
return (
<main className="container mx-auto px-4 py-8">
<SBOMHistoryPage />
</main>
);
}

View file

@ -0,0 +1,19 @@
import { createFileRoute } from "@tanstack/react-router";
import { ScanHistoryPage } from "@/features/scanner/components/scan-history-page";
import { requireAuthIfEnabled } from "@/lib/auth-guard";
export const Route = createFileRoute("/scan-history/")({
beforeLoad: async () => {
await requireAuthIfEnabled();
},
component: ScanHistoryRoute,
});
function ScanHistoryRoute() {
return (
<main className="container mx-auto px-4 py-8">
<ScanHistoryPage />
</main>
);
}

View file

@ -0,0 +1,55 @@
import { expect } from "vitest";
type AsymmetricMatcher = {
asymmetricMatch: (actual: unknown) => boolean;
};
function isAsymmetricMatcher(value: unknown): value is AsymmetricMatcher {
return Boolean(
value && typeof value === "object" && "asymmetricMatch" in value && typeof (value as AsymmetricMatcher).asymmetricMatch === "function"
);
}
function matchesAttributeValue(actual: string | null, expected: unknown): boolean {
if (expected === undefined) return actual !== null;
if (typeof expected === "string") return actual === expected;
if (expected instanceof RegExp) return actual !== null && expected.test(actual);
if (isAsymmetricMatcher(expected)) return expected.asymmetricMatch(actual);
return actual === String(expected);
}
expect.extend({
toBeInTheDocument(received: Element | null) {
const pass = Boolean(received && received.ownerDocument?.contains(received));
return {
pass,
message: () =>
pass
? "expected element not to be present in the document"
: "expected element to be present in the document",
};
},
toHaveAttribute(received: Element | null, name: string, expected?: unknown) {
const actual = received?.getAttribute(name) ?? null;
const pass = matchesAttributeValue(actual, expected);
return {
pass,
message: () =>
pass
? `expected element not to have attribute ${name}`
: `expected element to have attribute ${name}`,
};
},
});
declare module "vitest" {
interface Assertion<T = any> {
toBeInTheDocument(): T;
toHaveAttribute(name: string, expected?: unknown): T;
}
interface AsymmetricMatchersContaining {
toBeInTheDocument(): void;
toHaveAttribute(name: string, expected?: unknown): void;
}
}

15
frontend/vitest.config.ts Normal file
View file

@ -0,0 +1,15 @@
import { resolve } from "node:path";
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./src/test/vitest-setup.ts"],
},
resolve: {
alias: {
"@": resolve(__dirname, "./src"),
},
},
});

View file

@ -3,13 +3,19 @@ package main
import (
"log"
"net/http"
"os"
"time"
"github.com/hhftechnology/vps-monitor/internal/alerts"
"github.com/hhftechnology/vps-monitor/internal/api"
"github.com/hhftechnology/vps-monitor/internal/auth"
"github.com/hhftechnology/vps-monitor/internal/bot"
"github.com/hhftechnology/vps-monitor/internal/config"
"github.com/hhftechnology/vps-monitor/internal/containerstats"
"github.com/hhftechnology/vps-monitor/internal/coolify"
"github.com/hhftechnology/vps-monitor/internal/docker"
"github.com/hhftechnology/vps-monitor/internal/models"
"github.com/hhftechnology/vps-monitor/internal/scanner"
"github.com/hhftechnology/vps-monitor/internal/services"
"github.com/hhftechnology/vps-monitor/internal/system"
)
@ -17,6 +23,8 @@ import (
func main() {
system.Init()
const containerStatsRetention = 30 * 24 * time.Hour
manager := config.NewManager()
cfg := manager.Config()
@ -30,7 +38,7 @@ func main() {
if err != nil {
log.Fatalf("Failed to initialize auth service: %v\nPlease ensure ALL auth environment variables are set: JWT_SECRET, ADMIN_USERNAME, and ADMIN_PASSWORD.", err)
}
if authService != nil && authService.IsDisabled() {
if authService == nil || authService.IsDisabled() {
fc := manager.FileConfigSnapshot()
if fc.Auth != nil && fc.Auth.Enabled {
authService = auth.NewServiceFromFileConfig(fc.Auth)
@ -62,10 +70,27 @@ func main() {
log.Println("Coolify integration is DISABLED")
}
// Alert monitor (vps-monitor specific)
// Scanner database
dbPath := "/data/scanner.db"
if v := os.Getenv("SCANNER_DB_PATH"); v != "" {
dbPath = v
}
scanDB, err := scanner.NewScanDB(dbPath)
if err != nil {
log.Fatalf("Failed to open scan database: %v", err)
}
defer scanDB.Close()
log.Printf("Scan database opened at %s", dbPath)
// Alert monitor / stats collection
// alertMonitor starts nil and is injected after creation when alerts are enabled.
var alertMonitor *alerts.Monitor
registry := services.NewRegistry(multiHostClient, coolifyClient, authService, cfg, alertMonitor)
var statsCollector *containerstats.Collector
if cfg.Alerts.Enabled {
alertMonitor = alerts.NewMonitor(multiHostClient, &cfg.Alerts)
alertMonitor = alerts.NewMonitor(multiHostClient, &cfg.Alerts, scanDB, containerStatsRetention)
registry.SwapAlerts(alertMonitor)
alertMonitor.Start()
defer alertMonitor.Stop()
log.Println("Alert monitoring is ENABLED")
@ -75,11 +100,38 @@ func main() {
log.Println(" Webhook notifications are ENABLED")
}
} else {
statsCollector = containerstats.NewCollector(registry, scanDB, cfg.Stats.SampleInterval, containerStatsRetention)
statsCollector.Start()
defer statsCollector.Stop()
log.Println("Alert monitoring is DISABLED")
log.Println(" Background container stats collection remains ENABLED")
log.Println(" To enable alerts, set: ALERTS_ENABLED=true")
}
registry := services.NewRegistry(multiHostClient, coolifyClient, authService, cfg, alertMonitor)
telegramBot := bot.NewService(registry, cfg.Bot)
telegramBot.Start()
defer telegramBot.Stop()
// Build initial scanner config from env, then load/merge with DB settings
envScannerCfg := configToScannerConfig(cfg.Scanner)
if err := scanDB.MigrateFromFileConfig(envScannerCfg); err != nil {
log.Printf("Warning: failed to migrate scanner config to DB: %v", err)
}
scannerCfg := scanDB.LoadScannerSettings(envScannerCfg)
// Scanner service
scannerService := scanner.NewScannerService(registry, scannerCfg, scanDB)
log.Printf("Vulnerability scanner ready (default: %s)", scannerCfg.DefaultScanner)
// Auto-scanner
autoScanner := scanner.NewAutoScanner(registry, scannerService, scanDB)
if scannerCfg.AutoScan.Enabled {
autoScanner.Start()
log.Println("Auto-scan is ENABLED")
} else {
log.Println("Auto-scan is DISABLED")
}
defer autoScanner.Stop()
// Hot-reload callback
manager.OnChange(func(newCfg *config.Config) {
@ -110,11 +162,33 @@ func main() {
registry.SwapAuth(auth.NewServiceFromFileConfig(fc.Auth))
}
// Update scanner configuration from DB (with env overrides)
newEnvCfg := configToScannerConfig(newCfg.Scanner)
newScannerCfg := scanDB.LoadScannerSettings(newEnvCfg)
scannerService.UpdateConfig(newScannerCfg)
// Toggle auto-scanner
if newScannerCfg.AutoScan.Enabled {
autoScanner.SetPollInterval(newScannerCfg.AutoScan.PollInterval)
if !autoScanner.IsEnabled() {
autoScanner.Stop()
autoScanner.Start()
}
} else {
autoScanner.Stop()
}
telegramBot.UpdateConfig(newCfg.Bot)
log.Println("Configuration reloaded successfully")
})
routerOpts := &api.RouterOptions{
AlertMonitor: alertMonitor,
AlertMonitor: alertMonitor,
BotService: telegramBot,
ScanDB: scanDB,
ScannerService: scannerService,
AutoScanner: autoScanner,
}
apiRouter := api.NewRouter(registry, manager, routerOpts)
@ -123,3 +197,31 @@ func main() {
log.Fatalf("Server failed to start: %v", err)
}
}
func configToScannerConfig(sc config.ScannerConfig) *models.ScannerConfig {
return &models.ScannerConfig{
GrypeImage: sc.GrypeImage,
TrivyImage: sc.TrivyImage,
SyftImage: sc.SyftImage,
DefaultScanner: models.ScannerType(sc.DefaultScanner),
GrypeArgs: sc.GrypeArgs,
TrivyArgs: sc.TrivyArgs,
Notifications: models.NotificationConfig{
DiscordWebhookURL: sc.DiscordWebhookURL,
SlackWebhookURL: sc.SlackWebhookURL,
OnScanComplete: sc.NotifyOnComplete,
OnBulkComplete: sc.NotifyOnBulk,
OnNewCVEs: sc.NotifyOnNewCVEs,
MinSeverity: models.SeverityLevel(sc.NotifyMinSeverity),
},
AutoScan: models.AutoScanConfig{
Enabled: sc.AutoScanEnabled,
PollInterval: sc.AutoScanPollInterval,
},
ForceRescan: sc.ForceRescan,
ScanTimeoutMinutes: sc.ScanTimeoutMinutes,
BulkTimeoutMinutes: sc.BulkTimeoutMinutes,
ScannerMemoryMB: sc.ScannerMemoryMB,
ScannerPidsLimit: sc.ScannerPidsLimit,
}
}

View file

@ -1,6 +1,6 @@
module github.com/hhftechnology/vps-monitor
go 1.24.3
go 1.25.0
require (
github.com/docker/cli v29.0.2+incompatible
@ -8,10 +8,12 @@ require (
github.com/go-chi/chi/v5 v5.2.3
github.com/go-chi/cors v1.2.2
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/shirou/gopsutil/v4 v4.25.10
golang.org/x/crypto v0.44.0
modernc.org/sqlite v1.48.1
)
require (
@ -22,20 +24,24 @@ require (
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.9.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/sys/atomicwriter v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/tklauser/go-sysconf v0.3.15 // indirect
github.com/tklauser/numcpus v0.10.0 // indirect
@ -46,7 +52,10 @@ require (
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 // indirect
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/time v0.14.0 // indirect
gotest.tools/v3 v3.5.2 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

View file

@ -23,6 +23,8 @@ github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pM
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.9.0 h1:mh0zpKBIXDceC63hpvPuGLiJ8ZAa3DfrFTudmfi8A4k=
github.com/ebitengine/purego v0.9.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
@ -43,14 +45,22 @@ github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
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/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
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/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
@ -61,6 +71,8 @@ github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
@ -71,6 +83,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/shirou/gopsutil/v4 v4.25.10 h1:at8lk/5T1OgtuCp+AwrDofFRjnvosn0nkN2OLQ6g8tA=
github.com/shirou/gopsutil/v4 v4.25.10/go.mod h1:+kSwyC8DRUD9XXEHCAFjK+0nuArFJM0lva+StQAcskM=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
@ -109,19 +123,26 @@ go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOV
go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE=
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY=
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE=
@ -137,3 +158,31 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.48.1 h1:S85iToyU6cgeojybE2XJlSbcsvcWkQ6qqNXJHtW5hWA=
modernc.org/sqlite v1.48.1/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View file

@ -12,6 +12,7 @@ import (
"github.com/hhftechnology/vps-monitor/internal/config"
"github.com/hhftechnology/vps-monitor/internal/docker"
"github.com/hhftechnology/vps-monitor/internal/models"
"github.com/hhftechnology/vps-monitor/internal/stats"
)
// Monitor handles background monitoring and alerting
@ -20,22 +21,34 @@ type Monitor struct {
dockerMu sync.RWMutex
config *config.AlertConfig
history *AlertHistory
stats *stats.HistoryManager
store statsStore
stopCh chan struct{}
wg sync.WaitGroup
// Track container states for detecting changes
containerStates map[string]string // key: host:containerID, value: state
statesMu sync.RWMutex
statsRetention time.Duration
lastPrune time.Time
}
type statsStore interface {
InsertContainerStat(stat models.ContainerStats) error
PruneContainerStatsOlderThan(cutoff time.Time) error
}
// NewMonitor creates a new alert monitor
func NewMonitor(dockerClient *docker.MultiHostClient, alertConfig *config.AlertConfig) *Monitor {
func NewMonitor(dockerClient *docker.MultiHostClient, alertConfig *config.AlertConfig, store statsStore, statsRetention time.Duration) *Monitor {
return &Monitor{
docker: dockerClient,
config: alertConfig,
history: NewAlertHistory(100), // Keep last 100 alerts
stats: stats.NewHistoryManager(),
store: store,
stopCh: make(chan struct{}),
containerStates: make(map[string]string),
statsRetention: statsRetention,
}
}
@ -53,11 +66,6 @@ func (m *Monitor) getDockerClient() *docker.MultiHostClient {
// Start begins the background monitoring
func (m *Monitor) Start() {
if !m.config.Enabled {
log.Println("Alert monitoring is disabled")
return
}
log.Printf("Starting alert monitor (interval: %s, CPU threshold: %.1f%%, Memory threshold: %.1f%%)",
m.config.CheckInterval, m.config.CPUThreshold, m.config.MemoryThreshold)
@ -77,6 +85,10 @@ func (m *Monitor) GetHistory() *AlertHistory {
return m.history
}
func (m *Monitor) GetStatsHistory() *stats.HistoryManager {
return m.stats
}
// monitorLoop is the main monitoring loop
func (m *Monitor) monitorLoop() {
defer m.wg.Done()
@ -171,6 +183,10 @@ func (m *Monitor) checkContainerStates(ctx context.Context) {
for key := range m.containerStates {
if _, exists := currentContainers[key]; !exists {
delete(m.containerStates, key)
parts := strings.SplitN(key, ":", 2)
if len(parts) == 2 {
m.stats.CleanupContainer(parts[0], parts[1])
}
}
}
}
@ -197,6 +213,12 @@ func (m *Monitor) checkResourceThresholds(ctx context.Context) {
if err != nil {
continue
}
m.stats.RecordStats(hostName, ctr.ID, *stats)
if m.store != nil {
if err := m.store.InsertContainerStat(*stats); err != nil {
log.Printf("Alert monitor: failed to persist stats for %s on %s: %v", ctr.ID, hostName, err)
}
}
containerName := ctr.ID[:12]
if len(ctr.Names) > 0 {
@ -234,10 +256,22 @@ func (m *Monitor) checkResourceThresholds(ctx context.Context) {
}
}
}
if m.store != nil && m.statsRetention > 0 && (m.lastPrune.IsZero() || time.Since(m.lastPrune) >= time.Hour) {
if err := m.store.PruneContainerStatsOlderThan(time.Now().Add(-m.statsRetention)); err != nil {
log.Printf("Alert monitor: failed to prune persisted stats: %v", err)
} else {
m.lastPrune = time.Now()
}
}
}
// triggerAlert handles a new alert
func (m *Monitor) triggerAlert(alert models.Alert) {
if !m.config.Enabled {
return
}
log.Printf("Alert: %s - %s", alert.Type, alert.Message)
// Add to history
@ -245,6 +279,10 @@ func (m *Monitor) triggerAlert(alert models.Alert) {
// Send webhook
if m.config.WebhookURL != "" {
if m.config.AlertsFilter == "critical" && !isCriticalAlert(alert) {
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@ -255,3 +293,7 @@ func (m *Monitor) triggerAlert(alert models.Alert) {
}()
}
}
func isCriticalAlert(alert models.Alert) bool {
return alert.Type == models.AlertCPUThreshold || alert.Type == models.AlertMemoryThreshold
}

View file

@ -0,0 +1,19 @@
package alerts
import (
"testing"
"github.com/hhftechnology/vps-monitor/internal/models"
)
func TestIsCriticalAlertMatchesThresholdAlertsOnly(t *testing.T) {
if !isCriticalAlert(models.Alert{Type: models.AlertCPUThreshold}) {
t.Fatal("expected CPU threshold alerts to be critical")
}
if !isCriticalAlert(models.Alert{Type: models.AlertMemoryThreshold}) {
t.Fatal("expected memory threshold alerts to be critical")
}
if isCriticalAlert(models.Alert{Type: models.AlertContainerStopped}) {
t.Fatal("expected container stopped alerts to be excluded from critical-only filtering")
}
}

View file

@ -0,0 +1,71 @@
package api
import (
"encoding/json"
"errors"
"log"
"net/http"
"strings"
"github.com/hhftechnology/vps-monitor/internal/bot"
"github.com/hhftechnology/vps-monitor/internal/config"
)
func (ar *APIRouter) RelayBotCommand(w http.ResponseWriter, r *http.Request) {
if ar.botService == nil {
http.Error(w, "bot service unavailable", http.StatusServiceUnavailable)
return
}
if svc := ar.registry.Auth(); svc == nil || svc.IsDisabled() {
http.Error(w, "auth must be enabled before using bot relay mode", http.StatusConflict)
return
}
if ar.registry.Config().Bot.Mode != config.BotModeJWTRelay {
http.Error(w, "bot relay mode is disabled", http.StatusConflict)
return
}
var req struct {
Text string `json:"text"`
ChatID string `json:"chatId"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
req.Text = strings.TrimSpace(req.Text)
if req.Text == "" {
http.Error(w, "text is required", http.StatusBadRequest)
return
}
reply, err := ar.botService.RelayCommand(r.Context(), req.ChatID, req.Text)
if err != nil {
status, message := relayBotCommandErrorResponse(err)
log.Printf("bot relay command failed: %v", err)
http.Error(w, message, status)
return
}
WriteJsonResponse(w, http.StatusOK, map[string]any{
"message": "Command relayed",
"reply": reply,
})
}
func relayBotCommandErrorResponse(err error) (int, string) {
switch {
case errors.Is(err, bot.ErrRelayDisabled):
return http.StatusConflict, "bot relay mode is disabled"
case errors.Is(err, bot.ErrRelayNotConfigured):
return http.StatusConflict, "bot relay is not configured"
case errors.Is(err, bot.ErrRelayChatNotAllowed):
return http.StatusForbidden, "chat id is not allowed"
case errors.Is(err, bot.ErrTelegramSendFailed):
return http.StatusBadGateway, "failed to send Telegram message"
default:
return http.StatusBadRequest, "invalid bot relay command"
}
}

View file

@ -0,0 +1,180 @@
package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/hhftechnology/vps-monitor/internal/auth"
"github.com/hhftechnology/vps-monitor/internal/bot"
"github.com/hhftechnology/vps-monitor/internal/config"
"github.com/hhftechnology/vps-monitor/internal/services"
)
type fakeBotRelayService struct {
reply string
err error
gotText string
gotChat string
callCount int
}
func (f *fakeBotRelayService) RelayCommand(_ context.Context, chatID, text string) (string, error) {
f.callCount++
f.gotChat = chatID
f.gotText = text
return f.reply, f.err
}
func TestRelayBotCommandRejectsWhenAuthDisabled(t *testing.T) {
router := &APIRouter{
registry: services.NewRegistry(nil, nil, auth.NewDisabledService(), &config.Config{
Bot: config.BotConfig{Enabled: true, Mode: config.BotModeJWTRelay},
}, nil),
botService: &fakeBotRelayService{},
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/bot/relay/command", strings.NewReader(`{"text":"/help"}`))
rec := httptest.NewRecorder()
router.RelayBotCommand(rec, req)
if rec.Code != http.StatusConflict {
t.Fatalf("expected %d, got %d", http.StatusConflict, rec.Code)
}
}
func TestRelayBotCommandReturnsReply(t *testing.T) {
relay := &fakeBotRelayService{reply: "ok"}
router := &APIRouter{
registry: services.NewRegistry(nil, nil, newUsableAuthService(t), &config.Config{
Bot: config.BotConfig{Enabled: true, Mode: config.BotModeJWTRelay},
}, nil),
botService: relay,
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/bot/relay/command", strings.NewReader(`{"text":" /help ","chatId":"123"}`))
rec := httptest.NewRecorder()
router.RelayBotCommand(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected %d, got %d", http.StatusOK, rec.Code)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Fatalf("expected application/json content type, got %q", ct)
}
var body struct {
Message string `json:"message"`
Reply string `json:"reply"`
}
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode response: %v", err)
}
if body.Message != "Command relayed" || body.Reply != "ok" {
t.Fatalf("unexpected response body: %+v", body)
}
if relay.gotText != "/help" || relay.gotChat != "123" {
t.Fatalf("expected trimmed command and chat id, got text=%q chat=%q", relay.gotText, relay.gotChat)
}
}
func TestRelayBotCommandRejectsNilBotService(t *testing.T) {
router := &APIRouter{}
req := httptest.NewRequest(http.MethodPost, "/api/v1/bot/relay/command", strings.NewReader(`{"text":"/help"}`))
rec := httptest.NewRecorder()
router.RelayBotCommand(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("expected %d, got %d", http.StatusServiceUnavailable, rec.Code)
}
}
func TestRelayBotCommandRejectsWhenModeIsNotRelay(t *testing.T) {
router := &APIRouter{
registry: services.NewRegistry(nil, nil, newUsableAuthService(t), &config.Config{
Bot: config.BotConfig{Enabled: true, Mode: config.BotModePolling},
}, nil),
botService: &fakeBotRelayService{},
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/bot/relay/command", strings.NewReader(`{"text":"/help"}`))
rec := httptest.NewRecorder()
router.RelayBotCommand(rec, req)
if rec.Code != http.StatusConflict {
t.Fatalf("expected %d, got %d", http.StatusConflict, rec.Code)
}
}
func TestRelayBotCommandRejectsInvalidRequestBody(t *testing.T) {
router := newRelayBotCommandTestRouter(t, &fakeBotRelayService{})
for _, body := range []string{`{`, `{"text":""}`, `{"text":" "}`} {
req := httptest.NewRequest(http.MethodPost, "/api/v1/bot/relay/command", strings.NewReader(body))
rec := httptest.NewRecorder()
router.RelayBotCommand(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected %d for body %q, got %d", http.StatusBadRequest, body, rec.Code)
}
}
}
func TestRelayBotCommandMapsRelayErrors(t *testing.T) {
tests := []struct {
name string
err error
want int
body string
}{
{name: "unknown", err: errors.New("internal detail"), want: http.StatusBadRequest, body: "invalid bot relay command"},
{name: "disabled", err: bot.ErrRelayDisabled, want: http.StatusConflict, body: "bot relay mode is disabled"},
{name: "not configured", err: bot.ErrRelayNotConfigured, want: http.StatusConflict, body: "bot relay is not configured"},
{name: "chat not allowed", err: bot.ErrRelayChatNotAllowed, want: http.StatusForbidden, body: "chat id is not allowed"},
{name: "send failed", err: bot.ErrTelegramSendFailed, want: http.StatusBadGateway, body: "failed to send Telegram message"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
router := newRelayBotCommandTestRouter(t, &fakeBotRelayService{err: tt.err})
req := httptest.NewRequest(http.MethodPost, "/api/v1/bot/relay/command", strings.NewReader(`{"text":"/help"}`))
rec := httptest.NewRecorder()
router.RelayBotCommand(rec, req)
if rec.Code != tt.want {
t.Fatalf("expected %d, got %d", tt.want, rec.Code)
}
if !strings.Contains(rec.Body.String(), tt.body) {
t.Fatalf("expected sanitized body %q, got %q", tt.body, rec.Body.String())
}
})
}
}
func newRelayBotCommandTestRouter(t *testing.T, relay *fakeBotRelayService) *APIRouter {
t.Helper()
return &APIRouter{
registry: services.NewRegistry(nil, nil, newUsableAuthService(t), &config.Config{
Bot: config.BotConfig{Enabled: true, Mode: config.BotModeJWTRelay},
}, nil),
botService: relay,
}
}
func newUsableAuthService(t *testing.T) *auth.Service {
t.Helper()
hash, err := auth.HashPassword("secret")
if err != nil {
t.Fatalf("HashPassword() error = %v", err)
}
return auth.NewServiceFromFileConfig(&config.FileAuthConfig{
Enabled: true,
JWTSecret: "jwt-secret",
AdminUsername: "admin",
AdminPasswordHash: hash,
})
}

View file

@ -0,0 +1,145 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/hhftechnology/vps-monitor/internal/config"
"github.com/hhftechnology/vps-monitor/internal/models"
"github.com/hhftechnology/vps-monitor/internal/scanner"
"github.com/hhftechnology/vps-monitor/internal/services"
)
func TestGetContainerHistoricalStatsReturnsPersistedDataWithoutAlerts(t *testing.T) {
db := newTestAPIScanDB(t)
now := time.Now().UTC()
for _, sample := range []models.ContainerStats{
{
ContainerID: "container-1",
Host: "host-a",
CPUPercent: 12,
MemoryPercent: 34,
MemoryUsage: 512,
MemoryLimit: 1024,
Timestamp: now.Add(-30 * time.Minute).Unix(),
},
{
ContainerID: "container-1",
Host: "host-a",
CPUPercent: 20,
MemoryPercent: 40,
MemoryUsage: 768,
MemoryLimit: 1024,
Timestamp: now.Add(-15 * time.Minute).Unix(),
},
} {
if err := db.InsertContainerStat(sample); err != nil {
t.Fatalf("InsertContainerStat() error = %v", err)
}
}
router := &APIRouter{
registry: services.NewRegistry(nil, nil, nil, &config.Config{}, nil),
statsDB: db,
}
req := httptest.NewRequest(http.MethodGet, "/api/v1/containers/container-1/stats/history?host=host-a", nil)
req = withURLParam(req, "id", "container-1")
rec := httptest.NewRecorder()
router.GetContainerHistoricalStats(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
}
var history models.HistoricalAverages
if err := json.Unmarshal(rec.Body.Bytes(), &history); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if !history.HasData {
t.Fatal("expected historical data")
}
if len(history.Samples) != 2 {
t.Fatalf("expected 2 bootstrap samples, got %d", len(history.Samples))
}
}
func TestGetContainerHistoricalStatsValidatesHostBeforeStatsDB(t *testing.T) {
router := &APIRouter{
registry: services.NewRegistry(nil, nil, nil, &config.Config{}, nil),
}
req := httptest.NewRequest(http.MethodGet, "/api/v1/containers/container-1/stats/history", nil)
req = withURLParam(req, "id", "container-1")
rec := httptest.NewRecorder()
router.GetContainerHistoricalStats(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected %d, got %d: %s", http.StatusBadRequest, rec.Code, rec.Body.String())
}
if rec.Body.String() != "host parameter is required\n" {
t.Fatalf("unexpected body: %q", rec.Body.String())
}
}
func TestEnrichContainersWithHistoricalStatsUsesDatabase(t *testing.T) {
db := newTestAPIScanDB(t)
now := time.Now().UTC()
if err := db.InsertContainerStat(models.ContainerStats{
ContainerID: "container-1",
Host: "host-a",
CPUPercent: 18,
MemoryPercent: 42,
Timestamp: now.Add(-20 * time.Minute).Unix(),
}); err != nil {
t.Fatalf("InsertContainerStat() error = %v", err)
}
router := &APIRouter{statsDB: db}
containers := []models.ContainerInfo{
{
ID: "container-1",
Host: "host-a",
Names: []string{"/api"},
},
}
router.enrichContainersWithHistoricalStats(containers)
if containers[0].HistoricalStats == nil {
t.Fatal("expected historical stats to be attached")
}
if containers[0].HistoricalStats.CPU1h != 18 || containers[0].HistoricalStats.Memory1h != 42 {
t.Fatalf("unexpected historical stats: %+v", containers[0].HistoricalStats)
}
}
func newTestAPIScanDB(t *testing.T) *scanner.ScanDB {
t.Helper()
db, err := scanner.NewScanDB(t.TempDir() + "/scan.db")
if err != nil {
t.Fatalf("NewScanDB() error = %v", err)
}
t.Cleanup(func() {
_ = db.Close()
})
return db
}
func withURLParam(req *http.Request, key, value string) *http.Request {
routeContext := chi.NewRouteContext()
routeContext.URLParams.Add(key, value)
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeContext))
}

View file

@ -6,6 +6,7 @@ import (
"fmt"
"log"
"net/http"
"reflect"
"regexp"
"strconv"
"strings"
@ -15,11 +16,112 @@ import (
"github.com/hhftechnology/vps-monitor/internal/coolify"
"github.com/hhftechnology/vps-monitor/internal/models"
"github.com/hhftechnology/vps-monitor/internal/system"
"github.com/hhftechnology/vps-monitor/internal/docker"
"sync"
)
// Pre-compiled regex for validating environment variable keys (performance optimization)
var envKeyRegex = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
const containerStatsBootstrapLimit = 60
type ContainerActionJob struct {
Host string
ID string
Action string
Status string
Error string
ExpiresAt time.Time
}
var (
actionJobsMu sync.RWMutex
actionJobs = make(map[string]*ContainerActionJob)
)
func RecordActionJob(host, id, action, status, errStr string) {
actionJobsMu.Lock()
defer actionJobsMu.Unlock()
key := host + ":" + id + ":" + action
actionJobs[key] = &ContainerActionJob{
Host: host,
ID: id,
Action: action,
Status: status,
Error: errStr,
ExpiresAt: time.Now().Add(5 * time.Minute),
}
for k, v := range actionJobs {
if time.Now().After(v.ExpiresAt) {
delete(actionJobs, k)
}
}
}
func GetActionJob(host, id, action string) *ContainerActionJob {
actionJobsMu.RLock()
defer actionJobsMu.RUnlock()
return actionJobs[host+":"+id+":"+action]
}
type coolifyEnvSyncer interface {
SyncEnvVars(ctx context.Context, resource *coolify.ResourceInfo, envVars map[string]string) error
}
func isNilCoolifySyncer(syncer coolifyEnvSyncer) bool {
if syncer == nil {
return true
}
value := reflect.ValueOf(syncer)
switch value.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
return value.IsNil()
default:
return false
}
}
func (ar *APIRouter) getContainerHistoricalAverages(host, containerID string) (models.HistoricalAverages, error) {
if ar.statsDB == nil {
return models.HistoricalAverages{}, fmt.Errorf("stats database not available")
}
return ar.statsDB.GetContainerHistoricalAverages(host, containerID, time.Now())
}
func (ar *APIRouter) getContainerHistoricalSamples(host, containerID string) ([]models.ContainerStats, error) {
if ar.statsDB == nil {
return nil, fmt.Errorf("stats database not available")
}
return ar.statsDB.GetRecentContainerStats(
host,
containerID,
time.Now().Add(-12*time.Hour),
containerStatsBootstrapLimit,
)
}
func (ar *APIRouter) enrichContainersWithHistoricalStats(containers []models.ContainerInfo) {
if ar.statsDB == nil {
return
}
for i := range containers {
history, err := ar.getContainerHistoricalAverages(containers[i].Host, containers[i].ID)
if err != nil {
log.Printf("failed to load container history for %s on %s: %v", containers[i].ID, containers[i].Host, err)
continue
}
if history.HasData {
containers[i].HistoricalStats = &models.HistoricalStats{
CPU1h: history.CPU1h,
Memory1h: history.Memory1h,
CPU12h: history.CPU12h,
Memory12h: history.Memory12h,
}
}
}
}
func (ar *APIRouter) GetSystemStats(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
stats, err := system.GetStats(ctx)
@ -41,8 +143,8 @@ func (ar *APIRouter) GetContainers(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
dockerClient, releaseDocker := ar.registry.AcquireDocker()
defer releaseDocker()
if dockerClient == nil {
releaseDocker()
http.Error(w, "docker client unavailable", http.StatusServiceUnavailable)
return
}
@ -59,6 +161,8 @@ func (ar *APIRouter) GetContainers(w http.ResponseWriter, r *http.Request) {
allContainers = append(allContainers, containers...)
}
ar.enrichContainersWithHistoricalStats(allContainers)
// Build host errors list for the frontend (graceful partial results)
hostErrorMessages := make([]map[string]string, 0, len(hostErrors))
for _, he := range hostErrors {
@ -196,13 +300,13 @@ func (ar *APIRouter) StopContainer(w http.ResponseWriter, r *http.Request) {
return
}
err := dockerClient.StopContainer(r.Context(), host, id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
WriteJsonResponse(w, http.StatusOK, map[string]any{
"message": "Container stopped",
WriteJsonResponse(w, http.StatusAccepted, map[string]any{
"message": "Container stop initiated",
"status": "pending",
})
ar.runAsyncContainerAction(host, id, "stop", func(ctx context.Context, client *docker.MultiHostClient) error {
return client.StopContainer(ctx, host, id)
})
}
@ -216,19 +320,19 @@ func (ar *APIRouter) RestartContainer(w http.ResponseWriter, r *http.Request) {
}
dockerClient, releaseDocker := ar.registry.AcquireDocker()
defer releaseDocker()
if dockerClient == nil {
releaseDocker()
http.Error(w, "docker client unavailable", http.StatusServiceUnavailable)
return
}
err := dockerClient.RestartContainer(r.Context(), host, id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
WriteJsonResponse(w, http.StatusOK, map[string]any{
"message": "Container restarted",
WriteJsonResponse(w, http.StatusAccepted, map[string]any{
"message": "Container restart initiated",
"status": "pending",
})
ar.runAsyncContainerAction(host, id, "restart", func(ctx context.Context, client *docker.MultiHostClient) error {
return client.RestartContainer(ctx, host, id)
})
}
@ -242,20 +346,74 @@ func (ar *APIRouter) RemoveContainer(w http.ResponseWriter, r *http.Request) {
}
dockerClient, releaseDocker := ar.registry.AcquireDocker()
defer releaseDocker()
if dockerClient == nil {
releaseDocker()
http.Error(w, "docker client unavailable", http.StatusServiceUnavailable)
return
}
err := dockerClient.RemoveContainer(r.Context(), host, id)
WriteJsonResponse(w, http.StatusAccepted, map[string]any{
"message": "Container remove initiated",
"status": "pending",
})
ar.runAsyncContainerAction(host, id, "remove", func(ctx context.Context, client *docker.MultiHostClient) error {
return client.RemoveContainer(ctx, host, id)
})
}
func (ar *APIRouter) GetContainerHistoricalStats(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
host := r.URL.Query().Get("host")
if host == "" {
http.Error(w, "host parameter is required", http.StatusBadRequest)
return
}
if ar.statsDB == nil {
http.Error(w, "stats history not available", http.StatusServiceUnavailable)
return
}
history, err := ar.getContainerHistoricalAverages(host, id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
WriteJsonResponse(w, http.StatusOK, map[string]any{
"message": "Container removed",
})
samples, err := ar.getContainerHistoricalSamples(host, id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
history.Samples = samples
WriteJsonResponse(w, http.StatusOK, history)
}
func (ar *APIRouter) runAsyncContainerAction(host, id, action string, fn func(context.Context, *docker.MultiHostClient) error) {
RecordActionJob(host, id, action, "pending", "")
go func() {
dockerClient, release := ar.registry.AcquireDocker()
defer release()
if dockerClient == nil {
log.Printf("failed to %s container %s on host %s: docker client unavailable", action, id, host)
RecordActionJob(host, id, action, "failed", "docker client unavailable")
return
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
if err := fn(ctx, dockerClient); err != nil {
log.Printf("failed to %s container %s on host %s: %v", action, id, host, err)
RecordActionJob(host, id, action, "failed", err.Error())
} else {
RecordActionJob(host, id, action, "success", "")
}
}()
}
func (ar *APIRouter) GetContainerLogsParsed(w http.ResponseWriter, r *http.Request) {
@ -295,12 +453,11 @@ func (ar *APIRouter) GetContainerLogsParsed(w http.ResponseWriter, r *http.Reque
func (ar *APIRouter) streamParsedLogs(w http.ResponseWriter, host, id string, options models.LogOptions) {
dockerClient, releaseDocker := ar.registry.AcquireDocker()
defer releaseDocker()
if dockerClient == nil {
releaseDocker()
http.Error(w, "docker client unavailable", http.StatusServiceUnavailable)
return
}
defer releaseDocker()
stream, err := dockerClient.StreamContainerLogsParsed(host, id, options)
if err != nil {
@ -445,20 +602,35 @@ func (ar *APIRouter) UpdateEnvVariables(w http.ResponseWriter, r *http.Request)
coolifyMulti := ar.registry.Coolify()
if coolifyMulti != nil {
coolifyClient := coolifyMulti.GetClient(host)
coolifyResource := coolify.ExtractResourceInfo(labels)
if coolifyClient != nil && coolifyResource != nil {
syncCtx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
if syncErr := coolifyClient.SyncEnvVars(syncCtx, coolifyResource, envVariables.Env); syncErr != nil {
log.Printf("Warning: failed to sync env vars to Coolify for host %s: %v", host, syncErr)
response["coolify_synced"] = false
response["coolify_error"] = syncErr.Error()
} else {
response["coolify_synced"] = true
}
if isNilCoolifySyncer(coolifyClient) {
log.Printf("Warning: Coolify client unavailable for host %s; skipping env sync", host)
} else {
coolifyResource := coolify.ExtractResourceInfo(labels)
applyCoolifyEnvSync(r.Context(), host, coolifyClient, coolifyResource, envVariables.Env, response)
}
}
WriteJsonResponse(w, http.StatusOK, response)
}
func applyCoolifyEnvSync(ctx context.Context, host string, syncer coolifyEnvSyncer, resource *coolify.ResourceInfo, env map[string]string, response map[string]any) {
if isNilCoolifySyncer(syncer) || resource == nil {
return
}
if resource.Type == coolify.ResourceTypeDatabase {
response["coolify_synced"] = false
response["coolify_error"] = "sync not supported for database resources"
return
}
syncCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if syncErr := syncer.SyncEnvVars(syncCtx, resource, env); syncErr != nil {
log.Printf("Warning: failed to sync env vars to Coolify for host %s: %v", host, syncErr)
response["coolify_synced"] = false
response["coolify_error"] = syncErr.Error()
return
}
response["coolify_synced"] = true
}

View file

@ -2,6 +2,7 @@ package api
import (
"bytes"
"context"
"encoding/json"
"log"
"net/http"
@ -14,10 +15,15 @@ import (
"github.com/hhftechnology/vps-monitor/internal/auth"
"github.com/hhftechnology/vps-monitor/internal/config"
"github.com/hhftechnology/vps-monitor/internal/models"
"github.com/hhftechnology/vps-monitor/internal/scanner"
"github.com/hhftechnology/vps-monitor/internal/services"
"github.com/hhftechnology/vps-monitor/internal/static"
)
type botRelayService interface {
RelayCommand(ctx context.Context, chatID, text string) (string, error)
}
// Buffer pool for JSON encoding to reduce allocations
var jsonBufferPool = sync.Pool{
New: func() interface{} {
@ -30,11 +36,18 @@ type APIRouter struct {
registry *services.Registry
manager *config.Manager
alertHandlers *AlertHandlers
scanHandlers *ScanHandlers
botService botRelayService
statsDB *scanner.ScanDB
}
// RouterOptions contains optional dependencies for the router
type RouterOptions struct {
AlertMonitor *alerts.Monitor
AlertMonitor *alerts.Monitor
ScannerService *scanner.ScannerService
AutoScanner *scanner.AutoScanner
BotService botRelayService
ScanDB *scanner.ScanDB
}
func NewRouter(registry *services.Registry, manager *config.Manager, opts *RouterOptions) *chi.Mux {
@ -45,6 +58,23 @@ func NewRouter(registry *services.Registry, manager *config.Manager, opts *Route
registry: registry,
manager: manager,
}
if opts != nil {
r.botService = opts.BotService
r.statsDB = opts.ScanDB
if r.statsDB == nil && opts.ScannerService != nil {
r.statsDB = opts.ScannerService.Store().DB()
}
}
// Set up scan handlers
if opts != nil && opts.ScannerService != nil {
r.scanHandlers = NewScanHandlers(opts.ScannerService, manager)
if opts.AutoScanner != nil {
r.scanHandlers.SetAutoScanner(opts.AutoScanner)
}
} else {
r.scanHandlers = nil
}
// Set up alert handlers
if opts != nil && opts.AlertMonitor != nil {
@ -54,6 +84,7 @@ func NewRouter(registry *services.Registry, manager *config.Manager, opts *Route
MemoryThreshold: cfg.Alerts.MemoryThreshold,
CheckInterval: cfg.Alerts.CheckInterval.String(),
WebhookEnabled: cfg.Alerts.WebhookURL != "",
AlertsFilter: cfg.Alerts.AlertsFilter,
})
} else {
r.alertHandlers = NewAlertHandlers(nil, &models.AlertConfigResponse{
@ -62,6 +93,7 @@ func NewRouter(registry *services.Registry, manager *config.Manager, opts *Route
MemoryThreshold: cfg.Alerts.MemoryThreshold,
CheckInterval: cfg.Alerts.CheckInterval.String(),
WebhookEnabled: cfg.Alerts.WebhookURL != "",
AlertsFilter: cfg.Alerts.AlertsFilter,
})
}
@ -114,6 +146,8 @@ func (ar *APIRouter) Routes() *chi.Mux {
ar.registerImageRoutes(protected)
ar.registerNetworkRoutes(protected)
ar.registerAlertRoutes(protected)
ar.registerBotRoutes(protected)
ar.registerScanRoutes(protected)
})
})
@ -139,6 +173,7 @@ func (ar *APIRouter) registerContainerRoutes(r chi.Router) {
r.Get("/env", ar.GetEnvVariables)
r.Get("/stats", ar.HandleContainerStats)
r.Get("/stats/once", ar.GetContainerStatsOnce)
r.Get("/stats/history", ar.GetContainerHistoricalStats)
// Mutating routes (blocked in read-only mode)
r.Group(func(mutating chi.Router) {
@ -190,17 +225,82 @@ func (ar *APIRouter) registerAlertRoutes(r chi.Router) {
r.Post("/alerts/acknowledge-all", ar.alertHandlers.AcknowledgeAllAlerts)
}
func (ar *APIRouter) registerBotRoutes(r chi.Router) {
if ar.botService == nil {
return
}
r.Post("/bot/relay/command", ar.RelayBotCommand)
}
func (ar *APIRouter) registerScanRoutes(r chi.Router) {
if ar.scanHandlers == nil {
return
}
// Read-only routes
r.Get("/scan/jobs", ar.scanHandlers.GetScanJobs)
r.Get("/scan/jobs/{id}", ar.scanHandlers.GetScanJob)
r.Get("/scan/results", ar.scanHandlers.GetScanResults)
r.Get("/scan/results/latest", ar.scanHandlers.GetLatestScanResult)
r.Get("/scan/sbom/{id}", ar.scanHandlers.GetSBOMJob)
// History routes
r.Get("/scan/history", ar.scanHandlers.GetScanHistory)
r.Get("/scan/history/images", ar.scanHandlers.GetScannedImages)
r.Get("/scan/history/{id}", ar.scanHandlers.GetScanHistoryDetail)
r.Get("/scan/history/{id}/export", ar.scanHandlers.ExportScanHistory)
r.Get("/scan/sbom/history", ar.scanHandlers.GetSBOMHistory)
r.Get("/scan/sbom/history/images", ar.scanHandlers.GetSBOMedImages)
r.Get("/scan/sbom/history/{id}", ar.scanHandlers.GetSBOMHistoryDetail)
r.Get("/scan/sbom/history/{id}/download", ar.scanHandlers.DownloadSBOMHistory)
// Auto-scan status
r.Get("/scan/autoscan/status", ar.scanHandlers.GetAutoScanStatus)
// Mutating routes (blocked in read-only mode)
r.Group(func(mutating chi.Router) {
mutating.Use(middleware.ReadOnly(func() bool {
return ar.registry.Config().ReadOnly
}))
mutating.Post("/scan", ar.scanHandlers.StartScan)
mutating.Post("/scan/bulk", ar.scanHandlers.StartBulkScan)
mutating.Delete("/scan/jobs/{id}", ar.scanHandlers.CancelScanJob)
mutating.Delete("/scan/history/{id}", ar.scanHandlers.DeleteScanHistory)
mutating.Delete("/scan/sbom/history/{id}", ar.scanHandlers.DeleteSBOMHistory)
mutating.Post("/scan/sbom", ar.scanHandlers.StartSBOMGeneration)
})
}
func (ar *APIRouter) registerSettingsRoutes(r chi.Router) {
r.Route("/settings", func(r chi.Router) {
r.Use(auth.DynamicMiddleware(ar.registry.Auth))
r.Get("/", ar.GetSettings)
r.Put("/docker-hosts", ar.UpdateDockerHosts)
r.Put("/coolify-hosts", ar.UpdateCoolifyHosts)
r.Put("/read-only", ar.UpdateReadOnly)
r.Put("/auth", ar.UpdateAuth)
r.Post("/test/docker-host", ar.TestDockerHost)
r.Post("/test/coolify-host", ar.TestCoolifyHost)
r.Group(func(mutating chi.Router) {
mutating.Use(middleware.ReadOnly(func() bool {
return ar.registry.Config().ReadOnly
}))
mutating.Post("/test/bot", ar.TestBot)
mutating.Post("/test/discord-bot", ar.TestDiscordBot)
mutating.Put("/docker-hosts", ar.UpdateDockerHosts)
mutating.Put("/coolify-hosts", ar.UpdateCoolifyHosts)
mutating.Put("/auth", ar.UpdateAuth)
mutating.Put("/bot", ar.UpdateBot)
})
if ar.scanHandlers != nil {
r.Get("/scan", ar.scanHandlers.GetScannerConfig)
r.Group(func(mutating chi.Router) {
mutating.Use(middleware.ReadOnly(func() bool {
return ar.registry.Config().ReadOnly
}))
mutating.Put("/scan", ar.scanHandlers.UpdateScannerConfig)
mutating.Post("/scan/test-notification", ar.scanHandlers.TestScanNotification)
})
}
})
}

View file

@ -0,0 +1,793 @@
package api
import (
"context"
"encoding/csv"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/hhftechnology/vps-monitor/internal/config"
"github.com/hhftechnology/vps-monitor/internal/models"
"github.com/hhftechnology/vps-monitor/internal/scanner"
)
// ScanHandlers holds dependencies for scan-related handlers
type ScanHandlers struct {
scanner *scanner.ScannerService
manager *config.Manager
autoScanner *scanner.AutoScanner
resolveImageIDFn func(host, imageRef string) string
}
// NewScanHandlers creates new scan handlers
func NewScanHandlers(scannerService *scanner.ScannerService, manager *config.Manager) *ScanHandlers {
return &ScanHandlers{
scanner: scannerService,
manager: manager,
}
}
// SetAutoScanner sets the auto-scanner reference for status reporting.
func (h *ScanHandlers) SetAutoScanner(as *scanner.AutoScanner) {
h.autoScanner = as
}
// StartScan handles POST /api/v1/scan
func (h *ScanHandlers) StartScan(w http.ResponseWriter, r *http.Request) {
var req struct {
ImageRef string `json:"imageRef"`
Host string `json:"host"`
Scanner models.ScannerType `json:"scanner"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if req.ImageRef == "" || req.Host == "" {
http.Error(w, "imageRef and host are required", http.StatusBadRequest)
return
}
if req.Scanner != "" && req.Scanner != "grype" && req.Scanner != "trivy" {
http.Error(w, "unsupported scanner, must be 'grype' or 'trivy'", http.StatusBadRequest)
return
}
// Rescan gating: check if image has changed since last scan
cfg := h.scanner.Config()
if !cfg.ForceRescan {
db := h.scanner.Store().DB()
// Resolve current image ID
currentImageID := h.resolveImageID(req.Host, req.ImageRef)
if currentImageID != "" {
canRescan, err := db.CanRescan(req.Host, req.ImageRef, currentImageID)
if err != nil {
log.Printf("Failed to check rescan eligibility: %v", err)
} else if !canRescan {
state, _ := db.GetImageScanState(req.Host, req.ImageRef)
resp := map[string]any{
"error": "image_unchanged",
"message": "Image has not changed since last scan. Pull a new version or enable force rescan in settings.",
}
if state != nil {
resp["last_scan_id"] = state.LastScanID
resp["last_scan_at"] = state.LastScanAt
}
WriteJsonResponse(w, http.StatusConflict, resp)
return
}
}
}
job, err := h.scanner.StartScan(req.ImageRef, req.Host, req.Scanner)
if err != nil {
log.Printf("Failed to start scan: %v", err)
http.Error(w, "failed to start scan", http.StatusInternalServerError)
return
}
WriteJsonResponse(w, http.StatusAccepted, map[string]any{
"job": job,
})
}
// StartBulkScan handles POST /api/v1/scan/bulk
func (h *ScanHandlers) StartBulkScan(w http.ResponseWriter, r *http.Request) {
var req struct {
Scanner models.ScannerType `json:"scanner"`
Hosts []string `json:"hosts"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if req.Scanner != "" && req.Scanner != "grype" && req.Scanner != "trivy" {
http.Error(w, "unsupported scanner, must be 'grype' or 'trivy'", http.StatusBadRequest)
return
}
bulkJob, err := h.scanner.StartBulkScan(req.Scanner, req.Hosts)
if err != nil {
log.Printf("Failed to start bulk scan: %v", err)
http.Error(w, "failed to start bulk scan", http.StatusInternalServerError)
return
}
WriteJsonResponse(w, http.StatusAccepted, map[string]any{
"job": bulkJob,
})
}
// GetScanJobs handles GET /api/v1/scan/jobs
func (h *ScanHandlers) GetScanJobs(w http.ResponseWriter, r *http.Request) {
jobs := h.scanner.GetJobs()
bulkJobs := h.scanner.GetBulkJobs()
WriteJsonResponse(w, http.StatusOK, map[string]any{
"jobs": jobs,
"bulkJobs": bulkJobs,
})
}
// GetScanJob handles GET /api/v1/scan/jobs/{id}
func (h *ScanHandlers) GetScanJob(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" {
http.Error(w, "job id is required", http.StatusBadRequest)
return
}
// Check if it's a regular job or bulk job
job := h.scanner.GetJob(id)
if job != nil {
WriteJsonResponse(w, http.StatusOK, map[string]any{
"job": job,
})
return
}
bulkJob := h.scanner.GetBulkJob(id)
if bulkJob != nil {
WriteJsonResponse(w, http.StatusOK, map[string]any{
"bulkJob": bulkJob,
})
return
}
http.Error(w, "job not found", http.StatusNotFound)
}
// CancelScanJob handles DELETE /api/v1/scan/jobs/{id}
func (h *ScanHandlers) CancelScanJob(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" {
http.Error(w, "job id is required", http.StatusBadRequest)
return
}
if h.scanner.CancelJob(id) {
WriteJsonResponse(w, http.StatusOK, map[string]any{
"message": "Job cancelled",
})
} else {
http.Error(w, "job not found or already completed", http.StatusNotFound)
}
}
// GetScanResults handles GET /api/v1/scan/results
func (h *ScanHandlers) GetScanResults(w http.ResponseWriter, r *http.Request) {
imageRef := r.URL.Query().Get("image")
if imageRef == "" {
http.Error(w, "image query parameter is required", http.StatusBadRequest)
return
}
host := r.URL.Query().Get("host")
if host == "" {
http.Error(w, "host query parameter is required", http.StatusBadRequest)
return
}
results := h.scanner.Store().GetResults(host, imageRef)
WriteJsonResponse(w, http.StatusOK, map[string]any{
"results": results,
})
}
// GetLatestScanResult handles GET /api/v1/scan/results/latest
func (h *ScanHandlers) GetLatestScanResult(w http.ResponseWriter, r *http.Request) {
imageRef := r.URL.Query().Get("image")
if imageRef == "" {
http.Error(w, "image query parameter is required", http.StatusBadRequest)
return
}
host := r.URL.Query().Get("host")
if host == "" {
http.Error(w, "host query parameter is required", http.StatusBadRequest)
return
}
result := h.scanner.Store().GetLatest(host, imageRef)
if result == nil {
http.Error(w, "no scan results found", http.StatusNotFound)
return
}
WriteJsonResponse(w, http.StatusOK, map[string]any{
"result": result,
})
}
// StartSBOMGeneration handles POST /api/v1/scan/sbom
func (h *ScanHandlers) StartSBOMGeneration(w http.ResponseWriter, r *http.Request) {
var req struct {
ImageRef string `json:"imageRef"`
Host string `json:"host"`
Format models.SBOMFormat `json:"format"`
Force bool `json:"force"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if req.ImageRef == "" || req.Host == "" {
http.Error(w, "imageRef and host are required", http.StatusBadRequest)
return
}
if req.Format == "" {
req.Format = models.SBOMFormatSPDX
}
cfg := h.scanner.Config()
if !cfg.ForceRescan && !req.Force {
db := h.scanner.Store().DB()
currentImageID := h.resolveImageID(req.Host, req.ImageRef)
if currentImageID != "" {
canRegenerate, err := db.CanRegenerateSBOM(req.Host, req.ImageRef, string(req.Format), currentImageID)
if err != nil {
log.Printf("Failed to check SBOM regeneration eligibility: %v", err)
} else if !canRegenerate {
state, _ := db.GetImageSBOMState(req.Host, req.ImageRef, string(req.Format))
resp := map[string]any{
"error": "image_unchanged",
"message": "Image has not changed since the last SBOM was generated. Pull a new version or enable force rescan in settings.",
}
if state != nil {
resp["last_sbom_id"] = state.LastSBOMID
resp["last_sbom_at"] = state.LastSBOMAt
}
WriteJsonResponse(w, http.StatusConflict, resp)
return
}
}
}
job, err := h.scanner.StartSBOMGeneration(req.ImageRef, req.Host, req.Format)
if err != nil {
log.Printf("Failed to start SBOM generation: %v", err)
http.Error(w, "failed to start SBOM generation", http.StatusInternalServerError)
return
}
WriteJsonResponse(w, http.StatusAccepted, map[string]any{
"job": job,
})
}
// GetSBOMJob handles GET /api/v1/scan/sbom/{id}
func (h *ScanHandlers) GetSBOMJob(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" {
http.Error(w, "job id is required", http.StatusBadRequest)
return
}
job := h.scanner.GetSBOMJob(id)
if job == nil {
http.Error(w, "SBOM job not found", http.StatusNotFound)
return
}
// If complete and download requested, serve the file
if job.Status == models.ScanJobComplete && r.URL.Query().Get("download") == "true" && job.FilePath != "" {
if _, err := os.Stat(job.FilePath); err != nil {
http.Error(w, "SBOM file no longer available", http.StatusGone)
return
}
fileID := id
if job.ResultID != "" {
fileID = job.ResultID
}
w.Header().Set("Content-Disposition", "attachment; filename=sbom-"+fileID+".json")
w.Header().Set("Content-Type", "application/json")
http.ServeFile(w, r, job.FilePath)
return
}
WriteJsonResponse(w, http.StatusOK, map[string]any{
"job": job,
})
}
// GetScannerConfig handles GET /api/v1/settings/scan
func (h *ScanHandlers) GetScannerConfig(w http.ResponseWriter, r *http.Request) {
cfg := h.scanner.Config()
WriteJsonResponse(w, http.StatusOK, map[string]any{
"config": cfg,
})
}
// UpdateScannerConfig handles PUT /api/v1/settings/scan
func (h *ScanHandlers) UpdateScannerConfig(w http.ResponseWriter, r *http.Request) {
var req struct {
GrypeImage string `json:"grypeImage"`
TrivyImage string `json:"trivyImage"`
SyftImage string `json:"syftImage"`
DefaultScanner string `json:"defaultScanner"`
GrypeArgs string `json:"grypeArgs"`
TrivyArgs string `json:"trivyArgs"`
Notifications struct {
DiscordWebhookURL string `json:"discordWebhookURL"`
SlackWebhookURL string `json:"slackWebhookURL"`
OnScanComplete *bool `json:"onScanComplete"`
OnBulkComplete *bool `json:"onBulkComplete"`
OnNewCVEs *bool `json:"onNewCVEs"`
MinSeverity string `json:"minSeverity"`
} `json:"notifications"`
AutoScan *models.AutoScanConfig `json:"autoScan"`
ForceRescan *bool `json:"forceRescan"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
validScanners := map[string]bool{"grype": true, "trivy": true}
if req.DefaultScanner != "" && !validScanners[req.DefaultScanner] {
http.Error(w, "invalid defaultScanner", http.StatusBadRequest)
return
}
validSeverities := map[string]bool{"Critical": true, "High": true, "Medium": true, "Low": true, "Negligible": true, "Unknown": true}
if req.Notifications.MinSeverity != "" && !validSeverities[req.Notifications.MinSeverity] {
http.Error(w, "invalid minSeverity", http.StatusBadRequest)
return
}
// Build the full scanner config to save to DB
currentCfg := h.scanner.Config()
newCfg := &models.ScannerConfig{
GrypeImage: orDefault(req.GrypeImage, currentCfg.GrypeImage),
TrivyImage: orDefault(req.TrivyImage, currentCfg.TrivyImage),
SyftImage: orDefault(req.SyftImage, currentCfg.SyftImage),
DefaultScanner: models.ScannerType(orDefault(req.DefaultScanner, string(currentCfg.DefaultScanner))),
GrypeArgs: req.GrypeArgs,
TrivyArgs: req.TrivyArgs,
Notifications: models.NotificationConfig{
DiscordWebhookURL: req.Notifications.DiscordWebhookURL,
SlackWebhookURL: req.Notifications.SlackWebhookURL,
OnScanComplete: boolPtrOrDefault(req.Notifications.OnScanComplete, currentCfg.Notifications.OnScanComplete),
OnBulkComplete: boolPtrOrDefault(req.Notifications.OnBulkComplete, currentCfg.Notifications.OnBulkComplete),
OnNewCVEs: boolPtrOrDefault(req.Notifications.OnNewCVEs, currentCfg.Notifications.OnNewCVEs),
MinSeverity: models.SeverityLevel(orDefault(req.Notifications.MinSeverity, string(currentCfg.Notifications.MinSeverity))),
},
AutoScan: currentCfg.AutoScan,
ForceRescan: currentCfg.ForceRescan,
}
if req.AutoScan != nil {
newCfg.AutoScan = *req.AutoScan
}
if req.ForceRescan != nil {
newCfg.ForceRescan = *req.ForceRescan
}
// Save to DB
db := h.scanner.Store().DB()
if err := db.SaveScannerSettings(newCfg); err != nil {
log.Printf("Failed to persist scanner config to DB: %v", err)
http.Error(w, "failed to update scanner config", http.StatusInternalServerError)
return
}
// Update runtime config
h.scanner.UpdateConfig(newCfg)
WriteJsonResponse(w, http.StatusOK, map[string]any{
"message": "Scanner configuration updated",
"config": newCfg,
})
}
// TestScanNotification handles POST /api/v1/settings/scan/test-notification
func (h *ScanHandlers) TestScanNotification(w http.ResponseWriter, r *http.Request) {
cfg := h.scanner.Config()
notifier := scanner.NewNotifier()
if err := notifier.SendTestNotification(cfg.Notifications.DiscordWebhookURL, cfg.Notifications.SlackWebhookURL); err != nil {
log.Printf("Test notification failed: %v", err)
http.Error(w, "notification test failed", http.StatusInternalServerError)
return
}
WriteJsonResponse(w, http.StatusOK, map[string]any{
"message": "Test notification sent successfully",
})
}
// --- History Handlers ---
// GetScanHistory handles GET /api/v1/scan/history
func (h *ScanHandlers) GetScanHistory(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
params := scanner.HistoryQuery{
ImageRef: q.Get("image"),
Host: q.Get("host"),
MinSeverity: q.Get("min_severity"),
SortBy: q.Get("sort_by"),
SortDir: q.Get("sort_dir"),
}
if v := q.Get("page"); v != "" {
val, err := strconv.Atoi(v)
if err != nil {
http.Error(w, "invalid page", http.StatusBadRequest)
return
}
params.Page = val
}
if v := q.Get("page_size"); v != "" {
val, err := strconv.Atoi(v)
if err != nil {
http.Error(w, "invalid page_size", http.StatusBadRequest)
return
}
params.PageSize = val
}
if v := q.Get("start_date"); v != "" {
val, err := strconv.ParseInt(v, 10, 64)
if err != nil {
http.Error(w, "invalid start_date", http.StatusBadRequest)
return
}
params.StartDate = val
}
if v := q.Get("end_date"); v != "" {
val, err := strconv.ParseInt(v, 10, 64)
if err != nil {
http.Error(w, "invalid end_date", http.StatusBadRequest)
return
}
params.EndDate = val
}
db := h.scanner.Store().DB()
page, err := db.QueryHistory(params)
if err != nil {
log.Printf("Failed to query scan history: %v", err)
http.Error(w, "failed to query scan history", http.StatusInternalServerError)
return
}
WriteJsonResponse(w, http.StatusOK, page)
}
// GetScanHistoryDetail handles GET /api/v1/scan/history/{id}
func (h *ScanHandlers) GetScanHistoryDetail(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" {
http.Error(w, "scan id is required", http.StatusBadRequest)
return
}
db := h.scanner.Store().DB()
result, err := db.GetResultByID(id)
if err != nil {
log.Printf("Failed to get scan result: %v", err)
http.Error(w, "failed to get scan result", http.StatusInternalServerError)
return
}
if result == nil {
http.Error(w, "scan result not found", http.StatusNotFound)
return
}
WriteJsonResponse(w, http.StatusOK, map[string]any{
"result": result,
})
}
// GetScannedImages handles GET /api/v1/scan/history/images
func (h *ScanHandlers) GetScannedImages(w http.ResponseWriter, r *http.Request) {
db := h.scanner.Store().DB()
images, err := db.ListScannedImages()
if err != nil {
log.Printf("Failed to list scanned images: %v", err)
http.Error(w, "failed to list scanned images", http.StatusInternalServerError)
return
}
WriteJsonResponse(w, http.StatusOK, map[string]any{
"images": images,
})
}
// GetAutoScanStatus handles GET /api/v1/scan/autoscan/status
func (h *ScanHandlers) GetAutoScanStatus(w http.ResponseWriter, r *http.Request) {
if h.autoScanner == nil {
WriteJsonResponse(w, http.StatusOK, map[string]any{
"enabled": false,
"lastPollAt": 0,
"eventsConnected": map[string]bool{},
})
return
}
WriteJsonResponse(w, http.StatusOK, h.autoScanner.Status())
}
// ExportScanHistory returns a scan history detail in CSV format.
func (h *ScanHandlers) ExportScanHistory(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" {
http.Error(w, "missing scan id", http.StatusBadRequest)
return
}
result, err := h.scanner.Store().DB().GetResultByID(id)
if err != nil {
log.Printf("Failed to map export for %s: %v", id, err)
http.Error(w, "failed to get scan result", http.StatusInternalServerError)
return
}
if result == nil {
http.Error(w, "scan result not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/csv")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment;filename=scan_%s.csv", id))
writer := csv.NewWriter(w)
defer writer.Flush()
// Write header
writer.Write([]string{"Severity", "Package", "Version", "VulnerabilityID", "Description", "DataSource"})
sanitize := func(s string) string {
if len(s) > 0 && (s[0] == '=' || s[0] == '+' || s[0] == '-' || s[0] == '@') {
return "'" + s
}
return s
}
for _, vuln := range result.Vulnerabilities {
writer.Write([]string{
string(vuln.Severity),
sanitize(vuln.Package),
sanitize(vuln.InstalledVersion),
sanitize(vuln.ID),
sanitize(vuln.Description),
sanitize(vuln.DataSource),
})
}
}
// DeleteScanHistory deletes a scan history record from the database.
func (h *ScanHandlers) DeleteScanHistory(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" {
http.Error(w, "missing scan id", http.StatusBadRequest)
return
}
err := h.scanner.Store().DB().DeleteScanResult(id)
if err != nil {
http.Error(w, "failed to delete scan result", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// GetSBOMHistory handles GET /api/v1/scan/sbom/history.
func (h *ScanHandlers) GetSBOMHistory(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
params := scanner.SBOMHistoryQuery{
ImageRef: q.Get("image"),
Host: q.Get("host"),
Format: q.Get("format"),
SortBy: q.Get("sort_by"),
SortDir: q.Get("sort_dir"),
}
if v := q.Get("page"); v != "" {
val, err := strconv.Atoi(v)
if err != nil {
http.Error(w, "invalid page", http.StatusBadRequest)
return
}
params.Page = val
}
if v := q.Get("page_size"); v != "" {
val, err := strconv.Atoi(v)
if err != nil {
http.Error(w, "invalid page_size", http.StatusBadRequest)
return
}
params.PageSize = val
}
if v := q.Get("start_date"); v != "" {
val, err := strconv.ParseInt(v, 10, 64)
if err != nil {
http.Error(w, "invalid start_date", http.StatusBadRequest)
return
}
params.StartDate = val
}
if v := q.Get("end_date"); v != "" {
val, err := strconv.ParseInt(v, 10, 64)
if err != nil {
http.Error(w, "invalid end_date", http.StatusBadRequest)
return
}
params.EndDate = val
}
page, err := h.scanner.Store().DB().QuerySBOMHistory(params)
if err != nil {
log.Printf("Failed to query SBOM history: %v", err)
http.Error(w, "failed to query SBOM history", http.StatusInternalServerError)
return
}
WriteJsonResponse(w, http.StatusOK, page)
}
// GetSBOMHistoryDetail handles GET /api/v1/scan/sbom/history/{id}.
func (h *ScanHandlers) GetSBOMHistoryDetail(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" {
http.Error(w, "sbom id is required", http.StatusBadRequest)
return
}
result, err := h.scanner.Store().DB().GetSBOMResultByID(id)
if err != nil {
log.Printf("Failed to get SBOM result: %v", err)
http.Error(w, "failed to get SBOM result", http.StatusInternalServerError)
return
}
if result == nil {
http.Error(w, "sbom result not found", http.StatusNotFound)
return
}
WriteJsonResponse(w, http.StatusOK, map[string]any{
"result": result,
})
}
// GetSBOMedImages handles GET /api/v1/scan/sbom/history/images.
func (h *ScanHandlers) GetSBOMedImages(w http.ResponseWriter, r *http.Request) {
images, err := h.scanner.Store().DB().ListSBOMedImages()
if err != nil {
log.Printf("Failed to list SBOMed images: %v", err)
http.Error(w, "failed to list SBOMed images", http.StatusInternalServerError)
return
}
WriteJsonResponse(w, http.StatusOK, map[string]any{
"images": images,
})
}
// DownloadSBOMHistory handles GET /api/v1/scan/sbom/history/{id}/download.
func (h *ScanHandlers) DownloadSBOMHistory(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" {
http.Error(w, "sbom id is required", http.StatusBadRequest)
return
}
result, err := h.scanner.Store().DB().GetSBOMResultByID(id)
if err != nil {
log.Printf("Failed to get SBOM result for download: %v", err)
http.Error(w, "failed to get SBOM result", http.StatusInternalServerError)
return
}
if result == nil {
http.Error(w, "sbom result not found", http.StatusNotFound)
return
}
if _, err := os.Stat(result.FilePath); err != nil {
http.Error(w, "SBOM file no longer available", http.StatusGone)
return
}
w.Header().Set("Content-Disposition", "attachment; filename=sbom-"+id+".json")
w.Header().Set("Content-Type", "application/json")
http.ServeFile(w, r, result.FilePath)
}
// DeleteSBOMHistory handles DELETE /api/v1/scan/sbom/history/{id}.
func (h *ScanHandlers) DeleteSBOMHistory(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" {
http.Error(w, "missing sbom id", http.StatusBadRequest)
return
}
result, err := h.scanner.Store().DB().GetSBOMResultByID(id)
if err != nil {
log.Printf("Failed to load SBOM result for deletion: %v", err)
http.Error(w, "failed to delete SBOM result", http.StatusInternalServerError)
return
}
if result == nil {
http.Error(w, "sbom result not found", http.StatusNotFound)
return
}
if err := h.scanner.Store().DB().DeleteSBOMResult(id); err != nil {
log.Printf("Failed to delete SBOM result %s: %v", id, err)
http.Error(w, "failed to delete SBOM result", http.StatusInternalServerError)
return
}
if err := os.Remove(result.FilePath); err != nil && !os.IsNotExist(err) {
log.Printf("Failed to remove SBOM artifact %s: %v", result.FilePath, err)
}
w.WriteHeader(http.StatusNoContent)
}
// --- Helpers ---
func (h *ScanHandlers) resolveImageID(host, imageRef string) string {
if h.resolveImageIDFn != nil {
return h.resolveImageIDFn(host, imageRef)
}
dockerClient, release := h.scanner.Registry().AcquireDocker()
if dockerClient == nil {
release()
return ""
}
defer release()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
apiClient, err := dockerClient.GetClient(host)
if err != nil {
return ""
}
inspect, _, err := apiClient.ImageInspectWithRaw(ctx, imageRef)
if err != nil {
return ""
}
return inspect.ID
}
func orDefault(val, def string) string {
if val != "" {
return val
}
return def
}
func boolPtrOrDefault(ptr *bool, def bool) bool {
if ptr != nil {
return *ptr
}
return def
}

View file

@ -0,0 +1,195 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/hhftechnology/vps-monitor/internal/models"
"github.com/hhftechnology/vps-monitor/internal/scanner"
)
func testSBOMResult(id string, completedAt int64, filePath string) models.SBOMResult {
return models.SBOMResult{
ID: id,
ImageRef: "alpine:3.18",
Host: "local",
Format: models.SBOMFormatSPDX,
ComponentCount: 2,
FileSize: 128,
FilePath: filePath,
StartedAt: completedAt - 5,
CompletedAt: completedAt,
DurationMs: 5000,
Components: []models.SBOMComponent{
{Name: "busybox", Version: "1.36.1-r7", Type: "package", PURL: "pkg:apk/alpine/busybox@1.36.1-r7"},
{Name: "ssl_client", Version: "1.36.1-r7", Type: "package", PURL: "pkg:apk/alpine/ssl_client@1.36.1-r7"},
},
}
}
func TestStartSBOMGeneration409WhenImageUnchanged(t *testing.T) {
svc := newTestScannerService(t)
if err := svc.Store().DB().UpsertImageSBOMState("local", "alpine:3.18", string(models.SBOMFormatSPDX), "sha256:same", 100, "sbom-1"); err != nil {
t.Fatalf("UpsertImageSBOMState() error = %v", err)
}
h := &ScanHandlers{
scanner: svc,
resolveImageIDFn: func(host, imageRef string) string { return "sha256:same" },
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/scan/sbom", bytes.NewBufferString(`{"imageRef":"alpine:3.18","host":"local","format":"spdx-json"}`))
rec := httptest.NewRecorder()
h.StartSBOMGeneration(rec, req)
if rec.Code != http.StatusConflict {
t.Fatalf("expected 409, got %d (%s)", rec.Code, rec.Body.String())
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if body["error"] != "image_unchanged" {
t.Fatalf("expected image_unchanged error, got %+v", body)
}
if _, ok := body["last_sbom_id"]; !ok {
t.Fatalf("expected last_sbom_id in response, got %+v", body)
}
}
func TestStartSBOMGenerationBypassesGateWhenForceTrue(t *testing.T) {
svc := newTestScannerService(t)
if err := svc.Store().DB().UpsertImageSBOMState("local", "alpine:3.18", string(models.SBOMFormatSPDX), "sha256:same", 100, "sbom-1"); err != nil {
t.Fatalf("UpsertImageSBOMState() error = %v", err)
}
h := &ScanHandlers{
scanner: svc,
resolveImageIDFn: func(host, imageRef string) string { return "sha256:same" },
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/scan/sbom", bytes.NewBufferString(`{"imageRef":"alpine:3.18","host":"local","format":"spdx-json","force":true}`))
rec := httptest.NewRecorder()
h.StartSBOMGeneration(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("expected 202, got %d (%s)", rec.Code, rec.Body.String())
}
}
func TestGetSBOMHistoryReturnsPagedResults(t *testing.T) {
svc := newTestScannerService(t)
h := &ScanHandlers{scanner: svc}
filePath1 := filepath.Join(t.TempDir(), "sbom-1.json")
filePath2 := filepath.Join(t.TempDir(), "sbom-2.json")
if err := svc.Store().DB().InsertSBOMResult(testSBOMResult("sbom-1", 100, filePath1), "sha256:1"); err != nil {
t.Fatalf("InsertSBOMResult(sbom-1) error = %v", err)
}
if err := svc.Store().DB().InsertSBOMResult(testSBOMResult("sbom-2", 200, filePath2), "sha256:2"); err != nil {
t.Fatalf("InsertSBOMResult(sbom-2) error = %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/api/v1/scan/sbom/history?page=1&page_size=1", nil)
rec := httptest.NewRecorder()
h.GetSBOMHistory(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (%s)", rec.Code, rec.Body.String())
}
var page scanner.SBOMHistoryPage
if err := json.Unmarshal(rec.Body.Bytes(), &page); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if page.Total != 2 || page.PageSize != 1 || len(page.Results) != 1 {
t.Fatalf("unexpected page payload: %+v", page)
}
if page.Results[0].ID != "sbom-2" {
t.Fatalf("expected newest result first, got %+v", page.Results[0])
}
}
func TestDeleteSBOMHistoryRemovesRowAndFile(t *testing.T) {
svc := newTestScannerService(t)
filePath := filepath.Join(t.TempDir(), "sbom-delete.json")
if err := os.WriteFile(filePath, []byte(`{"bomFormat":"CycloneDX"}`), 0o600); err != nil {
t.Fatalf("os.WriteFile() error = %v", err)
}
result := testSBOMResult("sbom-delete", 100, filePath)
if err := svc.Store().DB().InsertSBOMResult(result, "sha256:1"); err != nil {
t.Fatalf("InsertSBOMResult() error = %v", err)
}
h := &ScanHandlers{scanner: svc}
req := httptest.NewRequest(http.MethodDelete, "/api/v1/scan/sbom/history/sbom-delete", nil)
req = chiContext(req, map[string]string{"id": "sbom-delete"})
rec := httptest.NewRecorder()
h.DeleteSBOMHistory(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("expected 204, got %d (%s)", rec.Code, rec.Body.String())
}
if _, err := os.Stat(filePath); !os.IsNotExist(err) {
t.Fatalf("expected artifact removed, stat err = %v", err)
}
stored, err := svc.Store().DB().GetSBOMResultByID("sbom-delete")
if err != nil {
t.Fatalf("GetSBOMResultByID() error = %v", err)
}
if stored != nil {
t.Fatalf("expected deleted row, got %+v", stored)
}
}
func TestDownloadSBOMHistoryReturnsJSON(t *testing.T) {
svc := newTestScannerService(t)
filePath := filepath.Join(t.TempDir(), "sbom-download.json")
content := []byte(`{"bomFormat":"CycloneDX","components":[{"name":"busybox"}]}`)
if err := os.WriteFile(filePath, content, 0o600); err != nil {
t.Fatalf("os.WriteFile() error = %v", err)
}
if err := svc.Store().DB().InsertSBOMResult(testSBOMResult("sbom-download", 100, filePath), "sha256:1"); err != nil {
t.Fatalf("InsertSBOMResult() error = %v", err)
}
h := &ScanHandlers{scanner: svc}
req := httptest.NewRequest(http.MethodGet, "/api/v1/scan/sbom/history/sbom-download/download", nil)
req = chiContext(req, map[string]string{"id": "sbom-download"})
rec := httptest.NewRecorder()
h.DownloadSBOMHistory(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (%s)", rec.Code, rec.Body.String())
}
if got := rec.Body.String(); got != string(content) {
t.Fatalf("download body mismatch: got %q want %q", got, string(content))
}
}
func TestDownloadSBOMHistory410WhenFileMissing(t *testing.T) {
svc := newTestScannerService(t)
filePath := filepath.Join(t.TempDir(), "missing.json")
if err := svc.Store().DB().InsertSBOMResult(testSBOMResult("sbom-missing", 100, filePath), "sha256:1"); err != nil {
t.Fatalf("InsertSBOMResult() error = %v", err)
}
h := &ScanHandlers{scanner: svc}
req := httptest.NewRequest(http.MethodGet, "/api/v1/scan/sbom/history/sbom-missing/download", nil)
req = chiContext(req, map[string]string{"id": "sbom-missing"})
rec := httptest.NewRecorder()
h.DownloadSBOMHistory(rec, req)
if rec.Code != http.StatusGone {
t.Fatalf("expected 410, got %d (%s)", rec.Code, rec.Body.String())
}
}

View file

@ -0,0 +1,475 @@
package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/hhftechnology/vps-monitor/internal/config"
"github.com/hhftechnology/vps-monitor/internal/models"
"github.com/hhftechnology/vps-monitor/internal/scanner"
"github.com/hhftechnology/vps-monitor/internal/services"
)
// newTestScannerService creates a ScannerService backed by a stub Registry that
// has no Docker client. StartScan goroutines fail gracefully (status="failed")
// without panicking, making the service safe for handler tests.
func newTestScannerService(t *testing.T) *scanner.ScannerService {
t.Helper()
cfg := &models.ScannerConfig{
GrypeImage: "anchore/grype:latest",
TrivyImage: "aquasec/trivy:latest",
SyftImage: "anchore/syft:latest",
DefaultScanner: models.ScannerGrype,
}
// Registry with nil docker client: AcquireDocker returns (nil, func(){})
// which is handled gracefully in runScan.
registry := services.NewRegistry(nil, nil, nil, &config.Config{}, nil)
db, err := scanner.NewScanDB(filepath.Join(t.TempDir(), "scan.db"))
if err != nil {
t.Fatalf("NewScanDB() error = %v", err)
}
return scanner.NewScannerService(registry, cfg, db)
}
// newTestManager creates a Manager pointing to a temp file (for tests that
// exercise UpdateScannerConfig persistence).
func newTestManager(t *testing.T) *config.Manager {
t.Helper()
return &config.Manager{}
}
// chiContext wraps a request in a chi router context with the given URL params.
func chiContext(r *http.Request, params map[string]string) *http.Request {
rctx := chi.NewRouteContext()
for k, v := range params {
rctx.URLParams.Add(k, v)
}
ctx := context.WithValue(r.Context(), chi.RouteCtxKey, rctx)
return r.WithContext(ctx)
}
// ─── GetScanJobs ──────────────────────────────────────────────────────────────
func TestGetScanJobsReturnsEmptySlices(t *testing.T) {
h := &ScanHandlers{scanner: newTestScannerService(t)}
req := httptest.NewRequest(http.MethodGet, "/api/v1/scan/jobs", nil)
rec := httptest.NewRecorder()
h.GetScanJobs(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if _, ok := body["jobs"]; !ok {
t.Fatal("response must contain 'jobs' key")
}
if _, ok := body["bulkJobs"]; !ok {
t.Fatal("response must contain 'bulkJobs' key")
}
}
// ─── GetScanJob ───────────────────────────────────────────────────────────────
func TestGetScanJobReturnsNotFoundForUnknownID(t *testing.T) {
h := &ScanHandlers{scanner: newTestScannerService(t)}
req := httptest.NewRequest(http.MethodGet, "/api/v1/scan/jobs/unknown-id", nil)
req = chiContext(req, map[string]string{"id": "unknown-id"})
rec := httptest.NewRecorder()
h.GetScanJob(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", rec.Code)
}
}
func TestGetScanJobReturnsJobWhenFound(t *testing.T) {
svc := newTestScannerService(t)
// Manually inject a job into the service store via StartScan
// (StartScan returns immediately with pending status; the goroutine fails
// because registry is nil, but the job is registered first.)
job, err := svc.StartScan("nginx:latest", "local", models.ScannerGrype)
if err != nil {
t.Fatalf("StartScan returned error: %v", err)
}
// Give the goroutine a moment to set the failure status.
time.Sleep(10 * time.Millisecond)
h := &ScanHandlers{scanner: svc}
req := httptest.NewRequest(http.MethodGet, "/api/v1/scan/jobs/"+job.ID, nil)
req = chiContext(req, map[string]string{"id": job.ID})
rec := httptest.NewRecorder()
h.GetScanJob(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if _, ok := body["job"]; !ok {
t.Fatal("response must contain 'job' key")
}
}
// ─── StartScan validation ─────────────────────────────────────────────────────
func TestStartScanRejectsMissingFields(t *testing.T) {
h := &ScanHandlers{scanner: newTestScannerService(t)}
cases := []struct {
name string
body string
}{
{"missing imageRef", `{"host":"local"}`},
{"missing host", `{"imageRef":"nginx:latest"}`},
{"empty body fields", `{"imageRef":"","host":""}`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/scan",
bytes.NewBufferString(tc.body))
rec := httptest.NewRecorder()
h.StartScan(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
})
}
}
func TestStartScanRejectsInvalidJSON(t *testing.T) {
h := &ScanHandlers{scanner: newTestScannerService(t)}
req := httptest.NewRequest(http.MethodPost, "/api/v1/scan",
bytes.NewBufferString(`not-json`))
rec := httptest.NewRecorder()
h.StartScan(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for invalid JSON, got %d", rec.Code)
}
}
// ─── StartBulkScan validation ─────────────────────────────────────────────────
func TestStartBulkScanRejectsInvalidJSON(t *testing.T) {
h := &ScanHandlers{scanner: newTestScannerService(t)}
req := httptest.NewRequest(http.MethodPost, "/api/v1/scan/bulk",
bytes.NewBufferString(`not-json`))
rec := httptest.NewRecorder()
h.StartBulkScan(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for invalid JSON, got %d", rec.Code)
}
}
// ─── CancelScanJob ────────────────────────────────────────────────────────────
func TestCancelScanJobReturnsNotFoundForUnknownID(t *testing.T) {
h := &ScanHandlers{scanner: newTestScannerService(t)}
req := httptest.NewRequest(http.MethodDelete, "/api/v1/scan/jobs/ghost", nil)
req = chiContext(req, map[string]string{"id": "ghost"})
rec := httptest.NewRecorder()
h.CancelScanJob(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", rec.Code)
}
}
// ─── GetScanResults ───────────────────────────────────────────────────────────
func TestGetScanResultsRequiresHostParam(t *testing.T) {
h := &ScanHandlers{scanner: newTestScannerService(t)}
req := httptest.NewRequest(http.MethodGet, "/api/v1/scan/results?image=nginx:latest", nil)
// No host query param
rec := httptest.NewRecorder()
h.GetScanResults(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 when host param is missing, got %d", rec.Code)
}
}
func TestGetScanResultsReturnsEmptyForUnknownImage(t *testing.T) {
h := &ScanHandlers{scanner: newTestScannerService(t)}
req := httptest.NewRequest(http.MethodGet, "/api/v1/scan/results?image=unknown:image&host=local", nil)
rec := httptest.NewRecorder()
h.GetScanResults(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
}
func TestGetScanResultsReturnsStoredResults(t *testing.T) {
svc := newTestScannerService(t)
svc.Store().Add(models.ScanResult{
ID: "res-1",
ImageRef: "redis:7",
Host: "local",
Summary: models.SeveritySummary{Total: 3},
})
h := &ScanHandlers{scanner: svc}
req := httptest.NewRequest(http.MethodGet, "/api/v1/scan/results?image=redis:7&host=local", nil)
rec := httptest.NewRecorder()
h.GetScanResults(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
results, ok := body["results"].([]any)
if !ok {
t.Fatalf("expected results array, got %T", body["results"])
}
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
}
// ─── GetLatestScanResult ──────────────────────────────────────────────────────
func TestGetLatestScanResultRequiresHostParam(t *testing.T) {
h := &ScanHandlers{scanner: newTestScannerService(t)}
req := httptest.NewRequest(http.MethodGet, "/api/v1/scan/results/latest?image=nginx:latest", nil)
rec := httptest.NewRecorder()
h.GetLatestScanResult(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 when host param is missing, got %d", rec.Code)
}
}
func TestGetLatestScanResultReturns404WhenNoResults(t *testing.T) {
h := &ScanHandlers{scanner: newTestScannerService(t)}
req := httptest.NewRequest(http.MethodGet, "/api/v1/scan/results/latest?image=missing:img&host=local", nil)
rec := httptest.NewRecorder()
h.GetLatestScanResult(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", rec.Code)
}
}
func TestGetLatestScanResultReturnsResult(t *testing.T) {
svc := newTestScannerService(t)
svc.Store().Add(models.ScanResult{
ID: "latest-1",
ImageRef: "postgres:16",
Host: "remote",
})
h := &ScanHandlers{scanner: svc}
req := httptest.NewRequest(http.MethodGet, "/api/v1/scan/results/latest?image=postgres:16&host=remote", nil)
rec := httptest.NewRecorder()
h.GetLatestScanResult(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if _, ok := body["result"]; !ok {
t.Fatal("expected 'result' key in response")
}
}
// ─── StartSBOMGeneration validation ──────────────────────────────────────────
func TestStartSBOMGenerationRejectsMissingFields(t *testing.T) {
h := &ScanHandlers{scanner: newTestScannerService(t)}
cases := []struct {
name string
body string
}{
{"missing imageRef", `{"host":"local"}`},
{"missing host", `{"imageRef":"nginx:latest"}`},
{"both missing", `{}`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/scan/sbom",
bytes.NewBufferString(tc.body))
rec := httptest.NewRecorder()
h.StartSBOMGeneration(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for %s, got %d", tc.name, rec.Code)
}
})
}
}
func TestStartSBOMGenerationRejectsInvalidJSON(t *testing.T) {
h := &ScanHandlers{scanner: newTestScannerService(t)}
req := httptest.NewRequest(http.MethodPost, "/api/v1/scan/sbom",
bytes.NewBufferString(`{invalid}`))
rec := httptest.NewRecorder()
h.StartSBOMGeneration(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for invalid JSON, got %d", rec.Code)
}
}
// ─── GetSBOMJob ───────────────────────────────────────────────────────────────
func TestGetSBOMJobReturnsNotFoundForUnknownID(t *testing.T) {
h := &ScanHandlers{scanner: newTestScannerService(t)}
req := httptest.NewRequest(http.MethodGet, "/api/v1/scan/sbom/ghost", nil)
req = chiContext(req, map[string]string{"id": "ghost"})
rec := httptest.NewRecorder()
h.GetSBOMJob(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", rec.Code)
}
}
// ─── GetScannerConfig ─────────────────────────────────────────────────────────
func TestGetScannerConfigReturnsCurrentConfig(t *testing.T) {
svc := newTestScannerService(t)
h := &ScanHandlers{scanner: svc}
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings/scan", nil)
rec := httptest.NewRecorder()
h.GetScannerConfig(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if _, ok := body["config"]; !ok {
t.Fatal("expected 'config' key in response")
}
}
// ─── UpdateScannerConfig ──────────────────────────────────────────────────────
func TestUpdateScannerConfigRejectsInvalidJSON(t *testing.T) {
tmpDir := t.TempDir()
mgr := &config.Manager{}
_ = mgr
// Use a real manager pointing to a temp file
realMgr := mustNewManagerWithTempFile(t, filepath.Join(tmpDir, "config.json"))
h := &ScanHandlers{
scanner: newTestScannerService(t),
manager: realMgr,
}
req := httptest.NewRequest(http.MethodPut, "/api/v1/settings/scan",
bytes.NewBufferString(`{not-json}`))
rec := httptest.NewRecorder()
h.UpdateScannerConfig(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for invalid JSON, got %d", rec.Code)
}
}
func TestUpdateScannerConfigPersistsChanges(t *testing.T) {
realMgr := mustNewManagerWithTempFile(t, filepath.Join(t.TempDir(), "config.json"))
h := &ScanHandlers{
scanner: newTestScannerService(t),
manager: realMgr,
}
payload := `{
"grypeImage":"anchore/grype:v2",
"trivyImage":"aquasec/trivy:v2",
"syftImage":"anchore/syft:v2",
"defaultScanner":"trivy",
"grypeArgs":"",
"trivyArgs":"",
"notifications":{
"onScanComplete":false,
"onBulkComplete":true,
"minSeverity":"Critical"
}
}`
req := httptest.NewRequest(http.MethodPut, "/api/v1/settings/scan",
bytes.NewBufferString(payload))
rec := httptest.NewRecorder()
h.UpdateScannerConfig(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if _, ok := body["config"]; !ok {
t.Fatal("expected 'config' key in response")
}
}
// ─── helpers ──────────────────────────────────────────────────────────────────
// mustNewManagerWithTempFile builds a config.Manager backed by a temp file.
// It accesses Manager fields directly because the package-level test lives in
// the same `api` package and Manager is from an external package — we construct
// it via the public exported fields (unexported fields are zero-valued, which
// is fine for the persist/merge path).
func mustNewManagerWithTempFile(t *testing.T, path string) *config.Manager {
t.Helper()
// We cannot set unexported fields from outside the config package, so we
// rely on Manager's exported API after construction via NewManager.
// Since NewManager reads env vars / disk, we instead build a minimal Manager
// using a table-driven trick: write an empty config file first, then let
// NewManager load it. In tests we override CONFIG_PATH.
t.Setenv("CONFIG_PATH", path)
mgr := config.NewManager()
return mgr
}

View file

@ -3,6 +3,7 @@ package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
@ -11,6 +12,7 @@ import (
"time"
"github.com/hhftechnology/vps-monitor/internal/auth"
"github.com/hhftechnology/vps-monitor/internal/bot"
"github.com/hhftechnology/vps-monitor/internal/config"
"github.com/hhftechnology/vps-monitor/internal/coolify"
"github.com/hhftechnology/vps-monitor/internal/docker"
@ -73,6 +75,33 @@ func (ar *APIRouter) GetSettings(w http.ResponseWriter, r *http.Request) {
authResp["passwordConfigured"] = fc.Auth.AdminPasswordHash != ""
}
botResp := map[string]any{
"source": sources.Bot,
"enabled": cfg.Bot.Enabled,
"mode": cfg.Bot.Mode,
"telegramTokenConfigured": false,
"allowedChatId": cfg.Bot.AllowedChatID,
"relayPath": "/api/v1/bot/relay/command",
"relayUsesAuth": true,
"discord": map[string]any{
"enabled": cfg.Bot.Discord.Enabled,
"botToken": "",
"applicationId": cfg.Bot.Discord.ApplicationID,
"guildId": cfg.Bot.Discord.GuildID,
"allowedChannelId": cfg.Bot.Discord.AllowedChannelID,
},
}
if cfg.Bot.TelegramToken != "" || (fc.Bot != nil && fc.Bot.TelegramToken != "") {
botResp["telegramTokenConfigured"] = true
}
if discordResp, ok := botResp["discord"].(map[string]any); ok {
if sources.Bot == config.SourceEnv && cfg.Bot.Discord.BotToken != "" {
discordResp["botToken"] = secretMask
} else if fc.Bot != nil && fc.Bot.Discord != nil && fc.Bot.Discord.BotToken != "" {
discordResp["botToken"] = secretMask
}
}
WriteJsonResponse(w, http.StatusOK, map[string]any{
"dockerHosts": map[string]any{
"source": sources.DockerHosts,
@ -87,6 +116,7 @@ func (ar *APIRouter) GetSettings(w http.ResponseWriter, r *http.Request) {
"value": cfg.ReadOnly,
},
"auth": authResp,
"bot": botResp,
})
}
@ -240,7 +270,7 @@ func (ar *APIRouter) UpdateAuth(w http.ResponseWriter, r *http.Request) {
if authCfg.JWTSecret == "" {
secret, err := auth.GenerateRandomHex(32)
if err != nil {
return nil, fmt.Errorf("failed to generate JWT secret")
return nil, fmt.Errorf("failed to generate JWT secret: %w", err)
}
authCfg.JWTSecret = secret
}
@ -270,6 +300,173 @@ func (ar *APIRouter) UpdateAuth(w http.ResponseWriter, r *http.Request) {
WriteJsonResponse(w, http.StatusOK, map[string]any{"message": "Auth settings updated"})
}
func (ar *APIRouter) UpdateBot(w http.ResponseWriter, r *http.Request) {
var req struct {
Enabled bool `json:"enabled"`
Mode string `json:"mode"`
TelegramToken string `json:"telegramToken"`
AllowedChatID string `json:"allowedChatId"`
Discord *struct {
Enabled bool `json:"enabled"`
BotToken string `json:"botToken"`
ApplicationID string `json:"applicationId"`
GuildID string `json:"guildId"`
AllowedChannelID string `json:"allowedChannelId"`
} `json:"discord,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
token := strings.TrimSpace(req.TelegramToken)
chatID := strings.TrimSpace(req.AllowedChatID)
mode := config.BotModePolling
if req.Mode != "" {
mode = config.NormalizeBotMode(req.Mode)
}
if token == secretMask {
fc := ar.manager.FileConfigSnapshot()
if fc.Bot == nil || fc.Bot.TelegramToken == "" {
http.Error(w, "no stored telegram token found; provide the actual token", http.StatusBadRequest)
return
}
token = fc.Bot.TelegramToken
}
if req.Enabled && (token == "" || chatID == "") {
http.Error(w, "telegramToken and allowedChatId are required when enabling bot", http.StatusBadRequest)
return
}
if req.Enabled && mode == config.BotModeJWTRelay {
authSvc := ar.registry.Auth()
if authSvc == nil || authSvc.IsDisabled() {
http.Error(w, "auth must be enabled before using jwt-relay bot mode", http.StatusConflict)
return
}
}
fc := ar.manager.FileConfigSnapshot()
nextBot := &config.FileBotConfig{
Enabled: &req.Enabled,
Mode: mode,
TelegramToken: token,
AllowedChatID: chatID,
}
if fc.Bot != nil && fc.Bot.Discord != nil {
existingDiscord := *fc.Bot.Discord
nextBot.Discord = &existingDiscord
}
if req.Discord != nil {
discordToken := strings.TrimSpace(req.Discord.BotToken)
if discordToken == secretMask {
if fc.Bot != nil && fc.Bot.Discord != nil && fc.Bot.Discord.BotToken != "" {
discordToken = fc.Bot.Discord.BotToken
} else {
http.Error(w, "no stored discord token found; provide the actual token", http.StatusBadRequest)
return
}
}
applicationID := strings.TrimSpace(req.Discord.ApplicationID)
guildID := strings.TrimSpace(req.Discord.GuildID)
channelID := strings.TrimSpace(req.Discord.AllowedChannelID)
if req.Discord.Enabled && (discordToken == "" || applicationID == "" || channelID == "") {
http.Error(w, "discord botToken, applicationId, and allowedChannelId are required when enabling Discord bot", http.StatusBadRequest)
return
}
nextBot.Discord = &config.FileDiscordBotConfig{
Enabled: &req.Discord.Enabled,
BotToken: discordToken,
ApplicationID: applicationID,
GuildID: guildID,
AllowedChannelID: channelID,
}
}
err := ar.manager.UpdateBotConfig(nextBot)
if err != nil {
http.Error(w, err.Error(), settingsErrorStatus(err))
return
}
WriteJsonResponse(w, http.StatusOK, map[string]any{"message": "Bot settings updated"})
}
func (ar *APIRouter) TestBot(w http.ResponseWriter, r *http.Request) {
var req struct {
TelegramToken string `json:"telegramToken"`
AllowedChatID string `json:"allowedChatId"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
token := strings.TrimSpace(req.TelegramToken)
if token == secretMask {
fc := ar.manager.FileConfigSnapshot()
if fc.Bot == nil || fc.Bot.TelegramToken == "" {
http.Error(w, "no stored telegram token found; provide the actual token", http.StatusBadRequest)
return
}
token = fc.Bot.TelegramToken
}
svc := bot.NewService(ar.registry, ar.registry.Config().Bot)
if err := svc.SendTestMessage(r.Context(), token, strings.TrimSpace(req.AllowedChatID)); err != nil {
WriteJsonResponse(w, http.StatusOK, map[string]any{
"success": false,
"message": err.Error(),
})
return
}
WriteJsonResponse(w, http.StatusOK, map[string]any{
"success": true,
"message": "Test message sent",
})
}
func (ar *APIRouter) TestDiscordBot(w http.ResponseWriter, r *http.Request) {
var req struct {
BotToken string `json:"botToken"`
AllowedChannelID string `json:"allowedChannelId"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
token := strings.TrimSpace(req.BotToken)
if token == secretMask {
fc := ar.manager.FileConfigSnapshot()
if fc.Bot != nil && fc.Bot.Discord != nil && fc.Bot.Discord.BotToken != "" {
token = fc.Bot.Discord.BotToken
} else {
http.Error(w, "no stored discord token found; provide the actual token", http.StatusBadRequest)
return
}
}
svc := bot.NewService(ar.registry, ar.registry.Config().Bot)
if err := svc.SendDiscordTestMessage(r.Context(), token, strings.TrimSpace(req.AllowedChannelID)); err != nil {
WriteJsonResponse(w, http.StatusOK, map[string]any{
"success": false,
"message": err.Error(),
})
return
}
WriteJsonResponse(w, http.StatusOK, map[string]any{
"success": true,
"message": "Discord test message sent",
})
}
// TestDockerHost handles POST /api/v1/settings/test/docker-host.
func (ar *APIRouter) TestDockerHost(w http.ResponseWriter, r *http.Request) {
var req struct {
@ -407,7 +604,7 @@ func (ar *APIRouter) TestCoolifyHost(w http.ResponseWriter, r *http.Request) {
}
func settingsErrorStatus(err error) int {
if strings.Contains(err.Error(), "environment variable") {
if errors.Is(err, config.ErrEnvironmentConfigured) {
return http.StatusConflict
}
return http.StatusInternalServerError

View file

@ -0,0 +1,364 @@
package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/hhftechnology/vps-monitor/internal/auth"
"github.com/hhftechnology/vps-monitor/internal/config"
"github.com/hhftechnology/vps-monitor/internal/coolify"
"github.com/hhftechnology/vps-monitor/internal/services"
)
type fakeCoolifySyncer struct {
called bool
err error
}
func (f *fakeCoolifySyncer) SyncEnvVars(ctx context.Context, resource *coolify.ResourceInfo, envVars map[string]string) error {
f.called = true
return f.err
}
func TestSettingsErrorStatusEnvironmentConfigured(t *testing.T) {
err := fmt.Errorf("update rejected: %w", config.ErrEnvironmentConfigured)
if got := settingsErrorStatus(err); got != http.StatusConflict {
t.Fatalf("expected %d, got %d", http.StatusConflict, got)
}
}
func TestSettingsErrorStatusDefault(t *testing.T) {
if got := settingsErrorStatus(errors.New("boom")); got != http.StatusInternalServerError {
t.Fatalf("expected %d, got %d", http.StatusInternalServerError, got)
}
}
func TestApplyCoolifyEnvSyncSkipsDatabaseResources(t *testing.T) {
syncer := &fakeCoolifySyncer{}
response := map[string]any{}
applyCoolifyEnvSync(context.Background(), "host-a", syncer, &coolify.ResourceInfo{
Type: coolify.ResourceTypeDatabase,
UUID: "resource-1",
}, map[string]string{"KEY": "VALUE"}, response)
if syncer.called {
t.Fatalf("expected SyncEnvVars not to be called for database resources")
}
if got, ok := response["coolify_synced"].(bool); !ok || got {
t.Fatalf("expected coolify_synced=false, got %#v", response["coolify_synced"])
}
if got, ok := response["coolify_error"].(string); !ok || got != "sync not supported for database resources" {
t.Fatalf("unexpected coolify_error: %#v", response["coolify_error"])
}
}
func TestApplyCoolifyEnvSyncPropagatesSyncErrors(t *testing.T) {
syncer := &fakeCoolifySyncer{err: errors.New("upstream failed")}
response := map[string]any{}
applyCoolifyEnvSync(context.Background(), "host-a", syncer, &coolify.ResourceInfo{
Type: coolify.ResourceTypeApplication,
UUID: "resource-1",
}, map[string]string{"KEY": "VALUE"}, response)
if !syncer.called {
t.Fatalf("expected SyncEnvVars to be called")
}
if got, ok := response["coolify_synced"].(bool); !ok || got {
t.Fatalf("expected coolify_synced=false, got %#v", response["coolify_synced"])
}
if got, ok := response["coolify_error"].(string); !ok || got != "upstream failed" {
t.Fatalf("unexpected coolify_error: %#v", response["coolify_error"])
}
}
func TestApplyCoolifyEnvSyncSucceeds(t *testing.T) {
syncer := &fakeCoolifySyncer{}
response := map[string]any{}
applyCoolifyEnvSync(context.Background(), "host-a", syncer, &coolify.ResourceInfo{
Type: coolify.ResourceTypeApplication,
UUID: "resource-1",
}, map[string]string{"KEY": "VALUE"}, response)
if !syncer.called {
t.Fatalf("expected SyncEnvVars to be called")
}
if got, ok := response["coolify_synced"].(bool); !ok || !got {
t.Fatalf("expected coolify_synced=true, got %#v", response["coolify_synced"])
}
if _, hasError := response["coolify_error"]; hasError {
t.Fatalf("expected no coolify_error key on success, got %#v", response["coolify_error"])
}
}
func TestApplyCoolifyEnvSyncNilSyncer(t *testing.T) {
response := map[string]any{}
applyCoolifyEnvSync(context.Background(), "host-a", nil, &coolify.ResourceInfo{
Type: coolify.ResourceTypeApplication,
UUID: "resource-1",
}, map[string]string{"KEY": "VALUE"}, response)
if len(response) != 0 {
t.Fatalf("expected empty response when syncer is nil, got %#v", response)
}
}
func TestApplyCoolifyEnvSyncNilResource(t *testing.T) {
syncer := &fakeCoolifySyncer{}
response := map[string]any{}
applyCoolifyEnvSync(context.Background(), "host-a", syncer, nil,
map[string]string{"KEY": "VALUE"}, response)
if syncer.called {
t.Fatalf("expected SyncEnvVars not to be called when resource is nil")
}
if len(response) != 0 {
t.Fatalf("expected empty response when resource is nil, got %#v", response)
}
}
func TestApplyCoolifyEnvSyncServiceResourceType(t *testing.T) {
syncer := &fakeCoolifySyncer{}
response := map[string]any{}
applyCoolifyEnvSync(context.Background(), "host-a", syncer, &coolify.ResourceInfo{
Type: coolify.ResourceTypeService,
UUID: "resource-1",
}, map[string]string{"KEY": "VALUE"}, response)
if !syncer.called {
t.Fatalf("expected SyncEnvVars to be called for service resources")
}
if got, ok := response["coolify_synced"].(bool); !ok || !got {
t.Fatalf("expected coolify_synced=true for service resource, got %#v", response["coolify_synced"])
}
}
func TestSettingsErrorStatusDirectErrEnvironmentConfigured(t *testing.T) {
if got := settingsErrorStatus(config.ErrEnvironmentConfigured); got != http.StatusConflict {
t.Fatalf("expected %d for direct ErrEnvironmentConfigured, got %d", http.StatusConflict, got)
}
}
func TestUpdateBotRejectsIncompleteDiscordConfig(t *testing.T) {
manager := newTestSettingsManager(t)
router := &APIRouter{
manager: manager,
registry: services.NewRegistry(nil, nil, nil, manager.Config(), nil),
}
req := httptest.NewRequest(http.MethodPut, "/api/v1/settings/bot", strings.NewReader(`{
"enabled": false,
"mode": "polling",
"telegramToken": "",
"allowedChatId": "",
"discord": {
"enabled": true,
"botToken": "discord-token",
"applicationId": "app-1",
"allowedChannelId": ""
}
}`))
rec := httptest.NewRecorder()
router.UpdateBot(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected %d, got %d: %s", http.StatusBadRequest, rec.Code, rec.Body.String())
}
}
func TestUpdateBotPreservesMaskedDiscordToken(t *testing.T) {
manager := newTestSettingsManager(t)
router := &APIRouter{
manager: manager,
registry: services.NewRegistry(nil, nil, nil, manager.Config(), nil),
}
req := httptest.NewRequest(http.MethodPut, "/api/v1/settings/bot", strings.NewReader(`{
"enabled": false,
"mode": "polling",
"telegramToken": "",
"allowedChatId": "",
"discord": {
"enabled": true,
"botToken": "discord-token",
"applicationId": "app-1",
"guildId": "guild-1",
"allowedChannelId": "channel-1"
}
}`))
rec := httptest.NewRecorder()
router.UpdateBot(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected initial update to succeed, got %d: %s", rec.Code, rec.Body.String())
}
router.registry.UpdateConfig(manager.Config())
req = httptest.NewRequest(http.MethodPut, "/api/v1/settings/bot", strings.NewReader(`{
"enabled": false,
"mode": "polling",
"telegramToken": "",
"allowedChatId": "",
"discord": {
"enabled": true,
"botToken": "••••••••",
"applicationId": "app-2",
"guildId": "",
"allowedChannelId": "channel-2"
}
}`))
rec = httptest.NewRecorder()
router.UpdateBot(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected masked update to succeed, got %d: %s", rec.Code, rec.Body.String())
}
got := manager.Config().Bot.Discord
if got.BotToken != "discord-token" {
t.Fatalf("expected masked update to preserve token, got %q", got.BotToken)
}
if got.ApplicationID != "app-2" || got.AllowedChannelID != "channel-2" || got.GuildID != "" {
t.Fatalf("unexpected discord config after masked update: %+v", got)
}
}
func TestGetSettingsMasksDiscordToken(t *testing.T) {
manager := newTestSettingsManager(t)
enabled := true
if err := manager.UpdateBotConfig(&config.FileBotConfig{
TelegramToken: "telegram-token",
Discord: &config.FileDiscordBotConfig{
Enabled: &enabled,
BotToken: "discord-token",
ApplicationID: "app-1",
AllowedChannelID: "channel-1",
},
}); err != nil {
t.Fatalf("UpdateBotConfig returned error: %v", err)
}
router := &APIRouter{
manager: manager,
registry: services.NewRegistry(nil, nil, nil, manager.Config(), nil),
}
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil)
rec := httptest.NewRecorder()
router.GetSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected %d, got %d: %s", http.StatusOK, rec.Code, rec.Body.String())
}
var body struct {
Bot struct {
TelegramToken string `json:"telegramToken"`
TelegramTokenConfigured bool `json:"telegramTokenConfigured"`
Discord struct {
BotToken string `json:"botToken"`
} `json:"discord"`
} `json:"bot"`
}
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode settings response: %v", err)
}
if body.Bot.TelegramToken != "" {
t.Fatalf("expected telegram token to be omitted, got %q", body.Bot.TelegramToken)
}
if !body.Bot.TelegramTokenConfigured {
t.Fatal("expected telegramTokenConfigured=true")
}
if body.Bot.Discord.BotToken != secretMask {
t.Fatalf("expected masked discord token, got %q", body.Bot.Discord.BotToken)
}
}
func TestUpdateBotRejectsMaskedTelegramTokenWithoutStoredToken(t *testing.T) {
manager := newTestSettingsManager(t)
router := &APIRouter{
manager: manager,
registry: services.NewRegistry(nil, nil, nil, manager.Config(), nil),
}
req := httptest.NewRequest(http.MethodPut, "/api/v1/settings/bot", strings.NewReader(`{
"enabled": false,
"mode": "polling",
"telegramToken": "••••••••",
"allowedChatId": ""
}`))
rec := httptest.NewRecorder()
router.UpdateBot(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected %d, got %d: %s", http.StatusBadRequest, rec.Code, rec.Body.String())
}
}
func TestTestBotRejectsMaskedTelegramTokenWithoutStoredToken(t *testing.T) {
manager := newTestSettingsManager(t)
router := &APIRouter{
manager: manager,
registry: services.NewRegistry(nil, nil, nil, manager.Config(), nil),
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/settings/test/bot", strings.NewReader(`{
"telegramToken": "••••••••",
"allowedChatId": "123"
}`))
rec := httptest.NewRecorder()
router.TestBot(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected %d, got %d: %s", http.StatusBadRequest, rec.Code, rec.Body.String())
}
}
func TestSettingsMutationRoutesRespectReadOnlyMode(t *testing.T) {
manager := newTestSettingsManager(t)
registry := services.NewRegistry(nil, nil, auth.NewDisabledService(), &config.Config{
ReadOnly: true,
Bot: config.BotConfig{Mode: config.BotModePolling},
}, nil)
router := NewRouter(registry, manager, nil)
for _, route := range []string{
"/api/v1/settings/docker-hosts",
"/api/v1/settings/coolify-hosts",
"/api/v1/settings/auth",
"/api/v1/settings/bot",
} {
req := httptest.NewRequest(http.MethodPut, route, strings.NewReader(`{}`))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected %d for %s, got %d: %s", http.StatusForbidden, route, rec.Code, rec.Body.String())
}
}
}
func newTestSettingsManager(t *testing.T) *config.Manager {
t.Helper()
t.Setenv("CONFIG_PATH", t.TempDir()+"/config.json")
t.Setenv("BOT_ENABLED", "")
t.Setenv("BOT_MODE", "")
t.Setenv("BOT_TELEGRAM_TOKEN", "")
t.Setenv("BOT_ALLOWED_CHAT_ID", "")
t.Setenv("BOT_POLL_INTERVAL", "")
t.Setenv("BOT_DISCORD_ENABLED", "")
t.Setenv("BOT_DISCORD_TOKEN", "")
t.Setenv("BOT_DISCORD_APPLICATION_ID", "")
t.Setenv("BOT_DISCORD_GUILD_ID", "")
t.Setenv("BOT_DISCORD_ALLOWED_CHANNEL_ID", "")
return config.NewManager()
}

View file

@ -5,6 +5,7 @@ import (
"encoding/hex"
"errors"
"os"
"strconv"
"time"
"github.com/golang-jwt/jwt/v5"
@ -189,5 +190,19 @@ func HashPassword(password string) (string, error) {
}
func isBcryptHash(hash string) bool {
return len(hash) >= 4 && hash[0] == '$' && hash[1] == '2'
if len(hash) != 60 {
return false
}
prefix := hash[:4]
if prefix != "$2a$" && prefix != "$2b$" && prefix != "$2x$" && prefix != "$2y$" {
return false
}
if hash[6] != '$' {
return false
}
cost, err := strconv.Atoi(hash[4:6])
if err != nil {
return false
}
return cost >= 4 && cost <= 31
}

View file

@ -122,3 +122,32 @@ func TestDynamicMiddlewareNoLastGoodServiceReturnsServiceUnavailable(t *testing.
t.Fatalf("expected %d, got %d", http.StatusServiceUnavailable, rec.Code)
}
}
func TestIsBcryptHashFormatValidation(t *testing.T) {
validHash, err := HashPassword("super-secret")
if err != nil {
t.Fatalf("HashPassword failed: %v", err)
}
if !isBcryptHash(validHash) {
t.Fatalf("expected generated hash to be recognized as bcrypt")
}
cases := []string{
"",
"$2z$10$abcdefghijklmnopqrstuvwxyzABCDE1234567890abcdefghiJKL",
"$2b$aa$abcdefghijklmnopqrstuvwxyzABCDE1234567890abcdefghiJKL",
"$2b$10:abcdefghijklmnopqrstuvwxyzABCDE1234567890abcdefghiJKL",
"$2b$10$too-short",
}
for _, tc := range cases {
if isBcryptHash(tc) {
t.Fatalf("expected invalid bcrypt format to be rejected: %q", tc)
}
}
legacyPrefixHash := "$2x$10$abcdefghijklmnopqrstuvwxyzABCDE1234567890abcdefghiJKL"
if !isBcryptHash(legacyPrefixHash) {
t.Fatalf("expected legacy $2x$ prefix to be accepted")
}
}

View file

@ -0,0 +1,166 @@
package bot
import (
"context"
"fmt"
"sort"
"strings"
"sync"
"time"
"github.com/hhftechnology/vps-monitor/internal/models"
"github.com/hhftechnology/vps-monitor/internal/services"
)
type commandHandler struct {
registry *services.Registry
}
func newCommandHandler(registry *services.Registry) *commandHandler {
return &commandHandler{registry: registry}
}
func (h *commandHandler) handle(text string) string {
switch {
case strings.HasPrefix(text, "/help"), strings.HasPrefix(text, "/start"):
return "Available commands:\n/status - current container health with history\n/critical - latest critical alerts\n/help - command list"
case strings.HasPrefix(text, "/critical"):
return h.buildCriticalMessage()
case strings.HasPrefix(text, "/status"):
return h.buildStatusMessage()
default:
return "Unknown command. Use /help."
}
}
func (h *commandHandler) buildCriticalMessage() string {
if h.registry == nil {
return "Alert monitoring is disabled."
}
monitor := h.registry.Alerts()
if monitor == nil {
return "Alert monitoring is disabled."
}
alertsList := monitor.GetHistory().GetAll()
critical := make([]models.Alert, 0, len(alertsList))
for _, alert := range alertsList {
if alert.Type == models.AlertCPUThreshold || alert.Type == models.AlertMemoryThreshold {
critical = append(critical, alert)
}
}
if len(critical) == 0 {
return "No critical alerts."
}
sort.SliceStable(critical, func(i, j int) bool {
return critical[i].Timestamp > critical[j].Timestamp
})
var lines []string
lines = append(lines, "Critical alerts:")
for _, alert := range critical[:min(5, len(critical))] {
lines = append(lines, fmt.Sprintf("- %s on %s (%s)", alert.ContainerName, alert.Host, alert.Type))
}
return strings.Join(lines, "\n")
}
func (h *commandHandler) buildStatusMessage() string {
if h.registry == nil {
return "Docker client unavailable."
}
dockerClient, release := h.registry.AcquireDocker()
defer release()
if dockerClient == nil {
return "Docker client unavailable."
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
containersMap, _, err := dockerClient.ListContainersAllHosts(ctx)
if err != nil {
return fmt.Sprintf("Failed to list containers: %v", err)
}
type containerLine struct {
name string
cpu float64
line string
}
var lines []containerLine
var linesMu sync.Mutex
var wg sync.WaitGroup
total := 0
running := 0
history := h.registry.Alerts()
var historyManager interface {
Get1hAverages(string, string) (float64, float64, bool)
Get12hAverages(string, string) (float64, float64, bool)
}
if history != nil {
historyManager = history.GetStatsHistory()
}
for hostName, containers := range containersMap {
for _, ctr := range containers {
total++
if ctr.State != "running" {
continue
}
running++
wg.Add(1)
go func(hName string, c models.ContainerInfo) {
defer wg.Done()
stats, err := dockerClient.GetContainerStatsOnce(ctx, hName, c.ID)
if err != nil {
return
}
name := c.ID[:12]
if len(c.Names) > 0 {
name = strings.TrimPrefix(c.Names[0], "/")
}
line := fmt.Sprintf("- %s@%s CPU %.1f%% MEM %.1f%%", name, hName, stats.CPUPercent, stats.MemoryPercent)
if historyManager != nil {
cpu1h, mem1h, has1h := historyManager.Get1hAverages(hName, c.ID)
cpu12h, mem12h, has12h := historyManager.Get12hAverages(hName, c.ID)
line = appendHistoryAverages(line, cpu1h, mem1h, has1h, cpu12h, mem12h, has12h)
}
linesMu.Lock()
lines = append(lines, containerLine{name: name, cpu: stats.CPUPercent, line: line})
linesMu.Unlock()
}(hostName, ctr)
}
}
wg.Wait()
sort.SliceStable(lines, func(i, j int) bool {
return lines[i].cpu > lines[j].cpu
})
message := []string{
fmt.Sprintf("Containers: %d total, %d running", total, running),
}
for _, line := range lines[:min(5, len(lines))] {
message = append(message, line.line)
}
return strings.Join(message, "\n")
}
func appendHistoryAverages(line string, cpu1h, mem1h float64, has1h bool, cpu12h, mem12h float64, has12h bool) string {
if has1h {
line += fmt.Sprintf(" | 1h %.1f/%.1f", cpu1h, mem1h)
}
if has12h {
line += fmt.Sprintf(" | 12h %.1f/%.1f", cpu12h, mem12h)
}
return line
}

View file

@ -0,0 +1,24 @@
package bot
import (
"strings"
"testing"
)
func TestAppendHistoryAveragesIncludesOnlyAvailableWindows(t *testing.T) {
line := appendHistoryAverages("container", 0, 0, false, 12, 34, true)
if strings.Contains(line, "1h") {
t.Fatalf("did not expect 1h segment, got %q", line)
}
if !strings.Contains(line, "12h 12.0/34.0") {
t.Fatalf("expected 12h segment, got %q", line)
}
line = appendHistoryAverages("container", 10, 20, true, 0, 0, false)
if !strings.Contains(line, "1h 10.0/20.0") {
t.Fatalf("expected 1h segment, got %q", line)
}
if strings.Contains(line, "12h") {
t.Fatalf("did not expect 12h segment, got %q", line)
}
}

View file

@ -0,0 +1,485 @@
package bot
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/hhftechnology/vps-monitor/internal/config"
)
const (
discordAPIBase = "https://discord.com/api/v10"
discordOpDispatch = 0
discordOpHeartbeat = 1
discordOpIdentify = 2
discordOpResume = 6
discordOpReconnect = 7
discordOpInvalidSession = 9
discordOpHello = 10
discordOpHeartbeatACK = 11
discordInteractionApplicationCommand = 2
discordResponseDeferredMessage = 5
discordMessageFlagEphemeral = 64
)
type websocketDialer interface {
Dial(urlStr string, requestHeader http.Header) (*websocket.Conn, *http.Response, error)
}
type discordGatewayResponse struct {
URL string `json:"url"`
}
type discordGatewayPayload struct {
Op int `json:"op"`
D json.RawMessage `json:"d"`
S *int64 `json:"s,omitempty"`
T string `json:"t,omitempty"`
}
type discordHelloPayload struct {
HeartbeatInterval int `json:"heartbeat_interval"`
}
type discordReadyPayload struct {
SessionID string `json:"session_id"`
ResumeGatewayURL string `json:"resume_gateway_url"`
}
type discordInteraction struct {
ID string `json:"id"`
Token string `json:"token"`
Type int `json:"type"`
GuildID string `json:"guild_id"`
ChannelID string `json:"channel_id"`
Data struct {
Name string `json:"name"`
} `json:"data"`
}
type discordCommand struct {
Name string `json:"name"`
Description string `json:"description"`
Type int `json:"type"`
}
func (s *Service) startDiscordLocked() {
if s.discordRunning || !isDiscordConfigured(s.cfg.Discord) {
return
}
cfg := s.cfg
s.discordStopCh = make(chan struct{})
s.discordDoneCh = make(chan struct{})
s.discordRunning = true
go s.discordLoop(cfg, s.discordStopCh, s.discordDoneCh)
}
func (s *Service) stopDiscord() {
s.mu.Lock()
if !s.discordRunning {
s.mu.Unlock()
return
}
stopCh := s.discordStopCh
doneCh := s.discordDoneCh
s.discordRunning = false
s.discordStopCh = nil
s.discordDoneCh = nil
s.mu.Unlock()
close(stopCh)
<-doneCh
}
func (s *Service) discordLoop(cfg config.BotConfig, stopCh <-chan struct{}, doneCh chan<- struct{}) {
defer close(doneCh)
if err := s.registerDiscordCommands(context.Background(), cfg.Discord); err != nil {
log.Printf("discord bot command registration failed: %v", err)
}
for {
select {
case <-stopCh:
return
default:
}
if err := s.runDiscordConnection(cfg, stopCh); err != nil {
log.Printf("discord bot gateway failed: %v", err)
}
select {
case <-time.After(5 * time.Second):
case <-stopCh:
return
}
}
}
func (s *Service) runDiscordConnection(cfg config.BotConfig, stopCh <-chan struct{}) error {
gatewayURL, err := s.discordGatewayURL(context.Background(), cfg.Discord.BotToken)
if err != nil {
return err
}
sessionID, resumeURL, _ := s.discordSession()
connectURL := gatewayURL
resuming := sessionID != "" && resumeURL != ""
if resuming {
connectURL = resumeURL
}
if !strings.Contains(connectURL, "?") {
connectURL += "?v=10&encoding=json"
}
conn, _, err := s.discordDialer.Dial(connectURL, nil)
if err != nil {
return err
}
defer conn.Close()
stopped := make(chan struct{})
go func() {
select {
case <-stopCh:
_ = conn.Close()
case <-stopped:
}
}()
defer close(stopped)
var writeMu sync.Mutex
ackMu := sync.Mutex{}
heartbeatAcked := true
heartbeatStop := make(chan struct{})
defer close(heartbeatStop)
for {
var payload discordGatewayPayload
if err := conn.ReadJSON(&payload); err != nil {
select {
case <-stopCh:
return nil
default:
return err
}
}
if payload.S != nil {
s.setDiscordSeq(*payload.S)
}
switch payload.Op {
case discordOpHello:
var hello discordHelloPayload
if err := json.Unmarshal(payload.D, &hello); err != nil {
return err
}
if hello.HeartbeatInterval <= 0 {
return fmt.Errorf("discord gateway hello missing heartbeat interval")
}
go s.discordHeartbeatLoop(conn, &writeMu, &ackMu, &heartbeatAcked, time.Duration(hello.HeartbeatInterval)*time.Millisecond, heartbeatStop)
if resuming {
if err := s.discordResume(conn, &writeMu, cfg.Discord.BotToken, sessionID); err != nil {
return err
}
} else if err := s.discordIdentify(conn, &writeMu, cfg.Discord.BotToken); err != nil {
return err
}
case discordOpHeartbeatACK:
ackMu.Lock()
heartbeatAcked = true
ackMu.Unlock()
case discordOpHeartbeat:
if err := s.discordSendHeartbeat(conn, &writeMu); err != nil {
return err
}
case discordOpReconnect:
return fmt.Errorf("discord gateway requested reconnect")
case discordOpInvalidSession:
s.clearDiscordSession()
return fmt.Errorf("discord gateway invalidated session")
case discordOpDispatch:
if payload.T == "READY" {
var ready discordReadyPayload
if err := json.Unmarshal(payload.D, &ready); err != nil {
return err
}
s.setDiscordSession(ready.SessionID, ready.ResumeGatewayURL)
}
if payload.T == "INTERACTION_CREATE" {
var interaction discordInteraction
if err := json.Unmarshal(payload.D, &interaction); err != nil {
log.Printf("discord interaction decode failed: %v", err)
continue
}
go s.handleDiscordInteraction(context.Background(), cfg.Discord, interaction)
}
}
}
}
func (s *Service) discordHeartbeatLoop(conn *websocket.Conn, writeMu, ackMu *sync.Mutex, acked *bool, interval time.Duration, stopCh <-chan struct{}) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
if err := s.discordSendHeartbeat(conn, writeMu); err != nil {
_ = conn.Close()
return
}
ackMu.Lock()
*acked = false
ackMu.Unlock()
for {
select {
case <-ticker.C:
ackMu.Lock()
if !*acked {
ackMu.Unlock()
_ = conn.Close()
return
}
*acked = false
ackMu.Unlock()
if err := s.discordSendHeartbeat(conn, writeMu); err != nil {
_ = conn.Close()
return
}
case <-stopCh:
return
}
}
}
func (s *Service) discordIdentify(conn *websocket.Conn, writeMu *sync.Mutex, token string) error {
payload := map[string]any{
"op": discordOpIdentify,
"d": map[string]any{
"token": token,
"intents": 0,
"properties": map[string]string{
"os": "linux",
"browser": "vps-monitor",
"device": "vps-monitor",
},
},
}
return discordWriteJSON(conn, writeMu, payload)
}
func (s *Service) discordResume(conn *websocket.Conn, writeMu *sync.Mutex, token, sessionID string) error {
payload := map[string]any{
"op": discordOpResume,
"d": map[string]any{
"token": token,
"session_id": sessionID,
"seq": s.discordSeq(),
},
}
return discordWriteJSON(conn, writeMu, payload)
}
func (s *Service) discordSendHeartbeat(conn *websocket.Conn, writeMu *sync.Mutex) error {
return discordWriteJSON(conn, writeMu, map[string]any{
"op": discordOpHeartbeat,
"d": s.discordSeq(),
})
}
func discordWriteJSON(conn *websocket.Conn, writeMu *sync.Mutex, payload any) error {
writeMu.Lock()
defer writeMu.Unlock()
return conn.WriteJSON(payload)
}
func (s *Service) discordGatewayURL(ctx context.Context, token string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.discordAPIBase+"/gateway/bot", nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bot "+token)
res, err := s.client.Do(req)
if err != nil {
return "", err
}
defer res.Body.Close()
if res.StatusCode >= 300 {
return "", fmt.Errorf("discord gateway lookup returned status %d", res.StatusCode)
}
var payload discordGatewayResponse
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
return "", err
}
if payload.URL == "" {
return "", fmt.Errorf("discord gateway lookup returned empty url")
}
return payload.URL, nil
}
func (s *Service) registerDiscordCommands(ctx context.Context, cfg config.DiscordBotConfig) error {
commands := []discordCommand{
{Name: "help", Description: "Show VPS Monitor bot commands", Type: 1},
{Name: "status", Description: "Show current container health with history", Type: 1},
{Name: "critical", Description: "Show latest critical alerts", Type: 1},
}
endpoint := fmt.Sprintf("%s/applications/%s/commands", s.discordAPIBase, cfg.ApplicationID)
if cfg.GuildID != "" {
endpoint = fmt.Sprintf("%s/applications/%s/guilds/%s/commands", s.discordAPIBase, cfg.ApplicationID, cfg.GuildID)
}
return s.doDiscordJSON(ctx, http.MethodPut, endpoint, cfg.BotToken, commands, nil)
}
func (s *Service) handleDiscordInteraction(ctx context.Context, cfg config.DiscordBotConfig, interaction discordInteraction) {
if interaction.Type != discordInteractionApplicationCommand {
return
}
if err := s.deferDiscordInteraction(ctx, cfg, interaction); err != nil {
log.Printf("discord interaction defer failed: %v", err)
return
}
reply := s.discordInteractionReply(cfg, interaction)
if reply == "" {
reply = "No response."
}
if err := s.editDiscordInteractionResponse(ctx, cfg, interaction.Token, reply); err != nil {
log.Printf("discord interaction response failed: %v", err)
}
}
func (s *Service) discordInteractionReply(cfg config.DiscordBotConfig, interaction discordInteraction) string {
if cfg.GuildID != "" && interaction.GuildID != cfg.GuildID {
return "This Discord server is not allowed."
}
if cfg.AllowedChannelID != "" && interaction.ChannelID != cfg.AllowedChannelID {
return "This Discord channel is not allowed."
}
switch interaction.Data.Name {
case "help", "status", "critical":
return s.commands.handle("/" + interaction.Data.Name)
default:
return "Unknown command. Use /help."
}
}
func (s *Service) deferDiscordInteraction(ctx context.Context, cfg config.DiscordBotConfig, interaction discordInteraction) error {
endpoint := fmt.Sprintf("%s/interactions/%s/%s/callback", s.discordAPIBase, interaction.ID, interaction.Token)
payload := map[string]any{
"type": discordResponseDeferredMessage,
"data": map[string]any{"flags": discordMessageFlagEphemeral},
}
return s.doDiscordJSON(ctx, http.MethodPost, endpoint, cfg.BotToken, payload, nil)
}
func (s *Service) editDiscordInteractionResponse(ctx context.Context, cfg config.DiscordBotConfig, token, content string) error {
endpoint := fmt.Sprintf("%s/webhooks/%s/%s/messages/@original", s.discordAPIBase, cfg.ApplicationID, token)
payload := map[string]any{
"content": content,
"flags": discordMessageFlagEphemeral,
}
return s.doDiscordJSON(ctx, http.MethodPatch, endpoint, cfg.BotToken, payload, nil)
}
func (s *Service) SendDiscordTestMessage(ctx context.Context, token, channelID string) error {
if strings.TrimSpace(token) == "" || strings.TrimSpace(channelID) == "" {
return fmt.Errorf("discord bot token and channel id are required")
}
endpoint := fmt.Sprintf("%s/channels/%s/messages", s.discordAPIBase, strings.TrimSpace(channelID))
payload := map[string]string{"content": "VPS Monitor Discord bot test successful."}
return s.doDiscordJSON(ctx, http.MethodPost, endpoint, strings.TrimSpace(token), payload, nil)
}
func (s *Service) doDiscordJSON(ctx context.Context, method, endpoint, token string, payload, out any) error {
var body *bytes.Reader
if payload == nil {
body = bytes.NewReader(nil)
} else {
data, err := json.Marshal(payload)
if err != nil {
return err
}
body = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, method, endpoint, body)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bot "+token)
req.Header.Set("Content-Type", "application/json")
res, err := s.client.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode >= 300 {
return fmt.Errorf("discord %s returned status %d", method, res.StatusCode)
}
if out == nil {
return nil
}
return json.NewDecoder(res.Body).Decode(out)
}
func (s *Service) discordSession() (string, string, int64) {
s.mu.Lock()
defer s.mu.Unlock()
return s.discordSessionID, s.discordResumeURL, s.discordLastSeq
}
func (s *Service) setDiscordSession(sessionID, resumeURL string) {
s.mu.Lock()
defer s.mu.Unlock()
s.discordSessionID = sessionID
s.discordResumeURL = resumeURL
}
func (s *Service) clearDiscordSession() {
s.mu.Lock()
defer s.mu.Unlock()
s.discordSessionID = ""
s.discordResumeURL = ""
s.discordLastSeq = 0
}
func (s *Service) discordSeq() int64 {
s.mu.Lock()
defer s.mu.Unlock()
return s.discordLastSeq
}
func (s *Service) setDiscordSeq(seq int64) {
s.mu.Lock()
defer s.mu.Unlock()
s.discordLastSeq = seq
}
func isDiscordConfigured(cfg config.DiscordBotConfig) bool {
return cfg.Enabled &&
strings.TrimSpace(cfg.BotToken) != "" &&
strings.TrimSpace(cfg.ApplicationID) != "" &&
strings.TrimSpace(cfg.AllowedChannelID) != ""
}

View file

@ -0,0 +1,310 @@
package bot
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/hhftechnology/vps-monitor/internal/config"
"github.com/hhftechnology/vps-monitor/internal/services"
)
const telegramAPIBase = "https://api.telegram.org"
var (
ErrRelayDisabled = errors.New("bot relay mode is disabled")
ErrRelayNotConfigured = errors.New("telegram token and allowed chat id are required")
ErrRelayChatNotAllowed = errors.New("chat id is not allowed")
ErrTelegramSendFailed = errors.New("telegram send failed")
)
type Service struct {
mu sync.Mutex
restartMu sync.Mutex
registry *services.Registry
commands *commandHandler
client *http.Client
discordAPIBase string
discordDialer websocketDialer
cfg config.BotConfig
running bool
stopCh chan struct{}
doneCh chan struct{}
offset int64
discordRunning bool
discordStopCh chan struct{}
discordDoneCh chan struct{}
discordSessionID string
discordResumeURL string
discordLastSeq int64
}
type telegramUpdateResponse struct {
OK bool `json:"ok"`
Result []telegramUpdate `json:"result"`
}
type telegramUpdate struct {
UpdateID int64 `json:"update_id"`
Message *telegramMessage `json:"message"`
}
type telegramMessage struct {
MessageID int64 `json:"message_id"`
Text string `json:"text"`
Chat telegramChat `json:"chat"`
}
type telegramChat struct {
ID int64 `json:"id"`
}
func NewService(registry *services.Registry, cfg config.BotConfig) *Service {
return &Service{
registry: registry,
client: &http.Client{
Timeout: 35 * time.Second,
},
commands: newCommandHandler(registry),
discordAPIBase: discordAPIBase,
discordDialer: websocket.DefaultDialer,
cfg: cfg,
}
}
func (s *Service) Start() {
s.mu.Lock()
defer s.mu.Unlock()
if !s.running && isConfigured(s.cfg) && s.cfg.Mode == config.BotModePolling {
s.stopCh = make(chan struct{})
s.doneCh = make(chan struct{})
s.running = true
go s.pollLoop(s.cfg, s.stopCh, s.doneCh)
}
s.startDiscordLocked()
}
func (s *Service) Stop() {
s.stopDiscord()
s.mu.Lock()
if !s.running {
s.mu.Unlock()
return
}
stopCh := s.stopCh
doneCh := s.doneCh
s.running = false
s.stopCh = nil
s.doneCh = nil
s.mu.Unlock()
close(stopCh)
<-doneCh
}
func (s *Service) UpdateConfig(cfg config.BotConfig) {
s.restartMu.Lock()
defer s.restartMu.Unlock()
s.Stop()
s.mu.Lock()
s.cfg = cfg
s.offset = 0
s.discordLastSeq = 0
s.discordSessionID = ""
s.discordResumeURL = ""
s.mu.Unlock()
s.Start()
}
func (s *Service) RelayCommand(ctx context.Context, chatID, text string) (string, error) {
s.mu.Lock()
cfg := s.cfg
s.mu.Unlock()
if cfg.Mode != config.BotModeJWTRelay {
return "", ErrRelayDisabled
}
if !isConfigured(cfg) {
return "", ErrRelayNotConfigured
}
text = strings.TrimSpace(text)
targetChatID := strings.TrimSpace(chatID)
if targetChatID == "" {
targetChatID = cfg.AllowedChatID
}
if cfg.AllowedChatID != "" && targetChatID != cfg.AllowedChatID {
return "", ErrRelayChatNotAllowed
}
reply := s.commands.handle(text)
if reply == "" {
return "", nil
}
if err := s.sendMessage(ctx, cfg.TelegramToken, targetChatID, reply); err != nil {
return "", fmt.Errorf("%w: %v", ErrTelegramSendFailed, err)
}
return reply, nil
}
func (s *Service) SendTestMessage(ctx context.Context, token, chatID string) error {
if strings.TrimSpace(token) == "" || strings.TrimSpace(chatID) == "" {
return fmt.Errorf("telegram token and chat id are required")
}
return s.sendMessage(ctx, token, chatID, "VPS Monitor bot test successful.")
}
func (s *Service) pollLoop(cfg config.BotConfig, stopCh <-chan struct{}, doneCh chan<- struct{}) {
defer close(doneCh)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
select {
case <-stopCh:
cancel()
case <-ctx.Done():
}
}()
for {
select {
case <-ctx.Done():
return
default:
}
if err := s.pollOnce(ctx, cfg); err != nil {
if errors.Is(err, context.Canceled) {
return
}
log.Printf("telegram bot poll failed: %v", err)
select {
case <-time.After(5 * time.Second):
case <-ctx.Done():
return
}
}
}
}
func (s *Service) pollOnce(ctx context.Context, cfg config.BotConfig) error {
params := url.Values{}
params.Set("timeout", strconv.Itoa(int(cfg.PollInterval.Seconds())))
s.mu.Lock()
offset := s.offset
s.mu.Unlock()
if offset > 0 {
params.Set("offset", strconv.FormatInt(offset, 10))
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.apiURL(cfg.TelegramToken, "getUpdates", params), nil)
if err != nil {
return err
}
res, err := s.client.Do(req)
if err != nil {
return sanitizeError(err, cfg.TelegramToken)
}
defer res.Body.Close()
if res.StatusCode >= 300 {
return fmt.Errorf("telegram getUpdates returned status %d", res.StatusCode)
}
var payload telegramUpdateResponse
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
return err
}
for _, update := range payload.Result {
s.mu.Lock()
if update.UpdateID >= s.offset {
s.offset = update.UpdateID + 1
}
s.mu.Unlock()
if update.Message == nil {
continue
}
if cfg.AllowedChatID != "" && strconv.FormatInt(update.Message.Chat.ID, 10) != cfg.AllowedChatID {
continue
}
reply := s.commands.handle(strings.TrimSpace(update.Message.Text))
if reply == "" {
continue
}
if err := s.sendMessage(ctx, cfg.TelegramToken, strconv.FormatInt(update.Message.Chat.ID, 10), reply); err != nil {
log.Printf("telegram bot send failed: %v", err)
}
}
return nil
}
func (s *Service) sendMessage(ctx context.Context, token, chatID, text string) error {
form := url.Values{}
form.Set("chat_id", chatID)
form.Set("text", text)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.apiURL(token, "sendMessage", nil), strings.NewReader(form.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
res, err := s.client.Do(req)
if err != nil {
return sanitizeError(err, token)
}
defer res.Body.Close()
if res.StatusCode >= 300 {
return fmt.Errorf("telegram sendMessage returned status %d", res.StatusCode)
}
return nil
}
func (s *Service) apiURL(token, method string, params url.Values) string {
base := fmt.Sprintf("%s/bot%s/%s", telegramAPIBase, token, method)
if params == nil || len(params) == 0 {
return base
}
return base + "?" + params.Encode()
}
func isConfigured(cfg config.BotConfig) bool {
return cfg.Enabled && strings.TrimSpace(cfg.TelegramToken) != "" && strings.TrimSpace(cfg.AllowedChatID) != ""
}
func sanitizeError(err error, token string) error {
if err == nil || token == "" {
return err
}
return errors.New(strings.ReplaceAll(err.Error(), token, "***"))
}

View file

@ -0,0 +1,179 @@
package bot
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"strings"
"testing"
"github.com/hhftechnology/vps-monitor/internal/config"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return fn(req)
}
func TestStartSkipsPollingInRelayMode(t *testing.T) {
svc := NewService(nil, config.BotConfig{
Enabled: true,
Mode: config.BotModeJWTRelay,
TelegramToken: "token",
AllowedChatID: "chat",
})
svc.Start()
if svc.running {
t.Fatal("expected relay mode to skip polling startup")
}
}
func TestRelayCommandRejectsUnexpectedChat(t *testing.T) {
svc := NewService(nil, config.BotConfig{
Enabled: true,
Mode: config.BotModeJWTRelay,
TelegramToken: "token",
AllowedChatID: "chat-1",
})
_, err := svc.RelayCommand(context.Background(), "chat-2", "/help")
if !errors.Is(err, ErrRelayChatNotAllowed) {
t.Fatalf("expected not allowed error, got %v", err)
}
}
func TestRelayCommandSendsReplyViaTelegram(t *testing.T) {
var gotChatID string
var gotText string
svc := NewService(nil, config.BotConfig{
Enabled: true,
Mode: config.BotModeJWTRelay,
TelegramToken: "token",
AllowedChatID: "chat-1",
})
svc.client = &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
body, err := io.ReadAll(req.Body)
if err != nil {
return nil, err
}
values, err := url.ParseQuery(string(body))
if err != nil {
return nil, err
}
gotChatID = values.Get("chat_id")
gotText = values.Get("text")
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"ok":true}`)),
Header: make(http.Header),
}, nil
}),
}
reply, err := svc.RelayCommand(context.Background(), "", "/help")
if err != nil {
t.Fatalf("RelayCommand returned error: %v", err)
}
if gotChatID != "chat-1" {
t.Fatalf("expected chat-1, got %q", gotChatID)
}
if gotText == "" || gotText != reply {
t.Fatalf("expected reply to be sent, got text=%q reply=%q", gotText, reply)
}
}
func TestPollOnceUsesCancellableContext(t *testing.T) {
svc := NewService(nil, config.BotConfig{
Enabled: true,
Mode: config.BotModePolling,
TelegramToken: "token",
AllowedChatID: "chat-1",
})
svc.client = &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
<-req.Context().Done()
return nil, req.Context().Err()
}),
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := svc.pollOnce(ctx, svc.cfg)
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected context canceled, got %v", err)
}
}
func TestSharedCommandHandlerKeepsTelegramHelpReply(t *testing.T) {
reply := newCommandHandler(nil).handle("/help")
if !strings.Contains(reply, "/status") || !strings.Contains(reply, "/critical") {
t.Fatalf("expected help reply to list existing commands, got %q", reply)
}
}
func TestDiscordInteractionReplyRejectsUnexpectedChannel(t *testing.T) {
svc := NewService(nil, config.BotConfig{})
var interaction discordInteraction
interaction.Type = discordInteractionApplicationCommand
interaction.ChannelID = "channel-2"
interaction.Data.Name = "help"
reply := svc.discordInteractionReply(config.DiscordBotConfig{
Enabled: true,
BotToken: "token",
ApplicationID: "app",
AllowedChannelID: "channel-1",
}, interaction)
if !strings.Contains(reply, "channel is not allowed") {
t.Fatalf("expected channel rejection, got %q", reply)
}
}
func TestSendDiscordTestMessagePostsToChannel(t *testing.T) {
var gotAuth string
var gotPath string
var gotContent string
svc := NewService(nil, config.BotConfig{})
svc.client = &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
gotAuth = req.Header.Get("Authorization")
gotPath = req.URL.Path
var body struct {
Content string `json:"content"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
return nil, err
}
gotContent = body.Content
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"id":"message-1"}`)),
Header: make(http.Header),
}, nil
}),
}
svc.discordAPIBase = "https://discord.test"
if err := svc.SendDiscordTestMessage(context.Background(), "token-1", "channel-1"); err != nil {
t.Fatalf("SendDiscordTestMessage returned error: %v", err)
}
if gotAuth != "Bot token-1" {
t.Fatalf("unexpected auth header %q", gotAuth)
}
if gotPath != "/channels/channel-1/messages" {
t.Fatalf("unexpected path %q", gotPath)
}
if !strings.Contains(gotContent, "VPS Monitor Discord bot test successful") {
t.Fatalf("unexpected content %q", gotContent)
}
}

View file

@ -37,6 +37,56 @@ type AlertConfig struct {
CPUThreshold float64 // 0-100, alert when exceeded
MemoryThreshold float64 // 0-100, alert when exceeded
CheckInterval time.Duration // How often to check thresholds
AlertsFilter string
}
type StatsConfig struct {
SampleInterval time.Duration
}
type BotConfig struct {
Enabled bool
Mode string
TelegramToken string
AllowedChatID string
PollInterval time.Duration
Discord DiscordBotConfig
}
type DiscordBotConfig struct {
Enabled bool
BotToken string
ApplicationID string
GuildID string
AllowedChannelID string
}
const (
BotModePolling = "polling"
BotModeJWTRelay = "jwt-relay"
)
// ScannerConfig holds configuration for vulnerability scanning
type ScannerConfig struct {
GrypeImage string
TrivyImage string
SyftImage string
DefaultScanner string
GrypeArgs string
TrivyArgs string
DiscordWebhookURL string
SlackWebhookURL string
NotifyOnComplete bool
NotifyOnBulk bool
NotifyOnNewCVEs bool
NotifyMinSeverity string
AutoScanEnabled bool
AutoScanPollInterval int // minutes, default 15
ForceRescan bool
ScanTimeoutMinutes int // default 20
BulkTimeoutMinutes int // default 120
ScannerMemoryMB int // default 2048
ScannerPidsLimit int // default 512
}
type Config struct {
@ -45,6 +95,9 @@ type Config struct {
DockerHosts []DockerHost
CoolifyHosts []CoolifyHostConfig
Alerts AlertConfig
Stats StatsConfig
Bot BotConfig
Scanner ScannerConfig
}
func NewConfig() *Config {
@ -53,6 +106,8 @@ func NewConfig() *Config {
dockerHosts := parseDockerHosts()
coolifyHosts := parseCoolifyHostConfigs()
alertConfig := parseAlertConfig()
statsConfig := parseStatsConfig(alertConfig.CheckInterval)
botConfig := parseBotConfig()
// if we don't have any docker hosts, we should default back to
// the unix socket on the machine running vps-monitor.
@ -71,12 +126,17 @@ func NewConfig() *Config {
}
}
scannerConfig := parseScannerConfig()
return &Config{
ReadOnly: isReadOnlyMode,
Hostname: hostname,
DockerHosts: dockerHosts,
CoolifyHosts: coolifyHosts,
Alerts: alertConfig,
Stats: statsConfig,
Bot: botConfig,
Scanner: scannerConfig,
}
}
@ -87,6 +147,7 @@ func parseAlertConfig() AlertConfig {
CPUThreshold: 80, // Default: 80%
MemoryThreshold: 90, // Default: 90%
CheckInterval: 30 * time.Second,
AlertsFilter: "all",
}
if cpuStr := os.Getenv("ALERTS_CPU_THRESHOLD"); cpuStr != "" {
@ -107,9 +168,75 @@ func parseAlertConfig() AlertConfig {
}
}
switch filter := strings.ToLower(strings.TrimSpace(os.Getenv("ALERTS_FILTER"))); filter {
case "", "all":
config.AlertsFilter = "all"
case "critical":
config.AlertsFilter = "critical"
default:
config.AlertsFilter = "all"
}
return config
}
func parseStatsConfig(alertsCheckInterval time.Duration) StatsConfig {
config := StatsConfig{
SampleInterval: alertsCheckInterval,
}
if intervalStr := strings.TrimSpace(os.Getenv("STATS_SAMPLE_INTERVAL")); intervalStr != "" {
if interval, err := time.ParseDuration(intervalStr); err == nil && interval > 0 {
config.SampleInterval = interval
}
}
return config
}
func parseBotConfig() BotConfig {
cfg := BotConfig{
Enabled: os.Getenv("BOT_ENABLED") == "true",
Mode: NormalizeBotMode(os.Getenv("BOT_MODE")),
TelegramToken: strings.TrimSpace(os.Getenv("BOT_TELEGRAM_TOKEN")),
AllowedChatID: strings.TrimSpace(os.Getenv("BOT_ALLOWED_CHAT_ID")),
PollInterval: 15 * time.Second,
Discord: DiscordBotConfig{
Enabled: os.Getenv("BOT_DISCORD_ENABLED") == "true",
BotToken: strings.TrimSpace(os.Getenv("BOT_DISCORD_TOKEN")),
ApplicationID: strings.TrimSpace(os.Getenv("BOT_DISCORD_APPLICATION_ID")),
GuildID: strings.TrimSpace(os.Getenv("BOT_DISCORD_GUILD_ID")),
AllowedChannelID: strings.TrimSpace(os.Getenv("BOT_DISCORD_ALLOWED_CHANNEL_ID")),
},
}
if intervalStr := strings.TrimSpace(os.Getenv("BOT_POLL_INTERVAL")); intervalStr != "" {
if interval, err := time.ParseDuration(intervalStr); err == nil && interval > 0 {
cfg.PollInterval = interval
}
}
if cfg.TelegramToken == "" || cfg.AllowedChatID == "" {
cfg.Enabled = false
}
if cfg.Discord.BotToken == "" || cfg.Discord.ApplicationID == "" || cfg.Discord.AllowedChannelID == "" {
cfg.Discord.Enabled = false
}
return cfg
}
func NormalizeBotMode(raw string) string {
switch strings.TrimSpace(raw) {
case "", BotModePolling:
return BotModePolling
case BotModeJWTRelay:
return BotModeJWTRelay
default:
return BotModePolling
}
}
func parseCoolifyHostConfigs() []CoolifyHostConfig {
// Format: COOLIFY_CONFIGS=hostA|https://coolify-a.com|tokenA,hostB|https://coolify-b.com|tokenB
raw := os.Getenv("COOLIFY_CONFIGS")
@ -151,6 +278,112 @@ func parseCoolifyHostConfigs() []CoolifyHostConfig {
return configs
}
func parseScannerConfig() ScannerConfig {
cfg := ScannerConfig{
GrypeImage: "anchore/grype:v0.110.0",
TrivyImage: "aquasec/trivy:0.69.3",
SyftImage: "anchore/syft:v1.42.3",
DefaultScanner: "grype",
GrypeArgs: "",
TrivyArgs: "",
DiscordWebhookURL: os.Getenv("SCANNER_DISCORD_WEBHOOK_URL"),
SlackWebhookURL: os.Getenv("SCANNER_SLACK_WEBHOOK_URL"),
NotifyOnComplete: true,
NotifyOnBulk: true,
NotifyOnNewCVEs: true,
NotifyMinSeverity: "High",
AutoScanEnabled: false,
AutoScanPollInterval: 15,
ForceRescan: false,
ScanTimeoutMinutes: 20,
BulkTimeoutMinutes: 120,
ScannerMemoryMB: 2048,
ScannerPidsLimit: 512,
}
if v := os.Getenv("SCANNER_GRYPE_IMAGE"); v != "" {
cfg.GrypeImage = v
}
if v := os.Getenv("SCANNER_TRIVY_IMAGE"); v != "" {
cfg.TrivyImage = v
}
if v := os.Getenv("SCANNER_SYFT_IMAGE"); v != "" {
cfg.SyftImage = v
}
if v := os.Getenv("SCANNER_DEFAULT"); v != "" {
cfg.DefaultScanner = v
}
if v := os.Getenv("SCANNER_GRYPE_ARGS"); v != "" {
cfg.GrypeArgs = v
}
if v := os.Getenv("SCANNER_TRIVY_ARGS"); v != "" {
cfg.TrivyArgs = v
}
if v := os.Getenv("SCANNER_NOTIFY_ON_COMPLETE"); v != "" {
if b, err := strconv.ParseBool(v); err == nil {
cfg.NotifyOnComplete = b
}
}
if v := os.Getenv("SCANNER_NOTIFY_ON_BULK"); v != "" {
if b, err := strconv.ParseBool(v); err == nil {
cfg.NotifyOnBulk = b
}
}
if v := os.Getenv("SCANNER_NOTIFY_ON_NEW_CVES"); v != "" {
if b, err := strconv.ParseBool(v); err == nil {
cfg.NotifyOnNewCVEs = b
}
}
if v := os.Getenv("SCANNER_AUTO_SCAN"); v != "" {
if b, err := strconv.ParseBool(v); err == nil {
cfg.AutoScanEnabled = b
}
}
if v := os.Getenv("SCANNER_FORCE_RESCAN"); v != "" {
if b, err := strconv.ParseBool(v); err == nil {
cfg.ForceRescan = b
}
}
if v := os.Getenv("SCANNER_NOTIFY_MIN_SEVERITY"); v != "" {
cfg.NotifyMinSeverity = v
}
if v := os.Getenv("SCANNER_AUTO_SCAN_POLL_INTERVAL"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
cfg.AutoScanPollInterval = n
}
}
if v := os.Getenv("SCANNER_TIMEOUT_MINUTES"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
cfg.ScanTimeoutMinutes = n
}
}
if v := os.Getenv("SCANNER_BULK_TIMEOUT_MINUTES"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
cfg.BulkTimeoutMinutes = n
}
}
if v := os.Getenv("SCANNER_MEMORY_MB"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
cfg.ScannerMemoryMB = n
}
}
if v := os.Getenv("SCANNER_PIDS_LIMIT"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
cfg.ScannerPidsLimit = n
}
}
if cfg.DefaultScanner != "grype" && cfg.DefaultScanner != "trivy" {
cfg.DefaultScanner = "grype"
}
if cfg.NotifyMinSeverity != "Low" && cfg.NotifyMinSeverity != "Medium" &&
cfg.NotifyMinSeverity != "High" && cfg.NotifyMinSeverity != "Critical" {
cfg.NotifyMinSeverity = "High"
}
return cfg
}
func parseDockerHosts() []DockerHost {
// Format: DOCKER_HOSTS=local=unix:///var/run/docker.sock,remote=ssh://root@X.X.X.X
dockerHosts := os.Getenv("DOCKER_HOSTS")

Some files were not shown because too many files have changed in this diff Show more