mirror of
https://github.com/hhftechnology/vps-monitor.git
synced 2026-04-28 03:29:55 +00:00
Compare commits
57 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dccad13d49 | ||
|
|
bc16708f96 | ||
|
|
e87978fb18 | ||
|
|
d96df77222 | ||
|
|
c42846cc89 | ||
|
|
6a7a393d7f | ||
|
|
4bba6c16cc | ||
|
|
81b28d62a8 | ||
|
|
fd455dabec | ||
|
|
21a5f9e01f | ||
|
|
e0e2a5f43e | ||
|
|
7601c95f2d | ||
|
|
aaf2d8b7fc | ||
|
|
af54d6ff1f | ||
|
|
e09bdaf75f | ||
|
|
4c6f0ef9cd | ||
|
|
c58c9ba618 | ||
|
|
38fee0a7af | ||
|
|
438b412c39 | ||
|
|
dc3e081818 | ||
|
|
41a4e02cc4 | ||
|
|
8b746a296a | ||
|
|
4307e07b51 | ||
|
|
9903934ca1 | ||
|
|
4c21ff4b95 | ||
|
|
61fc011c13 | ||
|
|
92d321c5e2 | ||
|
|
39cfbce718 | ||
|
|
b600fe542e | ||
|
|
a6aec1cd9c | ||
|
|
28160ad1d2 | ||
|
|
3787161e9f | ||
|
|
1fb729fc9e | ||
|
|
5b9589de7a | ||
|
|
87b0c1fca6 | ||
|
|
bf9f1fe3d7 | ||
|
|
2a5150534f | ||
|
|
6be667a277 | ||
|
|
65413ccf44 | ||
|
|
91e6558385 | ||
|
|
c715d70387 | ||
|
|
a40a261f75 | ||
|
|
29ae03bdfa | ||
|
|
7d104afa81 | ||
|
|
a6a2505d6e | ||
|
|
e7ed5f0286 | ||
|
|
02ba3e440b | ||
|
|
d7cd90a3ab | ||
|
|
9821b94b68 | ||
|
|
f9aba773fa | ||
|
|
df9ee7110b | ||
|
|
6bbec26f5a | ||
|
|
ccef23d065 | ||
|
|
31242dfda5 | ||
|
|
bbd4a09888 | ||
|
|
07be599ec6 | ||
|
|
087b24762a |
136 changed files with 21857 additions and 2127 deletions
110
.github/workflows/docker-publish.yml
vendored
110
.github/workflows/docker-publish.yml
vendored
|
|
@ -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
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
BIN
.github/workflows/logotype-light.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 209 KiB |
37
Readme.md
37
Readme.md
|
|
@ -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 @@
|
|||
[](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> <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
|
||||
|
|
|
|||
|
|
@ -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=="],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
169
frontend/src/components/ui/chart.tsx
Normal file
169
frontend/src/components/ui/chart.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 ?? [],
|
||||
};
|
||||
}
|
||||
|
|
@ -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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
157
frontend/src/features/scanner/api/generate-sbom.test.ts
Normal file
157
frontend/src/features/scanner/api/generate-sbom.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
71
frontend/src/features/scanner/api/generate-sbom.ts
Normal file
71
frontend/src/features/scanner/api/generate-sbom.ts
Normal 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();
|
||||
}
|
||||
47
frontend/src/features/scanner/api/get-sbom-history.test.ts
Normal file
47
frontend/src/features/scanner/api/get-sbom-history.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
102
frontend/src/features/scanner/api/get-sbom-history.ts
Normal file
102
frontend/src/features/scanner/api/get-sbom-history.ts
Normal 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 };
|
||||
}
|
||||
99
frontend/src/features/scanner/api/get-scan-history.ts
Normal file
99
frontend/src/features/scanner/api/get-scan-history.ts
Normal 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);
|
||||
}
|
||||
123
frontend/src/features/scanner/api/get-scan-jobs.test.ts
Normal file
123
frontend/src/features/scanner/api/get-scan-jobs.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
44
frontend/src/features/scanner/api/get-scan-jobs.ts
Normal file
44
frontend/src/features/scanner/api/get-scan-jobs.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
115
frontend/src/features/scanner/api/get-scan-results.test.ts
Normal file
115
frontend/src/features/scanner/api/get-scan-results.test.ts
Normal 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"));
|
||||
});
|
||||
});
|
||||
40
frontend/src/features/scanner/api/get-scan-results.ts
Normal file
40
frontend/src/features/scanner/api/get-scan-results.ts
Normal 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;
|
||||
}
|
||||
183
frontend/src/features/scanner/api/scanner-config.test.ts
Normal file
183
frontend/src/features/scanner/api/scanner-config.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
45
frontend/src/features/scanner/api/scanner-config.ts
Normal file
45
frontend/src/features/scanner/api/scanner-config.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
76
frontend/src/features/scanner/api/start-bulk-scan.test.ts
Normal file
76
frontend/src/features/scanner/api/start-bulk-scan.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
27
frontend/src/features/scanner/api/start-bulk-scan.ts
Normal file
27
frontend/src/features/scanner/api/start-bulk-scan.ts
Normal 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;
|
||||
}
|
||||
84
frontend/src/features/scanner/api/start-scan.test.ts
Normal file
84
frontend/src/features/scanner/api/start-scan.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
44
frontend/src/features/scanner/api/start-scan.ts
Normal file
44
frontend/src/features/scanner/api/start-scan.ts
Normal 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;
|
||||
}
|
||||
267
frontend/src/features/scanner/components/bulk-scan-dialog.tsx
Normal file
267
frontend/src/features/scanner/components/bulk-scan-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
309
frontend/src/features/scanner/components/sbom-dialog.tsx
Normal file
309
frontend/src/features/scanner/components/sbom-dialog.tsx
Normal 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 • {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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
407
frontend/src/features/scanner/components/sbom-history-page.tsx
Normal file
407
frontend/src/features/scanner/components/sbom-history-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
241
frontend/src/features/scanner/components/scan-dialog.tsx
Normal file
241
frontend/src/features/scanner/components/scan-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
348
frontend/src/features/scanner/components/scan-history-page.tsx
Normal file
348
frontend/src/features/scanner/components/scan-history-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
116
frontend/src/features/scanner/components/scan-results-export.tsx
Normal file
116
frontend/src/features/scanner/components/scan-results-export.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
201
frontend/src/features/scanner/components/scan-results-table.tsx
Normal file
201
frontend/src/features/scanner/components/scan-results-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
frontend/src/features/scanner/hooks/use-scan-query.test.tsx
Normal file
69
frontend/src/features/scanner/hooks/use-scan-query.test.tsx
Normal 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"] });
|
||||
});
|
||||
});
|
||||
262
frontend/src/features/scanner/hooks/use-scan-query.ts
Normal file
262
frontend/src/features/scanner/hooks/use-scan-query.ts
Normal 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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
152
frontend/src/features/scanner/types.test.ts
Normal file
152
frontend/src/features/scanner/types.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
195
frontend/src/features/scanner/types.ts
Normal file
195
frontend/src/features/scanner/types.ts
Normal 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;
|
||||
}
|
||||
37
frontend/src/features/settings/api/test-bot.ts
Normal file
37
frontend/src/features/settings/api/test-bot.ts
Normal 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 }>;
|
||||
}
|
||||
34
frontend/src/features/settings/api/update-bot.ts
Normal file
34
frontend/src/features/settings/api/update-bot.ts
Normal 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";
|
||||
}
|
||||
|
|
@ -56,6 +56,7 @@ export function AuthSection({ config }: AuthSectionProps) {
|
|||
{
|
||||
onSuccess: (msg) => {
|
||||
toast.success(msg);
|
||||
setUsername(trimmedUsername);
|
||||
setPassword("");
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
374
frontend/src/features/settings/components/bot-section.tsx
Normal file
374
frontend/src/features/settings/components/bot-section.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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: "" });
|
||||
|
|
|
|||
400
frontend/src/features/settings/components/scanner-section.tsx
Normal file
400
frontend/src/features/settings/components/scanner-section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
19
frontend/src/routes/sbom-history/index.tsx
Normal file
19
frontend/src/routes/sbom-history/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
frontend/src/routes/scan-history/index.tsx
Normal file
19
frontend/src/routes/scan-history/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
frontend/src/test/vitest-setup.ts
Normal file
55
frontend/src/test/vitest-setup.ts
Normal 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
15
frontend/vitest.config.ts
Normal 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"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
home/go.mod
13
home/go.mod
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
53
home/go.sum
53
home/go.sum
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
19
home/internal/alerts/monitor_test.go
Normal file
19
home/internal/alerts/monitor_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
71
home/internal/api/bot_handlers.go
Normal file
71
home/internal/api/bot_handlers.go
Normal 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"
|
||||
}
|
||||
}
|
||||
180
home/internal/api/bot_handlers_test.go
Normal file
180
home/internal/api/bot_handlers_test.go
Normal 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,
|
||||
})
|
||||
}
|
||||
145
home/internal/api/container_handlers_test.go
Normal file
145
home/internal/api/container_handlers_test.go
Normal 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))
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
793
home/internal/api/scan_handlers.go
Normal file
793
home/internal/api/scan_handlers.go
Normal 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
|
||||
}
|
||||
195
home/internal/api/scan_handlers_sbom_test.go
Normal file
195
home/internal/api/scan_handlers_sbom_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
475
home/internal/api/scan_handlers_test.go
Normal file
475
home/internal/api/scan_handlers_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
364
home/internal/api/settings_handlers_test.go
Normal file
364
home/internal/api/settings_handlers_test.go
Normal 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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
166
home/internal/bot/command.go
Normal file
166
home/internal/bot/command.go
Normal 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
|
||||
}
|
||||
24
home/internal/bot/command_test.go
Normal file
24
home/internal/bot/command_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
485
home/internal/bot/discord.go
Normal file
485
home/internal/bot/discord.go
Normal 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) != ""
|
||||
}
|
||||
310
home/internal/bot/telegram.go
Normal file
310
home/internal/bot/telegram.go
Normal 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, "***"))
|
||||
}
|
||||
179
home/internal/bot/telegram_test.go
Normal file
179
home/internal/bot/telegram_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue