Initial commit

This commit is contained in:
2026-01-02 23:14:23 +00:00
commit 48b834a62a
181 changed files with 33328 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
name: Autoupdate go.mod and go.sum
on:
workflow_dispatch:
schedule:
- cron: "0 3 * * *"
permissions:
contents: write
actions: write
pull-requests: write
jobs:
autoupdate:
uses: lukaszraczylo/shared-actions/.github/workflows/go-autoupdate.yaml@main
with:
go-version: ">=1.25"
release-workflow: "release.yaml"
secrets: inherit
+16
View File
@@ -0,0 +1,16 @@
name: Pull Request
on:
pull_request:
branches:
- main
push:
branches:
- "**"
- "!main"
jobs:
pr-checks:
uses: lukaszraczylo/shared-actions/.github/workflows/go-pr.yaml@main
with:
go-version: "1.25"
+96
View File
@@ -0,0 +1,96 @@
name: Release
on:
workflow_dispatch:
push:
paths-ignore:
- "**.md"
- "**/release.yaml"
- "frontend/**"
- "deployments/**"
- "docs/**"
branches:
- main
permissions:
id-token: write
contents: write
packages: write
deployments: write
jobs:
release:
uses: lukaszraczylo/shared-actions/.github/workflows/go-release.yaml@main
with:
go-version: "1.25"
docker-enabled: true
secrets: inherit
benchmark:
name: Publish Benchmarks
needs: release
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
ref: main
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.25"
- name: Run benchmarks
run: go test -bench=. -benchmem ./... -run=^# | tee output.txt
- name: Store benchmark result
uses: benchmark-action/github-action-benchmark@v1
with:
tool: "go"
output-file-path: output.txt
fail-on-alert: true
github-token: ${{ secrets.GITHUB_TOKEN }}
comment-on-alert: true
summary-always: true
auto-push: false
benchmark-data-dir-path: "docs/bench"
- name: Push benchmark results
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add docs/bench
git diff --staged --quiet || git commit -m "Update benchmark results"
git push origin main
publish-helm-chart:
name: Publish Helm Chart
needs: release
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get release version
id: version
run: |
VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "0.0.0")
VERSION=${VERSION#v}
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Trigger helm-charts release
env:
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
run: |
gh api repos/lukaszraczylo/helm-charts/dispatches \
-f event_type=release-chart \
-f client_payload[chart_name]=gohoarder \
-f client_payload[version]=${{ steps.version.outputs.version }} \
-f client_payload[source_repo]=lukaszraczylo/gohoarder \
-f client_payload[chart_path]=helm/gohoarder
+82
View File
@@ -0,0 +1,82 @@
# Binaries
bin/
*.exe
*.exe~
*.dll
*.so
*.dylib
/gohoarder
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool
*.out
coverage.txt
coverage.html
# Dependency directories
vendor/
# Go workspace file
go.work
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Config files (keep example)
config.yaml
*.local.yaml
# Databases
*.db
*.db-shm
*.db-wal
# Cache
/var/cache/gohoarder/
/cache/
# Logs
*.log
# Temporary files
tmp/
temp/
# Security
.env
*.pem
*.key
*.crt
# Build artifacts
dist/
build/
# Node modules (for frontend)
web/node_modules/
web/dist/
# Test fixtures
tests/fixtures/temp/
# Markdown files (except README.md)
*.md
!README.md
/gohoarder
*.log
*.out
test-go-proxy
frontend/node_modules
data/storage
*.pid
+205
View File
@@ -0,0 +1,205 @@
version: 2
# Project metadata
project_name: gohoarder
# Pre-release hooks
before:
hooks:
- go mod tidy
# Generate semantic version if not provided via git tag
# This script can be used by CI/CD to inject custom versions
# Usage: export GORELEASER_CURRENT_TAG=$(./script/generate-version.sh)
# - ./script/generate-version.sh
# Build configuration
builds:
- id: gohoarder
main: ./cmd/gohoarder
binary: gohoarder
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
ldflags:
- -s -w
- -X github.com/lukaszraczylo/gohoarder/internal/version.Version={{.Version}}
- -X github.com/lukaszraczylo/gohoarder/internal/version.GitCommit={{.ShortCommit}}
- -X github.com/lukaszraczylo/gohoarder/internal/version.BuildTime={{.Date}}
# Archives for releases
archives:
- id: default
name_template: >-
{{ .ProjectName }}_
{{- title .Os }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
formats:
- tar.gz
- zip
format_overrides:
- goos: windows
formats:
- zip
files:
- README.md
- LICENSE
- config.yaml.example
# Checksum
checksum:
name_template: 'checksums.txt'
algorithm: sha256
# Snapshot configuration
snapshot:
version_template: "{{ incpatch .Version }}-next"
# Changelog
changelog:
sort: asc
filters:
exclude:
- '^docs:'
- '^test:'
- '^ci:'
- '^chore:'
- '^Merge'
- '^WIP'
- '^Update go.mod'
- 'README'
# GitHub release configuration
release:
github:
owner: lukaszraczylo
name: gohoarder
name_template: "version {{.Version}}"
draft: false
prerelease: auto
# Docker images (v2 - modern syntax)
dockers_v2:
# 1. Application Engine - Main GoHoarder server
- id: gohoarder-server
ids:
- gohoarder
images:
- ghcr.io/lukaszraczylo/gohoarder-server
tags:
- "{{ .Version }}"
- latest
platforms:
- linux/amd64
- linux/arm64
dockerfile: Dockerfile.server
labels:
org.opencontainers.image.title: GoHoarder Server
org.opencontainers.image.description: Universal package cache proxy server
org.opencontainers.image.url: https://github.com/lukaszraczylo/gohoarder
org.opencontainers.image.source: https://github.com/lukaszraczylo/gohoarder
org.opencontainers.image.version: "{{ .Version }}"
org.opencontainers.image.created: "{{ .Date }}"
org.opencontainers.image.revision: "{{ .FullCommit }}"
extra_files:
- config.yaml.example
# 2. Website - Frontend Dashboard
- id: gohoarder-frontend
ids:
- gohoarder
images:
- ghcr.io/lukaszraczylo/gohoarder-frontend
tags:
- "{{ .Version }}"
- latest
platforms:
- linux/amd64
- linux/arm64
dockerfile: Dockerfile.frontend
labels:
org.opencontainers.image.title: GoHoarder Frontend
org.opencontainers.image.description: GoHoarder web dashboard
org.opencontainers.image.url: https://github.com/lukaszraczylo/gohoarder
org.opencontainers.image.source: https://github.com/lukaszraczylo/gohoarder
org.opencontainers.image.version: "{{ .Version }}"
org.opencontainers.image.created: "{{ .Date }}"
org.opencontainers.image.revision: "{{ .FullCommit }}"
extra_files:
- frontend
# 3. Scanning Engine - Background scanner worker
- id: gohoarder-scanner
ids:
- gohoarder
images:
- ghcr.io/lukaszraczylo/gohoarder-scanner
tags:
- "{{ .Version }}"
- latest
platforms:
- linux/amd64
- linux/arm64
dockerfile: Dockerfile.scanner
labels:
org.opencontainers.image.title: GoHoarder Scanner
org.opencontainers.image.description: GoHoarder vulnerability scanning engine
org.opencontainers.image.url: https://github.com/lukaszraczylo/gohoarder
org.opencontainers.image.source: https://github.com/lukaszraczylo/gohoarder
org.opencontainers.image.version: "{{ .Version }}"
org.opencontainers.image.created: "{{ .Date }}"
org.opencontainers.image.revision: "{{ .FullCommit }}"
extra_files:
- config.yaml.example
# 4. Gateway - Nginx reverse proxy for unified deployment
- id: gohoarder-gateway
ids:
- gohoarder
images:
- ghcr.io/lukaszraczylo/gohoarder-gateway
tags:
- "{{ .Version }}"
- latest
platforms:
- linux/amd64
- linux/arm64
dockerfile: Dockerfile.gateway
labels:
org.opencontainers.image.title: GoHoarder Gateway
org.opencontainers.image.description: Nginx reverse proxy for unified GoHoarder deployment
org.opencontainers.image.url: https://github.com/lukaszraczylo/gohoarder
org.opencontainers.image.source: https://github.com/lukaszraczylo/gohoarder
org.opencontainers.image.version: "{{ .Version }}"
org.opencontainers.image.created: "{{ .Date }}"
org.opencontainers.image.revision: "{{ .FullCommit }}"
# Artifact signing with cosign
signs:
- cmd: cosign
signature: "${artifact}.sigstore.json"
args:
- sign-blob
- "--bundle=${signature}"
- "${artifact}"
- "--yes"
artifacts: checksum
output: true
# Docker image signing with cosign
docker_signs:
- cmd: cosign
artifacts: manifests
output: true
args:
- sign
- "${artifact}@${digest}"
- "--yes"
+100
View File
@@ -0,0 +1,100 @@
# Website - Frontend Dashboard
# Build stage
FROM node:20-alpine AS builder
WORKDIR /build
# Copy frontend source
COPY frontend/package.json frontend/pnpm-lock.yaml ./
COPY frontend/ ./
# Install pnpm and dependencies
RUN npm install -g pnpm && \
pnpm install --frozen-lockfile
# Build the frontend
RUN pnpm run build
# Production stage
FROM nginx:alpine
# Install envsubst for runtime configuration
RUN apk add --no-cache gettext
# Copy built frontend
COPY --from=builder /build/dist /usr/share/nginx/html
# Create runtime config injection script
RUN cat > /docker-entrypoint.d/40-inject-config.sh <<'EOF'
#!/bin/sh
set -e
# Create runtime configuration file
cat > /usr/share/nginx/html/config.js <<JSEOF
window.__RUNTIME_CONFIG__ = {
API_BASE_URL: "${API_BASE_URL:-/api}",
APP_VERSION: "${APP_VERSION:-unknown}",
APP_NAME: "${APP_NAME:-GoHoarder}"
};
JSEOF
# Substitute environment variables
envsubst < /usr/share/nginx/html/config.js > /usr/share/nginx/html/config.tmp.js
mv /usr/share/nginx/html/config.tmp.js /usr/share/nginx/html/config.js
echo "Runtime configuration injected:"
cat /usr/share/nginx/html/config.js
EOF
RUN chmod +x /docker-entrypoint.d/40-inject-config.sh
# Copy nginx configuration
RUN cat > /etc/nginx/conf.d/default.conf <<'EOF'
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript;
# SPA routing
location / {
try_files $uri $uri/ /index.html;
}
# Runtime configuration endpoint
location = /config.js {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
EOF
# Expose port
EXPOSE 80
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --quiet --tries=1 --spider http://localhost/ || exit 1
# Environment variables with defaults
ENV API_BASE_URL=/api \
APP_VERSION=unknown \
APP_NAME=GoHoarder
CMD ["nginx", "-g", "daemon off;"]
+197
View File
@@ -0,0 +1,197 @@
# Gateway - Nginx reverse proxy for unified deployment
# Routes traffic between frontend and backend under single vhost
FROM nginx:alpine
# Install envsubst for runtime configuration
RUN apk add --no-cache gettext
# Copy nginx configuration template
COPY <<'EOF' /etc/nginx/templates/default.conf.template
# Upstream servers
upstream backend {
server ${BACKEND_HOST}:${BACKEND_PORT};
keepalive 32;
}
upstream frontend {
server ${FRONTEND_HOST}:${FRONTEND_PORT};
keepalive 32;
}
# Rate limiting zones
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=download_limit:10m rate=5r/s;
# Cache configuration
proxy_cache_path /var/cache/nginx/static levels=1:2 keys_zone=static_cache:10m max_size=100m inactive=60m use_temp_path=off;
server {
listen 80;
server_name ${SERVER_NAME};
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Client body size for package uploads
client_max_body_size 500M;
client_body_timeout 300s;
# Logging
access_log /var/log/nginx/access.log combined;
error_log /var/log/nginx/error.log warn;
# API endpoints - proxy to backend
location /api/ {
# Rate limiting
limit_req zone=api_limit burst=20 nodelay;
# Proxy settings
proxy_pass http://backend/;
proxy_http_version 1.1;
# Headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
# Connection reuse
proxy_set_header Connection "";
# Timeouts for long-running operations
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
# Buffer settings
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
proxy_busy_buffers_size 8k;
}
# Health check endpoint
location /health {
proxy_pass http://backend/health;
proxy_http_version 1.1;
proxy_set_header Connection "";
access_log off;
}
# Metrics endpoint (optional - may want to restrict access)
location /metrics {
# Uncomment to restrict to internal networks
# allow 10.0.0.0/8;
# allow 172.16.0.0/12;
# allow 192.168.0.0/16;
# deny all;
proxy_pass http://backend/metrics;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
# Package download endpoints with rate limiting
location ~ ^/(npm|pypi|go)/ {
limit_req zone=download_limit burst=10 nodelay;
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
# Extended timeouts for package downloads
proxy_connect_timeout 60s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
# Large buffer for package downloads
proxy_buffering on;
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
}
# Frontend - serve SPA
location / {
proxy_pass http://frontend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
# Cache static assets
proxy_cache static_cache;
proxy_cache_valid 200 1h;
proxy_cache_bypass $http_cache_control;
add_header X-Cache-Status $upstream_cache_status;
}
# WebSocket support (if needed for future features)
location /ws/ {
proxy_pass http://backend/ws/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket timeouts
proxy_connect_timeout 7d;
proxy_send_timeout 7d;
proxy_read_timeout 7d;
}
}
# HTTPS server (uncomment and configure SSL certificates)
# server {
# listen 443 ssl http2;
# server_name ${SERVER_NAME};
#
# ssl_certificate /etc/nginx/ssl/cert.pem;
# ssl_certificate_key /etc/nginx/ssl/key.pem;
#
# # SSL configuration
# ssl_protocols TLSv1.2 TLSv1.3;
# ssl_ciphers HIGH:!aNULL:!MD5;
# ssl_prefer_server_ciphers on;
# ssl_session_cache shared:SSL:10m;
# ssl_session_timeout 10m;
#
# # Include all location blocks from above
# # ... (copy from HTTP server block)
# }
EOF
# Create cache directory
RUN mkdir -p /var/cache/nginx/static && \
chown -R nginx:nginx /var/cache/nginx
# Expose port
EXPOSE 80 443
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --quiet --tries=1 --spider http://localhost/health || exit 1
# Environment variables with defaults
ENV BACKEND_HOST=gohoarder-server \
BACKEND_PORT=8080 \
FRONTEND_HOST=gohoarder-frontend \
FRONTEND_PORT=80 \
SERVER_NAME=_
# Use nginx with template substitution
CMD ["/bin/sh", "-c", "envsubst '$$BACKEND_HOST $$BACKEND_PORT $$FRONTEND_HOST $$FRONTEND_PORT $$SERVER_NAME' < /etc/nginx/templates/default.conf.template > /etc/nginx/conf.d/default.conf && nginx -g 'daemon off;'"]
+59
View File
@@ -0,0 +1,59 @@
# Scanning Engine - Background Scanner Worker
ARG TARGETOS
ARG TARGETARCH
FROM alpine:latest
# Install scanning tools and runtime dependencies
RUN apk add --no-cache \
ca-certificates \
tzdata \
git \
curl \
wget \
bash \
&& update-ca-certificates
# Install Trivy for container scanning
RUN curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
# Install Grype for vulnerability scanning
RUN curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin
# Create non-root user
RUN addgroup -g 1000 scanner && \
adduser -D -u 1000 -G scanner scanner
# Create necessary directories
RUN mkdir -p /data/cache /data/scans && \
chown -R scanner:scanner /data
# Copy binary (from platform-specific path)
ARG TARGETOS
ARG TARGETARCH
COPY ${TARGETOS}/${TARGETARCH}/gohoarder /usr/local/bin/gohoarder
RUN chmod +x /usr/local/bin/gohoarder
# Copy example config
COPY config.yaml.example /etc/gohoarder/config.yaml.example
WORKDIR /data
USER scanner
# Expose metrics port
EXPOSE 9091
# Health check
HEALTHCHECK --interval=60s --timeout=30s --start-period=10s --retries=3 \
CMD ["/usr/local/bin/gohoarder", "version"] || exit 1
# Environment variables for scanner mode
ENV SCANNER_MODE=true \
SCANNER_WORKERS=4 \
SCANNER_INTERVAL=300
# Run the scanner in background mode
# Note: You may need to add a scanner-specific command to your CLI
# For now, this assumes the serve command can run in scanner mode
ENTRYPOINT ["/usr/local/bin/gohoarder"]
CMD ["serve", "--scanner-only"]
+44
View File
@@ -0,0 +1,44 @@
# Application Engine - GoHoarder Server
ARG TARGETOS
ARG TARGETARCH
FROM alpine:latest
# Install runtime dependencies
RUN apk add --no-cache \
ca-certificates \
tzdata \
&& update-ca-certificates
# Create non-root user
RUN addgroup -g 1000 gohoarder && \
adduser -D -u 1000 -G gohoarder gohoarder
# Create necessary directories
RUN mkdir -p /data/cache /data/metadata && \
chown -R gohoarder:gohoarder /data
# Copy binary (from platform-specific path)
ARG TARGETOS
ARG TARGETARCH
COPY ${TARGETOS}/${TARGETARCH}/gohoarder /usr/local/bin/gohoarder
RUN chmod +x /usr/local/bin/gohoarder
# Copy example config
COPY config.yaml.example /etc/gohoarder/config.yaml.example
WORKDIR /data
USER gohoarder
# Expose ports
# 8080: Main proxy port
# 9090: Metrics/health port
EXPOSE 8080 9090
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD ["/usr/local/bin/gohoarder", "version"] || exit 1
# Run the server
ENTRYPOINT ["/usr/local/bin/gohoarder"]
CMD ["serve"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Lukasz Raczylo
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+132
View File
@@ -0,0 +1,132 @@
.PHONY: help build test test-coverage run clean install lint fmt vet
# Variables
BINARY_NAME=gohoarder
BINARY_PATH=bin/$(BINARY_NAME)
CMD_PATH=./cmd/gohoarder
# Generate semantic version using script, fallback to 'dev' if script fails
VERSION?=$(shell ./script/generate-version.sh 2>/dev/null || echo "dev")
GIT_COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
BUILD_TIME=$(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
LDFLAGS=-ldflags "-X github.com/lukaszraczylo/gohoarder/internal/version.Version=$(VERSION) \
-X github.com/lukaszraczylo/gohoarder/internal/version.GitCommit=$(GIT_COMMIT) \
-X github.com/lukaszraczylo/gohoarder/internal/version.BuildTime=$(BUILD_TIME)"
help: ## Display this help screen
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
build: ## Build the binary
@echo "Building $(BINARY_NAME)..."
@mkdir -p bin
@go build -buildvcs=false $(LDFLAGS) -o $(BINARY_PATH) $(CMD_PATH)
@echo "Binary built: $(BINARY_PATH)"
build-all: ## Build for all platforms
@echo "Building for all platforms..."
@mkdir -p bin
GOOS=linux GOARCH=amd64 go build -buildvcs=false $(LDFLAGS) -o bin/$(BINARY_NAME)-linux-amd64 $(CMD_PATH)
GOOS=linux GOARCH=arm64 go build -buildvcs=false $(LDFLAGS) -o bin/$(BINARY_NAME)-linux-arm64 $(CMD_PATH)
GOOS=darwin GOARCH=amd64 go build -buildvcs=false $(LDFLAGS) -o bin/$(BINARY_NAME)-darwin-amd64 $(CMD_PATH)
GOOS=darwin GOARCH=arm64 go build -buildvcs=false $(LDFLAGS) -o bin/$(BINARY_NAME)-darwin-arm64 $(CMD_PATH)
@echo "All binaries built"
test: ## Run tests
@echo "Running tests..."
@go test -v ./...
test-coverage: ## Run tests with coverage
@echo "Running tests with coverage..."
@go test -coverprofile=coverage.out ./...
@go tool cover -html=coverage.out -o coverage.html
@echo "Coverage report: coverage.html"
test-race: ## Run tests with race detector
@echo "Running tests with race detector..."
@go test -race ./...
bench: ## Run benchmarks
@echo "Running benchmarks..."
@go test -bench=. -benchmem ./...
run: build ## Build and run both backend and frontend for development
@echo "Starting $(BINARY_NAME) and frontend in development mode..."
@echo ""
@echo "Backend will run on: http://localhost:8080 (configured in config.yaml)"
@echo "Frontend will run on: http://localhost:5173 (configured in frontend/.env)"
@echo ""
@echo "To change ports:"
@echo " - Backend: Edit 'server.port' in config.yaml"
@echo " - Frontend: Edit 'VITE_PORT' and 'VITE_BACKEND_URL' in frontend/.env"
@echo ""
@trap 'kill 0' SIGINT; \
$(BINARY_PATH) serve & \
cd frontend && pnpm dev & \
wait
run-backend: build ## Build and run only the backend server
@echo "Starting $(BINARY_NAME)..."
@$(BINARY_PATH) serve
run-dev: ## Run with example config
@echo "Starting $(BINARY_NAME) in development mode..."
@go run $(CMD_PATH) serve --config config.yaml.example
clean: ## Clean build artifacts
@echo "Cleaning..."
@rm -rf bin/
@rm -f coverage.out coverage.html
@rm -f *.db *.db-shm *.db-wal
@echo "Clean complete"
clean-db: ## Clean all local cache and database files (from config.yaml paths)
@echo "WARNING: This will delete all cached packages and scan results!"
@echo "Paths from config.yaml:"
@echo " - ./data/storage (package cache)"
@echo " - ./data/gohoarder.db (metadata database)"
@echo " - /tmp/trivy (Trivy cache)"
@echo ""
@read -p "Are you sure you want to continue? [y/N] " confirm && [ "$$confirm" = "y" ] || exit 1
@echo "Cleaning database and cache..."
@rm -rf ./data/storage
@rm -f ./data/gohoarder.db ./data/gohoarder.db-shm ./data/gohoarder.db-wal
@rm -rf /tmp/trivy
@echo "Database and cache cleaned successfully"
install: build ## Install the binary
@echo "Installing $(BINARY_NAME)..."
@cp $(BINARY_PATH) $(GOPATH)/bin/
@echo "Installed to $(GOPATH)/bin/$(BINARY_NAME)"
lint: ## Run linters
@echo "Running linters..."
@go vet ./...
@which golangci-lint > /dev/null || (echo "golangci-lint not installed" && exit 1)
@golangci-lint run
fmt: ## Format code
@echo "Formatting code..."
@gofmt -s -w .
@which goimports > /dev/null && goimports -w . || true
vet: ## Run go vet
@go vet ./...
tidy: ## Tidy dependencies
@go mod tidy
docker-build: ## Build Docker image
@echo "Building Docker image..."
@docker build -t $(BINARY_NAME):$(VERSION) .
docker-run: docker-build ## Run Docker container
@echo "Running Docker container..."
@docker run -p 8080:8080 $(BINARY_NAME):$(VERSION)
test-packages: ## Download test packages through gohoarder proxy (clean + vulnerable packages)
@echo "Reading backend port from config.yaml..."
@PORT=$$(grep "^ port:" config.yaml | awk '{print $$2}'); \
if [ -z "$$PORT" ]; then PORT=8080; fi; \
export GOHOARDER_URL="http://localhost:$$PORT"; \
./script/test-packages.sh
.DEFAULT_GOAL := help
+1261
View File
File diff suppressed because it is too large Load Diff
+62
View File
@@ -0,0 +1,62 @@
package commands
import (
"fmt"
"github.com/lukaszraczylo/gohoarder/internal/version"
"github.com/lukaszraczylo/gohoarder/pkg/app"
"github.com/lukaszraczylo/gohoarder/pkg/config"
"github.com/lukaszraczylo/gohoarder/pkg/logger"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
)
var (
configPath string
)
// ServeCmd starts the HTTP server
var ServeCmd = &cobra.Command{
Use: "serve",
Short: "Start the GoHoarder server",
Long: "Start the HTTP server to serve as a package cache proxy",
RunE: runServe,
}
func init() {
ServeCmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
}
func runServe(cmd *cobra.Command, args []string) error {
// Load configuration
cfg, err := config.Load(configPath)
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
// Initialize logger
if err := logger.Init(logger.Config{
Level: cfg.Logging.Level,
Format: cfg.Logging.Format,
}); err != nil {
return fmt.Errorf("failed to initialize logger: %w", err)
}
log.Info().
Str("version", version.Version).
Str("commit", version.GitCommit).
Msg("Starting GoHoarder")
// Create and run application
application, err := app.New(cfg)
if err != nil {
return fmt.Errorf("failed to create application: %w", err)
}
// Run application (blocks until shutdown)
if err := application.Run(); err != nil {
return fmt.Errorf("application error: %w", err)
}
return nil
}
+42
View File
@@ -0,0 +1,42 @@
package commands
import (
"fmt"
json "github.com/goccy/go-json"
"github.com/lukaszraczylo/gohoarder/internal/version"
"github.com/spf13/cobra"
)
var (
jsonOutput bool
)
// VersionCmd displays version information
var VersionCmd = &cobra.Command{
Use: "version",
Short: "Print version information",
Long: "Display detailed version information about GoHoarder",
Run: func(cmd *cobra.Command, args []string) {
info := version.Get()
if jsonOutput {
data, err := json.MarshalIndent(info, "", " ")
if err != nil {
fmt.Fprintf(cmd.OutOrStderr(), "Error: %v\n", err)
return
}
fmt.Fprintln(cmd.OutOrStdout(), string(data))
} else {
fmt.Fprintf(cmd.OutOrStdout(), "GoHoarder %s\n", info.Version)
fmt.Fprintf(cmd.OutOrStdout(), "Git Commit: %s\n", info.GitCommit)
fmt.Fprintf(cmd.OutOrStdout(), "Built: %s\n", info.BuildTime)
fmt.Fprintf(cmd.OutOrStdout(), "Go Version: %s\n", info.GoVersion)
fmt.Fprintf(cmd.OutOrStdout(), "Platform: %s\n", info.Platform)
}
},
}
func init() {
VersionCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output version information as JSON")
}
+41
View File
@@ -0,0 +1,41 @@
package main
import (
"fmt"
"os"
"github.com/lukaszraczylo/gohoarder/cmd/gohoarder/commands"
"github.com/lukaszraczylo/gohoarder/internal/version"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "gohoarder",
Short: "Universal package cache proxy",
Long: `GoHoarder is a universal pass-through cache proxy for package managers.
Supports npm, pip, and Go modules with transparent caching, security scanning, and multi-backend storage.`,
Version: version.Version,
}
func init() {
// Add commands
rootCmd.AddCommand(commands.ServeCmd)
rootCmd.AddCommand(commands.VersionCmd)
// Set version template
rootCmd.SetVersionTemplate(fmt.Sprintf(
"GoHoarder %s\nGit Commit: %s\nBuilt: %s\nGo Version: %s\nPlatform: %s\n",
version.Version,
version.GitCommit,
version.BuildTime,
version.GoVersion,
"GOOS/GOARCH",
))
}
func main() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
+184
View File
@@ -0,0 +1,184 @@
# GoHoarder Configuration Example
#
# Port Configuration:
# - Backend server port is configured below (server.port)
# - Frontend dev server uses frontend/.env (VITE_PORT and VITE_BACKEND_URL)
# - When running `make run`, both will start with their configured ports
# - The frontend automatically proxies /api and /ws requests to the backend
server:
host: "0.0.0.0"
port: 8080 # Backend API server port
read_timeout: "5m"
write_timeout: "5m"
idle_timeout: "2m"
tls:
enabled: false
cert_file: ""
key_file: ""
storage:
backend: "filesystem" # filesystem, s3, smb, nfs
path: "/var/cache/gohoarder"
filesystem:
base_path: "/var/cache/gohoarder"
s3:
endpoint: "s3.amazonaws.com"
region: "us-east-1"
bucket: "gohoarder-cache"
access_key_id: ""
secret_access_key: ""
use_ssl: true
smb:
host: ""
share: ""
username: ""
password: ""
domain: ""
metadata:
backend: "sqlite" # sqlite, postgresql, file
connection: "file:gohoarder.db?cache=shared&mode=rwc"
sqlite:
path: "gohoarder.db"
wal_mode: true
postgresql:
host: "localhost"
port: 5432
database: "gohoarder"
user: "gohoarder"
password: ""
ssl_mode: "disable"
cache:
default_ttl: "168h" # 7 days
cleanup_interval: "1h"
max_size_bytes: 536870912000 # 500GB
per_project_quota: 53687091200 # 50GB
ttl_overrides:
npm: "168h"
pip: "168h"
go: "168h"
security:
enabled: false
block_on_severity: "high" # none, low, medium, high, critical
scan_on_download: true # Scan packages on first download
rescan_interval: "24h" # How often to re-scan packages (e.g., 24h, 168h for weekly)
update_db_on_startup: false # Update vulnerability databases on startup
allowed_packages: [] # Packages that bypass security checks (format: "registry/name@version")
ignored_cves: [] # CVE IDs to ignore globally (e.g., "CVE-2021-23337")
block_thresholds:
critical: 0 # Max critical vulns (0 = block any)
high: -1 # Max high vulns (-1 = unlimited)
medium: -1 # Max medium vulns
low: -1 # Max low vulns
scanners:
# Trivy - Comprehensive vulnerability scanner from Aqua Security
# Supports: containers, OS packages, language packages
trivy:
enabled: false
timeout: "5m"
cache_db: "/var/lib/trivy"
# OSV - Google's Open Source Vulnerabilities database
# Supports: npm, PyPI, Go, Maven, NuGet, etc.
osv:
enabled: false
api_url: "https://api.osv.dev"
timeout: "30s"
# Grype - Multi-ecosystem vulnerability scanner from Anchore
# Supports: all package types, containers, SBOMs
grype:
enabled: false
timeout: "5m"
# govulncheck - Official Go vulnerability scanner from the Go team
# Supports: Go modules only
govulncheck:
enabled: false
timeout: "5m"
# npm-audit - npm's built-in vulnerability scanner
# Supports: npm packages only
npm_audit:
enabled: false
timeout: "2m"
# pip-audit - Python package vulnerability scanner
# Supports: PyPI packages only
pip_audit:
enabled: false
timeout: "2m"
# GitHub Advisory Database - GitHub's security advisory database
# Supports: npm, pip, go, maven, nuget, cargo, pub
# Optional: Set token for higher API rate limits (60 req/hour unauthenticated, 5000 req/hour authenticated)
ghsa:
enabled: false
timeout: "30s"
token: "" # Optional: GitHub personal access token (ghp_...)
# Static Analysis - Basic static analysis and package validation
static:
enabled: true
max_package_size: 2147483648 # 2GB
check_checksums: true
block_suspicious: false
allowed_licenses: []
auth:
enabled: true
key_expiration: "0" # Never expire (0), or duration like "8760h" for 1 year
bcrypt_cost: 10
audit_log: true
network:
connect_timeout: "10s"
read_timeout: "5m"
write_timeout: "5m"
max_idle_conns: 100
max_conns_per_host: 10
rate_limit:
per_api_key: 1000
per_ip: 100
burst_size: 50
circuit_breaker:
threshold: 5
timeout: "30s"
reset_interval: "60s"
retry:
max_attempts: 3
initial_backoff: "1s"
max_backoff: "30s"
logging:
level: "info" # debug, info, warn, error
format: "json" # json, pretty
handlers:
go:
enabled: true
upstream_proxy: "https://proxy.golang.org"
checksum_db: "https://sum.golang.org"
verify_checksums: true
npm:
enabled: true
upstream_registry: "https://registry.npmjs.org"
pypi:
enabled: true
upstream_url: "https://pypi.org"
simple_api_url: "https://pypi.org/simple"
+416
View File
@@ -0,0 +1,416 @@
# Kubernetes Deployment Guide
This directory contains Kubernetes manifests for deploying GoHoarder in a production environment.
## Architecture Overview
```
┌─────────────────────────────────────────────────────┐
│ Kubernetes Pod │
│ ┌────────────────────────────────────────────────┐ │
│ │ GoHoarder Container │ │
│ │ ┌──────────────────────────────────────────┐ │ │
│ │ │ Pattern-Based Credential Store │ │ │
│ │ │ ├─ github.com/myorg/* → token_A │ │ │
│ │ │ ├─ gitlab.com/team/* → token_B │ │ │
│ │ │ └─ * → fallback_token │ │ │
│ │ └──────────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────────┘ │
│ │
│ Mounted Volumes: │
│ • config.yaml (ConfigMap) │
│ • git-credentials.json (Secret) │
│ • /var/lib/gohoarder/cache (PVC) │
│ • /var/lib/gohoarder (PVC for metadata DB) │
└─────────────────────────────────────────────────────┘
```
## Files
- `secret-git-credentials.yaml` - Git credentials for private repositories
- `configmap-config.yaml` - Application configuration
- `pvc.yaml` - Persistent volume claims for cache and metadata
- `deployment.yaml` - Main application deployment
- `service.yaml` - Service and optional ingress configuration
## Quick Start
### 1. Configure Git Credentials
Edit `secret-git-credentials.yaml` and replace the placeholder tokens with your actual tokens:
```yaml
{
"credentials": [
{
"pattern": "github.com/mycompany/*",
"host": "github.com",
"username": "oauth2",
"token": "ghp_YOUR_ACTUAL_TOKEN_HERE"
}
]
}
```
**Pattern Matching Examples:**
- `github.com/myorg/*` - Matches all repos under myorg
- `github.com/myorg/specific-repo` - Matches only specific-repo
- `gitlab.com/backend-team/*` - Matches all GitLab repos under backend-team
- `*` - Fallback pattern (matches everything)
**Credential Priority:**
1. Most specific pattern wins (longest match)
2. Fallback credential (`"fallback": true`)
3. System git config (if no matches)
### 2. Customize Configuration
Edit `configmap-config.yaml` to adjust:
- Cache size (`max_size_bytes`)
- Security scanning settings
- Upstream registries
- Logging level
### 3. Deploy to Kubernetes
```bash
# Create namespace (optional)
kubectl create namespace gohoarder
# Apply manifests
kubectl apply -f pvc.yaml
kubectl apply -f secret-git-credentials.yaml
kubectl apply -f configmap-config.yaml
kubectl apply -f deployment.yaml
kubectl apply -f service.yaml
# Verify deployment
kubectl get pods -l app=gohoarder
kubectl logs -l app=gohoarder -f
```
### 4. Configure Go Client
```bash
# Set GOPROXY environment variable
export GOPROXY=http://gohoarder.default.svc.cluster.local:8080/go,direct
# Or in your Dockerfile
ENV GOPROXY=http://gohoarder.default.svc.cluster.local:8080/go,direct
# Test with a private module
go get github.com/mycompany/private-module@latest
```
## Advanced Configuration
### Using External Secrets Operator (ESO)
If you're using External Secrets Operator, uncomment the ExternalSecret section in `secret-git-credentials.yaml` and configure your SecretStore:
```yaml
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: gohoarder-git-credentials
spec:
refreshInterval: 1h
secretStoreRef:
name: vault-backend
kind: SecretStore
target:
name: gohoarder-git-credentials
data:
- secretKey: credentials.json
remoteRef:
key: secret/gohoarder/git-credentials
```
### Storage Classes
For production deployments, specify appropriate storage classes:
```yaml
# In pvc.yaml
storageClassName: fast-ssd # For cache (needs fast I/O)
storageClassName: standard # For metadata (smaller, less critical)
```
### Horizontal Pod Autoscaling
```bash
kubectl autoscale deployment gohoarder \
--cpu-percent=70 \
--min=2 \
--max=10
```
### Monitoring
Check health and metrics:
```bash
# Health check
kubectl port-forward svc/gohoarder 8080:8080
curl http://localhost:8080/health
# Metrics (Prometheus format)
curl http://localhost:8080/metrics
```
## Multi-Organization Setup
### Example 1: Multiple GitHub Organizations
```json
{
"credentials": [
{
"pattern": "github.com/company-frontend/*",
"host": "github.com",
"username": "oauth2",
"token": "ghp_frontend_team_token"
},
{
"pattern": "github.com/company-backend/*",
"host": "github.com",
"username": "oauth2",
"token": "ghp_backend_team_token"
},
{
"pattern": "github.com/company-infra/*",
"host": "github.com",
"username": "oauth2",
"token": "ghp_infra_team_token"
},
{
"pattern": "*",
"host": "*",
"username": "oauth2",
"token": "ghp_readonly_default_token",
"fallback": true
}
]
}
```
### Example 2: GitHub + GitLab
```json
{
"credentials": [
{
"pattern": "github.com/myorg/*",
"host": "github.com",
"username": "oauth2",
"token": "ghp_github_token"
},
{
"pattern": "gitlab.com/myteam/*",
"host": "gitlab.com",
"username": "oauth2",
"token": "glpat_gitlab_token"
}
]
}
```
### Example 3: Enterprise GitHub
```json
{
"credentials": [
{
"pattern": "github.enterprise.com/engineering/*",
"host": "github.enterprise.com",
"username": "oauth2",
"token": "ghp_enterprise_token"
}
]
}
```
## Security Best Practices
1. **Token Scoping**: Use fine-grained personal access tokens with minimal permissions
- GitHub: Only `repo` scope needed for private repos
- GitLab: Only `read_repository` scope needed
2. **Secret Rotation**: Regularly rotate tokens
```bash
# Update secret
kubectl create secret generic gohoarder-git-credentials \
--from-file=credentials.json=./new-credentials.json \
--dry-run=client -o yaml | kubectl apply -f -
# Restart pods to pick up new credentials
kubectl rollout restart deployment gohoarder
```
3. **RBAC**: Limit who can read the secret
```bash
kubectl create role secret-reader \
--verb=get,list \
--resource=secrets \
--resource-name=gohoarder-git-credentials
```
4. **Audit Logging**: Enable Kubernetes audit logging for secret access
5. **Network Policies**: Restrict which pods can access GoHoarder
```yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-from-build-namespace
spec:
podSelector:
matchLabels:
app: gohoarder
ingress:
- from:
- namespaceSelector:
matchLabels:
name: build-namespace
```
## Troubleshooting
### Check if credentials are loaded
```bash
# Check logs for credential loading
kubectl logs -l app=gohoarder | grep "Loaded git credentials"
# Expected output:
# {"level":"info","file":"/etc/gohoarder/git-credentials.json","credentials":3,"message":"Loaded git credentials from file"}
# {"level":"debug","pattern":"github.com/myorg/*","host":"github.com","message":"Registered credential pattern"}
```
### Test credential pattern matching
```bash
# Enable debug logging
kubectl set env deployment/gohoarder LOG_LEVEL=debug
# Watch logs during a go get request
kubectl logs -l app=gohoarder -f
```
### Common Issues
**Issue**: `git clone failed: authentication required`
- **Cause**: No matching credential pattern
- **Solution**: Check pattern syntax in credentials.json, ensure it matches the module path
**Issue**: `Failed to load git credentials`
- **Cause**: Secret not mounted or JSON syntax error
- **Solution**: Verify secret exists and JSON is valid
```bash
kubectl get secret gohoarder-git-credentials
kubectl get secret gohoarder-git-credentials -o jsonpath='{.data.credentials\.json}' | base64 -d | jq .
```
**Issue**: Module fetch slow
- **Cause**: Git clone timeout or large repository
- **Solution**: Increase timeout in config.yaml or use upstream proxy for public modules
## Performance Tuning
### Cache Configuration
```yaml
cache:
max_size_bytes: 107374182400 # 100GB for large organizations
default_ttl: 168h # 7 days for stable modules
```
### Resource Limits
For high-traffic deployments:
```yaml
resources:
requests:
memory: "2Gi"
cpu: "1000m"
limits:
memory: "8Gi"
cpu: "4000m"
```
### Replicas
Run multiple replicas for high availability:
```yaml
spec:
replicas: 3
```
## Backup and Recovery
### Backup Metadata Database
```bash
# Backup SQLite database
kubectl exec -it deployment/gohoarder -- \
sqlite3 /var/lib/gohoarder/gohoarder.db ".backup /tmp/backup.db"
kubectl cp gohoarder-pod:/tmp/backup.db ./gohoarder-backup-$(date +%Y%m%d).db
```
### Restore from Backup
```bash
kubectl cp ./gohoarder-backup-20260102.db gohoarder-pod:/var/lib/gohoarder/gohoarder.db
kubectl rollout restart deployment gohoarder
```
## Integration Examples
### CI/CD Pipeline (GitHub Actions)
```yaml
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.21'
- name: Configure GOPROXY
run: |
echo "GOPROXY=http://gohoarder.company.internal:8080/go,direct" >> $GITHUB_ENV
- name: Build
run: go build ./...
```
### Dockerfile
```dockerfile
FROM golang:1.21-alpine
# Configure proxy
ENV GOPROXY=http://gohoarder.default.svc.cluster.local:8080/go,direct
ENV GONOPROXY=none
ENV GONOSUMDB=github.com/yourcompany
WORKDIR /app
COPY . .
RUN go build -o myapp ./cmd/myapp
CMD ["/app/myapp"]
```
## Support
For issues or questions:
- Check logs: `kubectl logs -l app=gohoarder`
- Enable debug logging: Set `logging.level: debug` in ConfigMap
- Review credential patterns in Secret
@@ -0,0 +1,54 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: gohoarder-config
namespace: default
data:
config.yaml: |
server:
host: "0.0.0.0"
port: 8080
read_timeout: 30s
write_timeout: 30s
cache:
max_size_bytes: 10737418240 # 10GB
default_ttl: 24h
cleanup_interval: 1h
storage:
backend: filesystem
path: /var/lib/gohoarder/cache
metadata:
backend: sqlite
connection: /var/lib/gohoarder/gohoarder.db
security:
enabled: true
providers:
- osv
- github
severity_threshold: medium
block_on_vulnerability: false
rescan_interval: 24h
handlers:
npm:
enabled: true
upstream_registry: "https://registry.npmjs.org"
pypi:
enabled: true
upstream_index: "https://pypi.org/simple"
go:
enabled: true
upstream_proxy: "https://proxy.golang.org"
checksum_db: "https://sum.golang.org"
# Path to git credentials file (mounted from Secret)
git_credentials_file: /etc/gohoarder/git-credentials.json
logging:
level: info
format: json
@@ -0,0 +1,502 @@
# GoHoarder - Kubernetes Deployment (All-in-One)
# This manifest deploys all GoHoarder services under a single ingress
#
# Usage:
# kubectl create namespace gohoarder
# kubectl apply -f deployment-all-in-one.yaml -n gohoarder
#
# Prerequisites:
# - Kubernetes 1.19+
# - Ingress controller (nginx, traefik, etc.)
# - Persistent volume provisioner
# - Optional: cert-manager for TLS certificates
---
# Namespace
apiVersion: v1
kind: Namespace
metadata:
name: gohoarder
labels:
app.kubernetes.io/name: gohoarder
app.kubernetes.io/component: namespace
---
# ConfigMap for application configuration
apiVersion: v1
kind: ConfigMap
metadata:
name: gohoarder-config
namespace: gohoarder
labels:
app.kubernetes.io/name: gohoarder
app.kubernetes.io/component: config
data:
# Add your configuration here or mount from a file
# config.yaml: |
# server:
# port: 8080
# ...
---
# PersistentVolumeClaim for cache storage
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: gohoarder-cache
namespace: gohoarder
labels:
app.kubernetes.io/name: gohoarder
app.kubernetes.io/component: storage
spec:
accessModes:
- ReadWriteMany # Multiple pods can access for scanner + server
resources:
requests:
storage: 100Gi
# storageClassName: your-storage-class # Specify your storage class
---
# PersistentVolumeClaim for metadata storage
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: gohoarder-metadata
namespace: gohoarder
labels:
app.kubernetes.io/name: gohoarder
app.kubernetes.io/component: storage
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
# storageClassName: your-storage-class
---
# Deployment - Application Server
apiVersion: apps/v1
kind: Deployment
metadata:
name: gohoarder-server
namespace: gohoarder
labels:
app.kubernetes.io/name: gohoarder
app.kubernetes.io/component: server
spec:
replicas: 2
selector:
matchLabels:
app.kubernetes.io/name: gohoarder
app.kubernetes.io/component: server
template:
metadata:
labels:
app.kubernetes.io/name: gohoarder
app.kubernetes.io/component: server
spec:
containers:
- name: server
image: ghcr.io/lukaszraczylo/gohoarder-server:latest
imagePullPolicy: Always
ports:
- name: http
containerPort: 8080
protocol: TCP
- name: metrics
containerPort: 9090
protocol: TCP
env:
- name: CONFIG_FILE
value: /config/config.yaml
- name: STORAGE_BACKEND
value: filesystem
- name: STORAGE_PATH
value: /data/cache
- name: DB_PATH
value: /data/metadata/gohoarder.db
- name: LOG_LEVEL
value: info
- name: LOG_FORMAT
value: json
volumeMounts:
- name: cache
mountPath: /data/cache
- name: metadata
mountPath: /data/metadata
- name: config
mountPath: /config
readOnly: true
livenessProbe:
exec:
command:
- /usr/local/bin/gohoarder
- version
initialDelaySeconds: 5
periodSeconds: 30
timeoutSeconds: 10
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
resources:
requests:
cpu: 500m
memory: 512Mi
limits:
cpu: 2000m
memory: 2Gi
volumes:
- name: cache
persistentVolumeClaim:
claimName: gohoarder-cache
- name: metadata
persistentVolumeClaim:
claimName: gohoarder-metadata
- name: config
configMap:
name: gohoarder-config
---
# Service - Application Server
apiVersion: v1
kind: Service
metadata:
name: gohoarder-server
namespace: gohoarder
labels:
app.kubernetes.io/name: gohoarder
app.kubernetes.io/component: server
spec:
type: ClusterIP
ports:
- name: http
port: 8080
targetPort: http
protocol: TCP
- name: metrics
port: 9090
targetPort: metrics
protocol: TCP
selector:
app.kubernetes.io/name: gohoarder
app.kubernetes.io/component: server
---
# Deployment - Frontend
apiVersion: apps/v1
kind: Deployment
metadata:
name: gohoarder-frontend
namespace: gohoarder
labels:
app.kubernetes.io/name: gohoarder
app.kubernetes.io/component: frontend
spec:
replicas: 2
selector:
matchLabels:
app.kubernetes.io/name: gohoarder
app.kubernetes.io/component: frontend
template:
metadata:
labels:
app.kubernetes.io/name: gohoarder
app.kubernetes.io/component: frontend
spec:
containers:
- name: frontend
image: ghcr.io/lukaszraczylo/gohoarder-frontend:latest
imagePullPolicy: Always
ports:
- name: http
containerPort: 80
protocol: TCP
env:
- name: API_BASE_URL
value: /api
- name: APP_VERSION
value: "1.0.0"
- name: APP_NAME
value: GoHoarder
livenessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 5
periodSeconds: 30
readinessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 5
periodSeconds: 10
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
---
# Service - Frontend
apiVersion: v1
kind: Service
metadata:
name: gohoarder-frontend
namespace: gohoarder
labels:
app.kubernetes.io/name: gohoarder
app.kubernetes.io/component: frontend
spec:
type: ClusterIP
ports:
- name: http
port: 80
targetPort: http
protocol: TCP
selector:
app.kubernetes.io/name: gohoarder
app.kubernetes.io/component: frontend
---
# Deployment - Scanner (Optional)
apiVersion: apps/v1
kind: Deployment
metadata:
name: gohoarder-scanner
namespace: gohoarder
labels:
app.kubernetes.io/name: gohoarder
app.kubernetes.io/component: scanner
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: gohoarder
app.kubernetes.io/component: scanner
template:
metadata:
labels:
app.kubernetes.io/name: gohoarder
app.kubernetes.io/component: scanner
spec:
containers:
- name: scanner
image: ghcr.io/lukaszraczylo/gohoarder-scanner:latest
imagePullPolicy: Always
env:
- name: CONFIG_FILE
value: /config/config.yaml
- name: SCANNER_MODE
value: "true"
- name: SCANNER_WORKERS
value: "4"
- name: LOG_LEVEL
value: info
volumeMounts:
- name: cache
mountPath: /data/cache
readOnly: true
- name: metadata
mountPath: /data/metadata
- name: config
mountPath: /config
readOnly: true
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
cpu: 2000m
memory: 4Gi
volumes:
- name: cache
persistentVolumeClaim:
claimName: gohoarder-cache
- name: metadata
persistentVolumeClaim:
claimName: gohoarder-metadata
- name: config
configMap:
name: gohoarder-config
---
# Deployment - Gateway (Nginx Reverse Proxy)
apiVersion: apps/v1
kind: Deployment
metadata:
name: gohoarder-gateway
namespace: gohoarder
labels:
app.kubernetes.io/name: gohoarder
app.kubernetes.io/component: gateway
spec:
replicas: 2
selector:
matchLabels:
app.kubernetes.io/name: gohoarder
app.kubernetes.io/component: gateway
template:
metadata:
labels:
app.kubernetes.io/name: gohoarder
app.kubernetes.io/component: gateway
spec:
containers:
- name: gateway
image: ghcr.io/lukaszraczylo/gohoarder-gateway:latest
imagePullPolicy: Always
ports:
- name: http
containerPort: 80
protocol: TCP
env:
- name: BACKEND_HOST
value: gohoarder-server
- name: BACKEND_PORT
value: "8080"
- name: FRONTEND_HOST
value: gohoarder-frontend
- name: FRONTEND_PORT
value: "80"
- name: SERVER_NAME
value: hoarder.i.raczylo.com
livenessProbe:
httpGet:
path: /health
port: 80
initialDelaySeconds: 5
periodSeconds: 30
readinessProbe:
httpGet:
path: /health
port: 80
initialDelaySeconds: 5
periodSeconds: 10
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
---
# Service - Gateway
apiVersion: v1
kind: Service
metadata:
name: gohoarder-gateway
namespace: gohoarder
labels:
app.kubernetes.io/name: gohoarder
app.kubernetes.io/component: gateway
spec:
type: ClusterIP
ports:
- name: http
port: 80
targetPort: http
protocol: TCP
selector:
app.kubernetes.io/name: gohoarder
app.kubernetes.io/component: gateway
---
# Ingress - Expose via domain
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: gohoarder
namespace: gohoarder
labels:
app.kubernetes.io/name: gohoarder
app.kubernetes.io/component: ingress
annotations:
# Nginx ingress annotations
nginx.ingress.kubernetes.io/proxy-body-size: "500m"
nginx.ingress.kubernetes.io/proxy-read-timeout: "600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "600"
# Enable CORS if needed
# nginx.ingress.kubernetes.io/enable-cors: "true"
# TLS/SSL configuration (uncomment if using cert-manager)
# cert-manager.io/cluster-issuer: "letsencrypt-prod"
spec:
ingressClassName: nginx # Adjust based on your ingress controller
rules:
- host: hoarder.i.raczylo.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: gohoarder-gateway
port:
number: 80
# Uncomment for HTTPS/TLS
# tls:
# - hosts:
# - hoarder.i.raczylo.com
# secretName: gohoarder-tls
---
# HorizontalPodAutoscaler - Server
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: gohoarder-server
namespace: gohoarder
labels:
app.kubernetes.io/name: gohoarder
app.kubernetes.io/component: server
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: gohoarder-server
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
---
# HorizontalPodAutoscaler - Gateway
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: gohoarder-gateway
namespace: gohoarder
labels:
app.kubernetes.io/name: gohoarder
app.kubernetes.io/component: gateway
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: gohoarder-gateway
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
+104
View File
@@ -0,0 +1,104 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: gohoarder
namespace: default
labels:
app: gohoarder
spec:
replicas: 2
selector:
matchLabels:
app: gohoarder
template:
metadata:
labels:
app: gohoarder
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
fsGroup: 1000
containers:
- name: gohoarder
image: gohoarder:latest
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 8080
protocol: TCP
env:
- name: CONFIG_FILE
value: /etc/gohoarder/config.yaml
volumeMounts:
# Configuration file
- name: config
mountPath: /etc/gohoarder/config.yaml
subPath: config.yaml
readOnly: true
# Git credentials (pattern-based)
- name: git-credentials
mountPath: /etc/gohoarder/git-credentials.json
subPath: credentials.json
readOnly: true
# Persistent storage for cache
- name: cache
mountPath: /var/lib/gohoarder/cache
# Persistent storage for metadata database
- name: metadata
mountPath: /var/lib/gohoarder
resources:
requests:
memory: "512Mi"
cpu: "250m"
limits:
memory: "2Gi"
cpu: "1000m"
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 10
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /health/ready
port: http
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
volumes:
# ConfigMap with application configuration
- name: config
configMap:
name: gohoarder-config
# Secret with git credentials
- name: git-credentials
secret:
secretName: gohoarder-git-credentials
defaultMode: 0400 # Read-only for owner
# PersistentVolumeClaim for cache
- name: cache
persistentVolumeClaim:
claimName: gohoarder-cache-pvc
# PersistentVolumeClaim for metadata
- name: metadata
persistentVolumeClaim:
claimName: gohoarder-metadata-pvc
+29
View File
@@ -0,0 +1,29 @@
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: gohoarder-cache-pvc
namespace: default
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 20Gi
# Uncomment and set your storage class if needed
# storageClassName: fast-ssd
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: gohoarder-metadata-pvc
namespace: default
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
# Uncomment and set your storage class if needed
# storageClassName: standard
@@ -0,0 +1,61 @@
apiVersion: v1
kind: Secret
metadata:
name: gohoarder-git-credentials
namespace: default
type: Opaque
stringData:
credentials.json: |
{
"credentials": [
{
"pattern": "github.com/mycompany/*",
"host": "github.com",
"username": "oauth2",
"token": "ghp_REPLACE_WITH_YOUR_GITHUB_TOKEN",
"fallback": false
},
{
"pattern": "github.com/external-vendor/*",
"host": "github.com",
"username": "oauth2",
"token": "ghp_REPLACE_WITH_VENDOR_TOKEN",
"fallback": false
},
{
"pattern": "gitlab.com/backend-team/*",
"host": "gitlab.com",
"username": "oauth2",
"token": "glpat_REPLACE_WITH_GITLAB_TOKEN",
"fallback": false
},
{
"pattern": "*",
"host": "*",
"username": "oauth2",
"token": "ghp_REPLACE_WITH_DEFAULT_READONLY_TOKEN",
"fallback": true
}
]
}
---
# Example using External Secrets Operator (ESO)
# Uncomment and configure if you're using ESO
# apiVersion: external-secrets.io/v1beta1
# kind: ExternalSecret
# metadata:
# name: gohoarder-git-credentials
# namespace: default
# spec:
# refreshInterval: 1h
# secretStoreRef:
# name: vault-backend # Your SecretStore name
# kind: SecretStore
# target:
# name: gohoarder-git-credentials
# creationPolicy: Owner
# data:
# - secretKey: credentials.json
# remoteRef:
# key: secret/gohoarder/git-credentials
# property: credentials.json
+44
View File
@@ -0,0 +1,44 @@
apiVersion: v1
kind: Service
metadata:
name: gohoarder
namespace: default
labels:
app: gohoarder
spec:
type: ClusterIP
ports:
- port: 8080
targetPort: http
protocol: TCP
name: http
selector:
app: gohoarder
---
# Optional: Ingress for external access
# Uncomment and configure based on your ingress controller
# apiVersion: networking.k8s.io/v1
# kind: Ingress
# metadata:
# name: gohoarder
# namespace: default
# annotations:
# nginx.ingress.kubernetes.io/proxy-body-size: "500m"
# nginx.ingress.kubernetes.io/proxy-read-timeout: "600"
# spec:
# ingressClassName: nginx
# rules:
# - host: gohoarder.example.com
# http:
# paths:
# - path: /
# pathType: Prefix
# backend:
# service:
# name: gohoarder
# port:
# name: http
# tls:
# - hosts:
# - gohoarder.example.com
# secretName: gohoarder-tls
+151
View File
@@ -0,0 +1,151 @@
version: '3.8'
# GoHoarder - Unified Deployment Example
# This docker-compose file demonstrates deploying all GoHoarder services
# under a single domain using the gateway reverse proxy
services:
# Backend - Main application server
gohoarder-server:
image: ghcr.io/lukaszraczylo/gohoarder-server:latest
container_name: gohoarder-server
restart: unless-stopped
environment:
# Application configuration
- CONFIG_FILE=/config/config.yaml
# Database
- DB_PATH=/data/metadata/gohoarder.db
# Storage
- STORAGE_BACKEND=filesystem
- STORAGE_PATH=/data/cache
# Security scanning
- ENABLE_SCANNING=true
- SCAN_ON_DOWNLOAD=true
# Logging
- LOG_LEVEL=info
- LOG_FORMAT=json
volumes:
# Configuration
- ./config.yaml:/config/config.yaml:ro
# Data persistence
- gohoarder-cache:/data/cache
- gohoarder-metadata:/data/metadata
networks:
- gohoarder-internal
healthcheck:
test: ["CMD", "/usr/local/bin/gohoarder", "version"]
interval: 30s
timeout: 10s
retries: 3
start_period: 5s
# Frontend - Web dashboard
gohoarder-frontend:
image: ghcr.io/lukaszraczylo/gohoarder-frontend:latest
container_name: gohoarder-frontend
restart: unless-stopped
environment:
# Runtime configuration - injected into /config.js
- API_BASE_URL=/api
- APP_VERSION=1.0.0
- APP_NAME=GoHoarder
networks:
- gohoarder-internal
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 5s
# Scanner - Background vulnerability scanner (optional)
gohoarder-scanner:
image: ghcr.io/lukaszraczylo/gohoarder-scanner:latest
container_name: gohoarder-scanner
restart: unless-stopped
environment:
- CONFIG_FILE=/config/config.yaml
- SCANNER_MODE=true
- SCANNER_WORKERS=4
- SCANNER_INTERVAL=300
- LOG_LEVEL=info
volumes:
- ./config.yaml:/config/config.yaml:ro
- gohoarder-cache:/data/cache:ro
- gohoarder-metadata:/data/metadata
networks:
- gohoarder-internal
depends_on:
- gohoarder-server
# Uncomment if you want to run scanner separately
# If commented out, scanning happens inline in the server
# profiles:
# - scanner
# Gateway - Nginx reverse proxy
gohoarder-gateway:
image: ghcr.io/lukaszraczylo/gohoarder-gateway:latest
container_name: gohoarder-gateway
restart: unless-stopped
environment:
# Backend service connection
- BACKEND_HOST=gohoarder-server
- BACKEND_PORT=8080
# Frontend service connection
- FRONTEND_HOST=gohoarder-frontend
- FRONTEND_PORT=80
# Server configuration
- SERVER_NAME=hoarder.i.raczylo.com
ports:
# Map to host port 80 (HTTP)
- "80:80"
# Map to host port 443 (HTTPS) - uncomment if using SSL
# - "443:443"
networks:
- gohoarder-internal
depends_on:
- gohoarder-server
- gohoarder-frontend
# Uncomment if using custom SSL certificates
# volumes:
# - ./ssl/cert.pem:/etc/nginx/ssl/cert.pem:ro
# - ./ssl/key.pem:/etc/nginx/ssl/key.pem:ro
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 5s
networks:
gohoarder-internal:
driver: bridge
volumes:
# Persistent storage for cached packages
gohoarder-cache:
driver: local
# Persistent storage for metadata and scan results
gohoarder-metadata:
driver: local
# Usage:
# 1. Copy this file: cp docker-compose.example.yaml docker-compose.yaml
# 2. Copy config: cp config.yaml.example config.yaml
# 3. Edit config.yaml with your settings
# 4. Start services: docker-compose up -d
# 5. View logs: docker-compose logs -f
# 6. Stop services: docker-compose down
#
# Access:
# - Web UI: http://localhost or http://hoarder.i.raczylo.com
# - API: http://localhost/api or http://hoarder.i.raczylo.com/api
# - Health: http://localhost/health
# - Metrics: http://localhost/metrics
#
# For production:
# - Enable HTTPS in the gateway container
# - Set up proper SSL certificates
# - Configure firewall rules
# - Set appropriate resource limits
# - Enable monitoring and alerting
+7
View File
@@ -0,0 +1,7 @@
# Backend API URL (used by Vite dev server proxy)
# Change this if your gohoarder backend is running on a different port
VITE_BACKEND_URL=http://localhost:8080
# Frontend dev server port
# The Vite development server will run on this port
VITE_PORT=5173
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "new-york",
"typescript": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/styles/main.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"composables": "@/composables"
},
"registries": {}
}
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<title>GoHoarder Dashboard</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+43
View File
@@ -0,0 +1,43 @@
{
"name": "gohoarder-dashboard",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@vueuse/core": "^14.1.0",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-vue-next": "^0.562.0",
"marked": "^17.0.1",
"pinia": "^3.0.4",
"reka-ui": "^2.7.0",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
"vue": "^3.5.26",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@fortawesome/fontawesome-free": "^7.1.0",
"@tailwindcss/typography": "^0.5.19",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/vue": "^8.1.0",
"@vitejs/plugin-vue": "^6.0.3",
"@vue/test-utils": "^2.4.6",
"autoprefixer": "^10.4.23",
"jsdom": "^27.4.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3",
"vite": "^7.3.0",
"vitest": "^4.0.16",
"vue-tsc": "^3.2.1"
}
}
+3795
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+61
View File
@@ -0,0 +1,61 @@
<template>
<div class="min-h-screen bg-background">
<nav class="sticky top-0 z-50 bg-card/95 backdrop-blur-lg shadow-sm border-b">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-primary text-primary-foreground">
<i class="fas fa-box-open text-lg"></i>
</div>
<div class="flex flex-col">
<h1 class="text-lg font-semibold tracking-tight text-foreground">GoHoarder</h1>
<p class="text-xs text-muted-foreground">Package Cache Manager</p>
</div>
</div>
<div class="flex items-center gap-1">
<router-link
to="/"
class="inline-flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium text-foreground/70 hover:text-foreground hover:bg-accent transition-colors"
active-class="text-foreground bg-accent"
>
<i class="fas fa-chart-pie text-sm"></i>
<span>Dashboard</span>
</router-link>
<router-link
to="/packages"
class="inline-flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium text-foreground/70 hover:text-foreground hover:bg-accent transition-colors"
active-class="text-foreground bg-accent"
>
<i class="fas fa-boxes text-sm"></i>
<span>Packages</span>
</router-link>
<router-link
to="/stats"
class="inline-flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium text-foreground/70 hover:text-foreground hover:bg-accent transition-colors"
active-class="text-foreground bg-accent"
>
<i class="fas fa-chart-bar text-sm"></i>
<span>Statistics</span>
</router-link>
<router-link
to="/admin/bypasses"
class="inline-flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium text-foreground/70 hover:text-foreground hover:bg-accent transition-colors"
active-class="text-foreground bg-accent"
>
<i class="fas fa-shield-alt text-sm"></i>
<span>Admin</span>
</router-link>
</div>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
<router-view />
</main>
</div>
</template>
<script setup lang="ts">
// App component
</script>
@@ -0,0 +1,609 @@
<template>
<div>
<div class="flex justify-between items-center mb-8">
<div>
<h2 class="text-3xl font-bold text-gray-900">CVE Bypass Management</h2>
<p class="text-gray-600 mt-1">Manage temporary security bypasses for packages and CVEs</p>
</div>
<Button @click="showCreateModal = true" class="bg-green-600 hover:bg-green-700">
<i class="fas fa-plus mr-2"></i>Create Bypass
</Button>
</div>
<!-- Filter Section -->
<div class="mb-6 flex flex-col sm:flex-row items-start sm:items-center gap-4">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-gray-700">Filter:</span>
<div class="flex gap-2">
<Button
@click="activeFilter = 'all'"
:variant="activeFilter === 'all' ? 'default' : 'outline'"
size="sm"
>
All
</Button>
<Button
@click="activeFilter = 'active'"
:variant="activeFilter === 'active' ? 'default' : 'outline'"
size="sm"
>
<i class="fas fa-check-circle mr-1"></i>Active
</Button>
<Button
@click="activeFilter === 'expired'"
:variant="activeFilter === 'expired' ? 'default' : 'outline'"
size="sm"
>
<i class="fas fa-clock mr-1"></i>Expired
</Button>
</div>
</div>
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-gray-700">Type:</span>
<div class="flex gap-2">
<Button
@click="typeFilter = ''"
:variant="typeFilter === '' ? 'default' : 'outline'"
size="sm"
>
All
</Button>
<Button
@click="typeFilter = 'cve'"
:variant="typeFilter === 'cve' ? 'default' : 'outline'"
size="sm"
>
CVE
</Button>
<Button
@click="typeFilter = 'package'"
:variant="typeFilter === 'package' ? 'default' : 'outline'"
size="sm"
>
Package
</Button>
</div>
</div>
<Button @click="fetchBypasses" variant="outline" size="sm" class="sm:ml-auto">
<i class="fas fa-sync-alt mr-2"></i>Refresh
</Button>
</div>
<!-- Error Alert -->
<Alert v-if="error" variant="destructive" class="mb-4">
<i class="fas fa-exclamation-circle mr-2"></i>
<AlertDescription>{{ error }}</AlertDescription>
</Alert>
<!-- Success Alert -->
<Alert v-if="successMessage" class="mb-4 bg-green-50 border-green-200">
<i class="fas fa-check-circle mr-2 text-green-600"></i>
<AlertDescription class="text-green-800">{{ successMessage }}</AlertDescription>
</Alert>
<!-- Loading State -->
<div v-if="loading" class="text-center py-12">
<i class="fas fa-spinner fa-spin text-4xl text-primary-600"></i>
<p class="mt-4 text-gray-600">Loading bypasses...</p>
</div>
<!-- Bypass List -->
<Card v-else>
<CardContent class="p-6">
<div v-if="filteredBypasses.length === 0" class="text-center py-12 text-gray-500">
<i class="fas fa-shield-alt text-6xl mb-4"></i>
<p class="text-xl">No bypasses found</p>
<p class="mt-2">Create a bypass to allow packages with known vulnerabilities</p>
</div>
<div v-else class="space-y-4">
<div
v-for="bypass in filteredBypasses"
:key="bypass.id"
class="border rounded-lg p-4 hover:bg-gray-50"
:class="bypass.active ? 'border-gray-200' : 'border-gray-300 bg-gray-50'"
>
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<Badge :variant="bypass.type === 'cve' ? 'default' : 'outline'">
{{ bypass.type.toUpperCase() }}
</Badge>
<Badge
:class="bypass.active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600'"
>
{{ bypass.active ? 'ACTIVE' : 'INACTIVE' }}
</Badge>
<Badge
v-if="isExpired(bypass.expires_at)"
class="bg-red-100 text-red-800"
>
EXPIRED
</Badge>
</div>
<h4 class="font-semibold text-lg text-gray-900">{{ bypass.target }}</h4>
<p class="text-sm text-gray-600 mt-1">{{ bypass.reason }}</p>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mt-3 text-sm text-gray-500">
<div>
<i class="fas fa-user mr-1"></i>
<strong>Created by:</strong> {{ bypass.created_by }}
</div>
<div>
<i class="fas fa-calendar mr-1"></i>
<strong>Created:</strong> {{ formatDate(bypass.created_at) }}
</div>
<div>
<i class="fas fa-clock mr-1"></i>
<strong>Expires:</strong> {{ formatDate(bypass.expires_at) }}
</div>
<div v-if="bypass.applies_to">
<i class="fas fa-box mr-1"></i>
<strong>Applies to:</strong> {{ bypass.applies_to }}
</div>
</div>
</div>
<div class="flex items-center gap-2 ml-4">
<Button
@click="editBypass(bypass)"
variant="outline"
size="sm"
title="Edit bypass"
>
<i class="fas fa-edit"></i>
</Button>
<Button
@click="confirmDeleteBypass(bypass)"
variant="destructive"
size="sm"
title="Delete bypass"
>
<i class="fas fa-trash"></i>
</Button>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
<!-- Create/Edit Bypass Modal -->
<Dialog v-model:open="showCreateModal">
<DialogContent class="max-w-2xl">
<DialogHeader>
<DialogTitle>{{ editingBypass ? 'Edit' : 'Create' }} Bypass</DialogTitle>
<DialogDescription>
{{ editingBypass ? 'Update bypass settings' : 'Create a temporary bypass for a CVE or package' }}
</DialogDescription>
</DialogHeader>
<div class="space-y-4 py-4">
<!-- Type Selection -->
<div v-if="!editingBypass">
<label class="text-sm font-medium text-gray-700 mb-2 block">Bypass Type</label>
<div class="flex gap-4">
<Button
@click="bypassForm.type = 'cve'"
:variant="bypassForm.type === 'cve' ? 'default' : 'outline'"
class="flex-1"
>
<i class="fas fa-bug mr-2"></i>CVE Bypass
</Button>
<Button
@click="bypassForm.type = 'package'"
:variant="bypassForm.type === 'package' ? 'default' : 'outline'"
class="flex-1"
>
<i class="fas fa-box mr-2"></i>Package Bypass
</Button>
</div>
</div>
<!-- Target -->
<div v-if="!editingBypass">
<label class="text-sm font-medium text-gray-700 mb-2 block">
{{ bypassForm.type === 'cve' ? 'CVE ID' : 'Package' }}
</label>
<Input
v-model="bypassForm.target"
:placeholder="bypassForm.type === 'cve' ? 'CVE-2021-23337' : 'npm/lodash@4.17.20'"
class="w-full"
/>
<p class="text-xs text-gray-500 mt-1">
{{ bypassForm.type === 'cve' ? 'Enter the CVE ID (e.g., CVE-2021-23337)' : 'Enter package (e.g., npm/lodash@4.17.20 or npm/lodash for all versions)' }}
</p>
</div>
<!-- Applies To (CVE only) -->
<div v-if="!editingBypass && bypassForm.type === 'cve'">
<label class="text-sm font-medium text-gray-700 mb-2 block">
Applies To (Optional)
</label>
<Input
v-model="bypassForm.applies_to"
placeholder="npm/lodash@4.17.20"
class="w-full"
/>
<p class="text-xs text-gray-500 mt-1">
Limit this CVE bypass to a specific package. Leave empty to apply to all packages with this CVE.
</p>
</div>
<!-- Reason -->
<div>
<label class="text-sm font-medium text-gray-700 mb-2 block">Reason *</label>
<Input
v-model="bypassForm.reason"
placeholder="No fix available, business critical dependency"
class="w-full"
/>
<p class="text-xs text-gray-500 mt-1">
Explain why this bypass is needed (required for audit trail)
</p>
</div>
<!-- Created By -->
<div v-if="!editingBypass">
<label class="text-sm font-medium text-gray-700 mb-2 block">Created By *</label>
<Input
v-model="bypassForm.created_by"
placeholder="admin@example.com"
class="w-full"
/>
</div>
<!-- Expires In -->
<div>
<label class="text-sm font-medium text-gray-700 mb-2 block">Expires In (Hours) *</label>
<Input
v-model.number="bypassForm.expires_in_hours"
type="number"
min="1"
placeholder="168"
class="w-full"
/>
<p class="text-xs text-gray-500 mt-1">
How many hours until this bypass expires (e.g., 168 = 7 days, 720 = 30 days)
</p>
</div>
<!-- Active (Edit only) -->
<div v-if="editingBypass" class="flex items-center gap-2">
<input
type="checkbox"
v-model="bypassForm.active"
id="active-checkbox"
class="w-4 h-4"
/>
<label for="active-checkbox" class="text-sm font-medium text-gray-700">
Active
</label>
</div>
<!-- Notify on Expiry -->
<div class="flex items-center gap-2">
<input
type="checkbox"
v-model="bypassForm.notify_on_expiry"
id="notify-checkbox"
class="w-4 h-4"
/>
<label for="notify-checkbox" class="text-sm font-medium text-gray-700">
Send notification when bypass expires
</label>
</div>
</div>
<DialogFooter>
<Button @click="closeCreateModal" variant="outline">
Cancel
</Button>
<Button @click="submitBypass" :disabled="!isFormValid">
{{ editingBypass ? 'Update' : 'Create' }} Bypass
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- Delete Confirmation Dialog -->
<Dialog v-model:open="showDeleteModal">
<DialogContent>
<DialogHeader>
<DialogTitle>Confirm Deletion</DialogTitle>
<DialogDescription>
Are you sure you want to delete this bypass for <strong>{{ bypassToDelete?.target }}</strong>?
This action cannot be undone and the security check will be re-enabled immediately.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button @click="showDeleteModal = false" variant="outline">
Cancel
</Button>
<Button @click="deleteBypass" variant="destructive">
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import axios from 'axios'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
interface Bypass {
id: string
type: 'cve' | 'package'
target: string
reason: string
created_by: string
created_at: string
expires_at: string
applies_to?: string
notify_on_expiry: boolean
active: boolean
}
const loading = ref(false)
const error = ref<string | null>(null)
const successMessage = ref<string | null>(null)
const bypasses = ref<Bypass[]>([])
const activeFilter = ref<'all' | 'active' | 'expired'>('all')
const typeFilter = ref<'' | 'cve' | 'package'>('')
const showCreateModal = ref(false)
const showDeleteModal = ref(false)
const editingBypass = ref<Bypass | null>(null)
const bypassToDelete = ref<Bypass | null>(null)
const bypassForm = ref({
type: 'cve' as 'cve' | 'package',
target: '',
reason: '',
created_by: '',
expires_in_hours: 168,
applies_to: '',
notify_on_expiry: false,
active: true,
})
// Get API key from localStorage or prompt user
const apiKey = ref<string>('')
const filteredBypasses = computed(() => {
let filtered = bypasses.value
// Filter by active/expired
if (activeFilter.value === 'active') {
filtered = filtered.filter(b => b.active && !isExpired(b.expires_at))
} else if (activeFilter.value === 'expired') {
filtered = filtered.filter(b => isExpired(b.expires_at))
}
// Filter by type
if (typeFilter.value) {
filtered = filtered.filter(b => b.type === typeFilter.value)
}
return filtered
})
const isFormValid = computed(() => {
if (editingBypass.value) {
return bypassForm.value.reason.trim() !== '' && bypassForm.value.expires_in_hours > 0
}
return (
bypassForm.value.target.trim() !== '' &&
bypassForm.value.reason.trim() !== '' &&
bypassForm.value.created_by.trim() !== '' &&
bypassForm.value.expires_in_hours > 0
)
})
onMounted(() => {
// Try to get API key from localStorage
apiKey.value = localStorage.getItem('admin_api_key') || ''
if (!apiKey.value) {
promptForApiKey()
} else {
fetchBypasses()
}
})
function promptForApiKey() {
const key = prompt('Enter your admin API key:')
if (key) {
apiKey.value = key
localStorage.setItem('admin_api_key', key)
fetchBypasses()
} else {
error.value = 'Admin API key required to manage bypasses'
}
}
async function fetchBypasses() {
if (!apiKey.value) {
promptForApiKey()
return
}
loading.value = true
error.value = null
try {
const params = new URLSearchParams()
if (activeFilter.value === 'active') {
params.append('active_only', 'true')
} else if (activeFilter.value === 'expired') {
params.append('include_expired', 'true')
}
if (typeFilter.value) {
params.append('type', typeFilter.value)
}
const response = await axios.get('/api/admin/bypasses?' + params.toString(), {
headers: {
Authorization: `Bearer ${apiKey.value}`,
},
})
bypasses.value = response.data.bypasses || []
} catch (err: any) {
console.error('Failed to fetch bypasses:', err)
if (err.response?.status === 401) {
error.value = 'Invalid API key. Please check your credentials.'
localStorage.removeItem('admin_api_key')
apiKey.value = ''
} else {
error.value = err.response?.data?.error?.message || err.message || 'Failed to load bypasses'
}
} finally {
loading.value = false
}
}
async function submitBypass() {
if (!isFormValid.value) return
loading.value = true
error.value = null
successMessage.value = null
try {
if (editingBypass.value) {
// Update existing bypass
await axios.patch(
`/api/admin/bypasses/${editingBypass.value.id}`,
{
active: bypassForm.value.active,
reason: bypassForm.value.reason,
expires_in_hours: bypassForm.value.expires_in_hours,
},
{
headers: {
Authorization: `Bearer ${apiKey.value}`,
},
}
)
successMessage.value = 'Bypass updated successfully'
} else {
// Create new bypass
await axios.post(
'/api/admin/bypasses',
{
type: bypassForm.value.type,
target: bypassForm.value.target,
reason: bypassForm.value.reason,
created_by: bypassForm.value.created_by,
expires_in_hours: bypassForm.value.expires_in_hours,
applies_to: bypassForm.value.applies_to || undefined,
notify_on_expiry: bypassForm.value.notify_on_expiry,
},
{
headers: {
Authorization: `Bearer ${apiKey.value}`,
},
}
)
successMessage.value = 'Bypass created successfully'
}
closeCreateModal()
await fetchBypasses()
// Clear success message after 5 seconds
setTimeout(() => {
successMessage.value = null
}, 5000)
} catch (err: any) {
console.error('Failed to submit bypass:', err)
error.value = err.response?.data?.error?.message || err.message || 'Failed to save bypass'
} finally {
loading.value = false
}
}
function editBypass(bypass: Bypass) {
editingBypass.value = bypass
bypassForm.value = {
type: bypass.type,
target: bypass.target,
reason: bypass.reason,
created_by: bypass.created_by,
expires_in_hours: 168, // Default extension
applies_to: bypass.applies_to || '',
notify_on_expiry: bypass.notify_on_expiry,
active: bypass.active,
}
showCreateModal.value = true
}
function closeCreateModal() {
showCreateModal.value = false
editingBypass.value = null
bypassForm.value = {
type: 'cve',
target: '',
reason: '',
created_by: '',
expires_in_hours: 168,
applies_to: '',
notify_on_expiry: false,
active: true,
}
}
function confirmDeleteBypass(bypass: Bypass) {
bypassToDelete.value = bypass
showDeleteModal.value = true
}
async function deleteBypass() {
if (!bypassToDelete.value) return
loading.value = true
error.value = null
successMessage.value = null
try {
await axios.delete(`/api/admin/bypasses/${bypassToDelete.value.id}`, {
headers: {
Authorization: `Bearer ${apiKey.value}`,
},
})
successMessage.value = 'Bypass deleted successfully'
showDeleteModal.value = false
bypassToDelete.value = null
await fetchBypasses()
// Clear success message after 5 seconds
setTimeout(() => {
successMessage.value = null
}, 5000)
} catch (err: any) {
console.error('Failed to delete bypass:', err)
error.value = err.response?.data?.error?.message || err.message || 'Failed to delete bypass'
} finally {
loading.value = false
}
}
function isExpired(expiresAt: string): boolean {
return new Date(expiresAt) < new Date()
}
function formatDate(date: string): string {
return new Date(date).toLocaleString()
}
</script>
+208
View File
@@ -0,0 +1,208 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import Dashboard from './Dashboard.vue'
import { usePackageStore } from '../stores/packages'
describe('Dashboard.vue', () => {
beforeEach(() => {
setActivePinia(createPinia())
// Mock the fetch functions to prevent actual API calls
const store = usePackageStore()
vi.spyOn(store, 'fetchStats').mockResolvedValue()
vi.spyOn(store, 'fetchPackages').mockResolvedValue()
})
it('renders dashboard component', () => {
const wrapper = mount(Dashboard)
expect(wrapper.find('h2').text()).toBe('Dashboard')
})
it('displays error message when error occurs', async () => {
const wrapper = mount(Dashboard)
const store = usePackageStore()
store.error = 'Failed to load dashboard'
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Failed to load dashboard')
})
it('displays loading state when loading', async () => {
const wrapper = mount(Dashboard)
const store = usePackageStore()
store.loading = true
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Loading statistics...')
})
it('displays overview stats cards', async () => {
const wrapper = mount(Dashboard)
const store = usePackageStore()
store.loading = false
store.stats = {
registry: '',
total_packages: 2,
total_size: 3072,
total_downloads: 30,
scanned_packages: 2,
vulnerable_packages: 0,
}
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Total Packages')
expect(wrapper.text()).toContain('Total Size')
expect(wrapper.text()).toContain('Total Downloads')
})
it('displays total packages from stats', async () => {
const wrapper = mount(Dashboard)
const store = usePackageStore()
store.loading = false
store.stats = {
registry: '',
total_packages: 100,
total_size: 0,
total_downloads: 0,
scanned_packages: 0,
vulnerable_packages: 0,
}
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('100')
})
it('displays total downloads from stats', async () => {
const wrapper = mount(Dashboard)
const store = usePackageStore()
store.loading = false
store.stats = {
registry: '',
total_packages: 0,
total_size: 0,
total_downloads: 500,
scanned_packages: 0,
vulnerable_packages: 0,
}
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('500')
})
it('displays total size from stats', async () => {
const wrapper = mount(Dashboard)
const store = usePackageStore()
store.loading = false
store.stats = {
registry: '',
total_packages: 0,
total_size: 1048576, // 1 MB
total_downloads: 0,
scanned_packages: 0,
vulnerable_packages: 0,
}
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('1 MB')
})
it('displays recent packages section', async () => {
const wrapper = mount(Dashboard)
const store = usePackageStore()
store.loading = false
store.packages = [
{
id: '1',
registry: 'npm',
name: 'test-package',
version: '1.0.0',
size: 1024,
cached_at: '2025-01-01T00:00:00Z',
last_accessed: '2025-01-01T00:00:00Z',
download_count: 10,
},
]
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Recent Packages')
})
it('shows recent packages sorted by cached_at', async () => {
const wrapper = mount(Dashboard)
const store = usePackageStore()
store.loading = false
store.packages = [
{
id: '1',
registry: 'npm',
name: 'old-package',
version: '1.0.0',
size: 1024,
cached_at: '2025-01-01T00:00:00Z',
last_accessed: '2025-01-01T00:00:00Z',
download_count: 10,
},
{
id: '2',
registry: 'npm',
name: 'new-package',
version: '1.0.0',
size: 1024,
cached_at: '2025-01-02T00:00:00Z',
last_accessed: '2025-01-02T00:00:00Z',
download_count: 5,
},
]
await wrapper.vm.$nextTick()
const recentPackagesSection = wrapper.text().split('Recent Packages')[1]
// new-package should appear before old-package since it's more recent
expect(recentPackagesSection.indexOf('new-package')).toBeLessThan(
recentPackagesSection.indexOf('old-package')
)
})
it('limits recent packages to 10 items', async () => {
const wrapper = mount(Dashboard)
const store = usePackageStore()
store.loading = false
store.packages = Array.from({ length: 15 }, (_, i) => ({
id: `${i}`,
registry: 'npm',
name: `package${i}`,
version: '1.0.0',
size: 1024,
cached_at: `2025-01-${String(i + 1).padStart(2, '0')}T00:00:00Z`,
last_accessed: `2025-01-${String(i + 1).padStart(2, '0')}T00:00:00Z`,
download_count: i,
}))
await wrapper.vm.$nextTick()
const recentSection = wrapper.text().split('Recent Packages')[1]
// Count how many "package" strings appear (each package has the word "package" in its name)
const packageCount = (recentSection.match(/package\d+/g) || []).length
expect(packageCount).toBeLessThanOrEqual(10)
})
it('handles empty packages array', async () => {
const wrapper = mount(Dashboard)
const store = usePackageStore()
store.loading = false
store.packages = []
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('0')
expect(wrapper.text()).toContain('0 B')
})
})
+305
View File
@@ -0,0 +1,305 @@
<template>
<div>
<h2 class="text-3xl font-bold text-gray-900 mb-8">Dashboard</h2>
<!-- Error Alert -->
<Alert v-if="error" variant="destructive" class="mb-4">
<i class="fas fa-exclamation-circle mr-2"></i>
<AlertDescription>{{ error }}</AlertDescription>
</Alert>
<!-- Loading State -->
<div v-if="loading" class="text-center py-12">
<i class="fas fa-spinner fa-spin text-4xl text-primary-600"></i>
<p class="mt-4 text-gray-600">Loading statistics...</p>
</div>
<!-- Stats Grid -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-10">
<Card class="border-0 shadow-lg hover:shadow-xl transition-shadow">
<CardContent class="p-6">
<div class="flex items-start justify-between">
<div class="space-y-2">
<p class="text-sm font-medium text-muted-foreground">Total Packages</p>
<p class="text-3xl font-bold text-foreground tracking-tight">
{{ formatNumber(stats?.total_packages || 0) }}
</p>
</div>
<div class="w-12 h-12 bg-slate-100 rounded-xl flex items-center justify-center">
<i class="fas fa-boxes text-slate-700 text-lg"></i>
</div>
</div>
</CardContent>
</Card>
<Card class="border-0 shadow-lg hover:shadow-xl transition-shadow">
<CardContent class="p-6">
<div class="flex items-start justify-between">
<div class="space-y-2">
<p class="text-sm font-medium text-muted-foreground">Total Size</p>
<p class="text-3xl font-bold text-foreground tracking-tight">
{{ formatBytes(stats?.total_size || 0) }}
</p>
</div>
<div class="w-12 h-12 bg-sky-50 rounded-xl flex items-center justify-center">
<i class="fas fa-hard-drive text-sky-600 text-lg"></i>
</div>
</div>
</CardContent>
</Card>
<Card class="border-0 shadow-lg hover:shadow-xl transition-shadow">
<CardContent class="p-6">
<div class="flex items-start justify-between">
<div class="space-y-2">
<p class="text-sm font-medium text-muted-foreground">Total Downloads</p>
<p class="text-3xl font-bold text-foreground tracking-tight">
{{ formatNumber(stats?.total_downloads || 0) }}
</p>
</div>
<div class="w-12 h-12 bg-emerald-50 rounded-xl flex items-center justify-center">
<i class="fas fa-download text-emerald-600 text-lg"></i>
</div>
</div>
</CardContent>
</Card>
<Card class="border-0 shadow-lg hover:shadow-xl transition-shadow">
<CardContent class="p-6">
<div class="flex items-start justify-between">
<div class="space-y-2">
<p class="text-sm font-medium text-muted-foreground">Scanned Packages</p>
<p class="text-3xl font-bold text-foreground tracking-tight">
{{ formatNumber(stats?.scanned_packages || 0) }}
</p>
</div>
<div class="w-12 h-12 bg-violet-50 rounded-xl flex items-center justify-center">
<i class="fas fa-shield-alt text-violet-600 text-lg"></i>
</div>
</div>
</CardContent>
</Card>
</div>
<!-- Downloads Chart -->
<Card class="border-0 shadow-lg mb-10">
<CardContent class="p-6">
<div class="flex items-center justify-between mb-6">
<h3 class="text-xl font-semibold text-foreground">Download Activity</h3>
<div class="flex gap-2">
<Button
v-for="period in chartPeriods"
:key="period.value"
@click="selectedPeriod = period.value"
:variant="selectedPeriod === period.value ? 'default' : 'outline'"
size="sm"
>
{{ period.label }}
</Button>
</div>
</div>
<div class="h-64 flex items-end justify-between gap-2">
<div
v-for="(value, index) in chartData"
:key="index"
class="flex-1 flex flex-col items-center gap-2"
>
<div class="w-full bg-slate-100 rounded-t-lg relative" :style="{ height: `${(value / maxChartValue) * 100}%` }">
<div class="absolute inset-0 bg-gradient-to-t from-slate-700 to-slate-500 rounded-t-lg"></div>
</div>
<span class="text-xs text-muted-foreground">{{ getChartLabel(index) }}</span>
</div>
</div>
<div v-if="chartLoading || chartData.length === 0" class="mt-4 text-center">
<p class="text-sm text-muted-foreground">
<i class="fas fa-info-circle mr-1"></i>
{{ chartLoading ? 'Loading chart data...' : 'No download activity in this period' }}
</p>
</div>
</CardContent>
</Card>
<!-- Recent Packages -->
<Card><CardContent class="p-6">
<h3 class="text-xl font-semibold text-gray-900 mb-4">
<i class="fas fa-clock mr-2"></i>Recent Packages
</h3>
<div v-if="packages.length === 0" class="text-center py-8 text-gray-500">
<i class="fas fa-inbox text-4xl mb-4"></i>
<p>No packages cached yet</p>
</div>
<div v-else class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead>
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Package
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Version
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Registry
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Size
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Downloads
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="pkg in recentPackages" :key="pkg.id">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{{ pkg.name }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ pkg.version }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<Badge variant="outline" :class="getRegistryBadgeClass(pkg.registry)">
{{ pkg.registry }}
</Badge>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ formatBytes(pkg.size) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ formatNumber(pkg.download_count) }}
</td>
</tr>
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import axios from 'axios'
import { usePackageStore } from '../stores/packages'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { getRegistryBadgeClass } from '@/composables/useBadgeStyles'
const store = usePackageStore()
const { packages, stats, loading, error } = storeToRefs(store)
// Chart periods and data
const selectedPeriod = ref<string>('1day')
const chartPeriods = [
{ value: '1h', label: '1 Hour' },
{ value: '1day', label: '24 Hours' },
{ value: '7day', label: '7 Days' },
{ value: '30day', label: '30 Days' },
]
// Time-series data from API
interface TimeSeriesDataPoint {
timestamp: string
value: number
}
interface TimeSeriesStats {
period: string
registry: string
data_points: TimeSeriesDataPoint[]
}
const timeSeriesData = ref<TimeSeriesStats | null>(null)
const chartLoading = ref(false)
// Fetch time-series data from API
async function fetchTimeSeriesData() {
chartLoading.value = true
try {
const response = await axios.get(`/api/stats/timeseries?period=${selectedPeriod.value}`)
timeSeriesData.value = response.data
} catch (err) {
console.error('Failed to fetch time-series data:', err)
timeSeriesData.value = null
} finally {
chartLoading.value = false
}
}
// Extract chart values from time-series data
const chartData = computed(() => {
if (!timeSeriesData.value || !timeSeriesData.value.data_points) {
return []
}
return timeSeriesData.value.data_points.map(point => point.value)
})
const maxChartValue = computed(() => {
if (chartData.value.length === 0) return 100
const max = Math.max(...chartData.value)
return max === 0 ? 100 : max
})
function getChartLabel(index: number): string {
// Use timestamp from data if available
if (timeSeriesData.value && timeSeriesData.value.data_points[index]) {
const date = new Date(timeSeriesData.value.data_points[index].timestamp)
switch (selectedPeriod.value) {
case '1h':
return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
case '1day':
return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
case '7day':
return date.toLocaleDateString('en-US', { weekday: 'short' })
case '30day':
return date.toLocaleDateString('en-US', { day: 'numeric' })
default:
return `${index}`
}
}
// Fallback to index-based labels
const labels: Record<string, (i: number) => string> = {
'1h': (i) => `${i * 5}m`,
'1day': (i) => `${i}:00`,
'7day': (i) => ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][i] || `Day ${i + 1}`,
'30day': (i) => `${i + 1}`,
}
return labels[selectedPeriod.value]?.(index) || `${index}`
}
// API returns clean, deduplicated data - just sort and limit
const recentPackages = computed(() => {
return packages.value
.slice()
.sort((a, b) => new Date(b.cached_at).getTime() - new Date(a.cached_at).getTime())
.slice(0, 10)
})
// Watch for period changes and fetch new data
watch(selectedPeriod, () => {
fetchTimeSeriesData()
})
onMounted(async () => {
await store.fetchStats()
await store.fetchPackages()
await fetchTimeSeriesData()
})
function formatNumber(num: number): string {
return new Intl.NumberFormat().format(num)
}
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 Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
}
</script>
+416
View File
@@ -0,0 +1,416 @@
<template>
<div class="max-w-7xl mx-auto px-4 py-8">
<!-- Header with Back Button -->
<div class="mb-6">
<Button @click="goBack" variant="ghost" class="mb-4">
<i class="fas fa-arrow-left mr-2"></i>
Back to Packages
</Button>
<div class="flex items-center gap-3">
<i class="fas fa-box text-3xl text-primary-600"></i>
<div>
<h1 class="text-3xl font-bold text-gray-900">{{ packageName }}</h1>
<div class="flex items-center gap-2 mt-2">
<Badge :class="getRegistryBadgeClass(registry)">{{ registry }}</Badge>
<Badge variant="outline" class="font-mono">v{{ version }}</Badge>
</div>
</div>
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="text-center py-12">
<i class="fas fa-spinner fa-spin text-4xl text-primary-600"></i>
<p class="mt-4 text-gray-600">Loading vulnerability details...</p>
</div>
<!-- Error State -->
<Alert v-else-if="error" variant="destructive" class="mb-4">
<i class="fas fa-exclamation-circle mr-2"></i>
<AlertDescription>{{ error }}</AlertDescription>
</Alert>
<!-- Vulnerability Details -->
<div v-else-if="vulnerabilities" class="space-y-6">
<!-- Summary Card -->
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<i class="fas fa-shield-virus text-red-600"></i>
Security Scan Summary
</CardTitle>
</CardHeader>
<CardContent>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<div class="text-center p-4 bg-red-50 rounded-lg border border-red-200">
<p class="text-3xl font-bold text-red-600">{{ severityCounts.critical }}</p>
<p class="text-sm text-gray-600 mt-1">Critical</p>
</div>
<div class="text-center p-4 bg-orange-50 rounded-lg border border-orange-200">
<p class="text-3xl font-bold text-orange-600">{{ severityCounts.high }}</p>
<p class="text-sm text-gray-600 mt-1">High</p>
</div>
<div class="text-center p-4 bg-yellow-50 rounded-lg border border-yellow-200">
<p class="text-3xl font-bold text-yellow-600">{{ severityCounts.moderate }}</p>
<p class="text-sm text-gray-600 mt-1">Moderate</p>
</div>
<div class="text-center p-4 bg-blue-50 rounded-lg border border-blue-200">
<p class="text-3xl font-bold text-blue-600">{{ severityCounts.low }}</p>
<p class="text-sm text-gray-600 mt-1">Low</p>
</div>
</div>
<Separator class="my-4" />
<div class="flex items-center justify-between text-sm">
<div class="flex flex-col gap-3">
<span class="text-gray-600">
<i class="fas fa-search mr-1"></i>
Scanned: {{ formatDate(vulnerabilities.scanned_at) }}
</span>
<div class="flex items-center gap-2">
<span class="text-gray-600">
<i class="fas fa-cog mr-1"></i>
Scanners:
</span>
<div class="flex flex-wrap gap-1">
<Badge
v-for="scanner in scannerList"
:key="scanner"
variant="secondary"
class="text-xs"
>
{{ scanner }}
</Badge>
</div>
</div>
</div>
<div v-if="bypassedCount > 0" class="flex items-center gap-2 text-green-600">
<i class="fas fa-check-circle"></i>
<span>{{ bypassedCount }} bypassed</span>
</div>
</div>
</CardContent>
</Card>
<!-- No Vulnerabilities -->
<Card v-if="vulnerabilityList.length === 0">
<CardContent class="text-center py-12">
<i class="fas fa-check-circle text-6xl text-green-500 mb-4"></i>
<p class="text-xl font-semibold text-gray-900">No Vulnerabilities Found</p>
<p class="mt-2 text-gray-600">This package is clean and safe to use</p>
</CardContent>
</Card>
<!-- Vulnerability List -->
<Card v-else>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<i class="fas fa-list text-gray-600"></i>
Detected Vulnerabilities ({{ vulnerabilityList.length }})
</CardTitle>
</CardHeader>
<CardContent class="p-0">
<div class="border-t overflow-x-auto">
<Table class="min-w-[800px]">
<TableHeader>
<TableRow class="bg-gray-50 hover:bg-gray-50">
<TableHead class="w-[100px]">Severity</TableHead>
<TableHead class="w-[180px]">CVE ID</TableHead>
<TableHead>Description</TableHead>
<TableHead class="w-[120px]">Fix Version</TableHead>
<TableHead class="w-[100px] text-center">Details</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<template v-for="(vuln, index) in vulnerabilityList" :key="vuln.id">
<!-- Main Row -->
<TableRow
:class="getRowClass(index, vuln.severity)"
@click="toggleRow(index)"
>
<TableCell>
<Badge :class="getSeverityBadgeClass(vuln.severity)">
{{ formatSeverityName(vuln.severity) }}
</Badge>
<span
v-if="vuln.bypassed"
class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800"
title="Bypassed"
>
<i class="fas fa-unlock text-xs"></i>
</span>
</TableCell>
<TableCell>
<span class="font-mono text-sm font-medium">{{ vuln.id }}</span>
</TableCell>
<TableCell>
<p class="text-sm text-gray-900 line-clamp-2 break-words">{{ vuln.title || vuln.description }}</p>
</TableCell>
<TableCell>
<span v-if="vuln.fixed_in" class="inline-flex items-center text-sm text-green-700">
<i class="fas fa-arrow-up text-xs mr-1"></i>
v{{ vuln.fixed_in }}
</span>
<span v-else class="text-sm text-gray-400">-</span>
</TableCell>
<TableCell class="text-center">
<Button
variant="ghost"
size="sm"
class="h-8 w-8 p-0"
@click.stop="toggleRow(index)"
>
<i
:class="[
'fas transition-transform',
expandedRows.has(index) ? 'fa-chevron-up' : 'fa-chevron-down'
]"
></i>
</Button>
</TableCell>
</TableRow>
<!-- Expanded Details Row -->
<TableRow v-if="expandedRows.has(index)" class="bg-gray-50 hover:bg-gray-50">
<TableCell colspan="5" class="p-0">
<div class="px-6 py-4 space-y-3">
<!-- Full Description -->
<div>
<h5 class="font-semibold text-gray-900 mb-2">Description</h5>
<div
class="text-sm text-gray-700 leading-relaxed prose prose-sm max-w-none break-words overflow-hidden"
v-html="renderMarkdown(vuln.description)"
></div>
</div>
<!-- Bypass Information -->
<div v-if="vuln.bypassed && vuln.bypass" class="bg-green-50 border border-green-200 rounded-lg p-3">
<h5 class="font-semibold text-green-900 mb-2 flex items-center gap-2">
<i class="fas fa-info-circle"></i>
Bypass Active
</h5>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm text-green-800">
<div class="break-words">
<span class="font-medium">Reason:</span> {{ vuln.bypass.reason }}
</div>
<div class="break-words">
<span class="font-medium">By:</span> {{ vuln.bypass.created_by }}
</div>
<div class="break-words">
<span class="font-medium">Expires:</span> {{ formatDate(vuln.bypass.expires_at) }}
</div>
</div>
</div>
<!-- Fix Information -->
<div v-if="vuln.fixed_in" class="bg-blue-50 border border-blue-200 rounded-lg p-3">
<div class="flex items-center gap-2">
<i class="fas fa-wrench text-blue-700"></i>
<span class="text-sm text-blue-900">
<span class="font-semibold">Fix Available:</span> Upgrade to version <strong>{{ vuln.fixed_in }}</strong> or later
</span>
</div>
</div>
<!-- References -->
<div v-if="vuln.references && vuln.references.length > 0">
<h5 class="font-semibold text-gray-900 mb-2 flex items-center gap-2">
<i class="fas fa-link"></i>
References ({{ vuln.references.length }})
</h5>
<div class="space-y-1.5">
<div
v-for="(ref, refIndex) in vuln.references"
:key="refIndex"
class="flex items-start gap-2 text-sm"
>
<i class="fas fa-external-link-alt text-gray-400 text-xs mt-0.5"></i>
<a
:href="ref"
target="_blank"
rel="noopener noreferrer"
class="text-blue-600 hover:text-blue-800 hover:underline break-all"
>
{{ ref }}
</a>
</div>
</div>
</div>
</div>
</TableCell>
</TableRow>
</template>
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import axios from 'axios'
import { marked } from 'marked'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Separator } from '@/components/ui/separator'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
getSeverityBadgeClass,
getRegistryBadgeClass,
getVulnerabilityBorderClass,
formatSeverityName,
} from '@/composables/useBadgeStyles'
// Configure marked
marked.setOptions({
breaks: true,
gfm: true,
})
const router = useRouter()
const route = useRoute()
const registry = computed(() => route.params.registry as string)
const packageName = computed(() => {
// Handle package names with slashes (e.g., Go packages like github.com/user/repo)
const nameParam = route.params.name
if (Array.isArray(nameParam)) {
return nameParam.join('/')
}
return nameParam as string
})
const version = computed(() => route.params.version as string)
interface BypassInfo {
id: string
reason: string
created_by: string
expires_at: string
}
interface Vulnerability {
id: string
severity: string
title: string
description: string
references: string[]
fixed_in: string
bypassed: boolean
bypass?: BypassInfo
}
interface VulnerabilityResponse {
scanned: boolean
scanner: string
scanned_at: string
status: string
vulnerabilities: Vulnerability[]
vulnerability_count: number
severity_counts: {
critical: number
high: number
moderate: number
low: number
}
bypassed_count: number
}
const loading = ref(false)
const error = ref<string | null>(null)
const vulnerabilities = ref<VulnerabilityResponse | null>(null)
const expandedRows = ref<Set<number>>(new Set())
// Severity order for sorting (higher values = more severe)
const severityOrder: Record<string, number> = {
CRITICAL: 4,
HIGH: 3,
MEDIUM: 2,
MODERATE: 2, // Treat MODERATE same as MEDIUM
LOW: 1,
UNKNOWN: 0,
}
const vulnerabilityList = computed(() => {
const vulns = vulnerabilities.value?.vulnerabilities || []
// Sort by severity (most severe first)
return [...vulns].sort((a, b) => {
const severityA = severityOrder[a.severity.toUpperCase()] || 0
const severityB = severityOrder[b.severity.toUpperCase()] || 0
return severityB - severityA // Descending order
})
})
const severityCounts = computed(() => vulnerabilities.value?.severity_counts || { critical: 0, high: 0, moderate: 0, low: 0 })
const bypassedCount = computed(() => vulnerabilities.value?.bypassed_count || 0)
// Parse scanner string into array of scanner names
const scannerList = computed(() => {
if (!vulnerabilities.value?.scanner) return []
return vulnerabilities.value.scanner.split('+').map((s: string) => s.trim())
})
onMounted(() => {
fetchVulnerabilities()
})
async function fetchVulnerabilities() {
loading.value = true
error.value = null
vulnerabilities.value = null
try {
const response = await axios.get(
`/api/packages/${registry.value}/${packageName.value}/${version.value}/vulnerabilities`
)
// Store the response data
vulnerabilities.value = response.data
} catch (err: any) {
console.error('Failed to fetch vulnerabilities:', err)
error.value = err.response?.data?.error || err.message || 'Failed to load vulnerability details'
} finally {
loading.value = false
}
}
function goBack() {
router.push('/packages')
}
function toggleRow(index: number) {
if (expandedRows.value.has(index)) {
expandedRows.value.delete(index)
} else {
expandedRows.value.add(index)
}
}
function getRowClass(index: number, severity: string): string {
const classes = ['cursor-pointer']
if (expandedRows.value.has(index)) {
classes.push('bg-gray-50')
}
classes.push(getVulnerabilityBorderClass(severity))
return classes.join(' ')
}
function formatDate(date: string): string {
return new Date(date).toLocaleString()
}
function renderMarkdown(text: string): string {
if (!text) return ''
return marked.parse(text) as string
}
</script>
+187
View File
@@ -0,0 +1,187 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import PackageList from './PackageList.vue'
import { usePackageStore } from '../stores/packages'
describe('PackageList.vue', () => {
beforeEach(() => {
// Create a fresh pinia instance before each test
setActivePinia(createPinia())
})
it('renders package list component', () => {
const wrapper = mount(PackageList)
expect(wrapper.find('h2').text()).toBe('Packages')
})
it('displays loading state when loading', async () => {
const wrapper = mount(PackageList)
const store = usePackageStore()
store.loading = true
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Loading packages...')
})
it('displays error message when error occurs', async () => {
const wrapper = mount(PackageList)
const store = usePackageStore()
store.error = 'Failed to fetch packages'
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Failed to fetch packages')
})
it('displays empty state when no packages', async () => {
const wrapper = mount(PackageList)
const store = usePackageStore()
store.loading = false
store.packages = []
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('No packages cached yet')
})
it('displays package accordion when packages exist', async () => {
const wrapper = mount(PackageList)
const store = usePackageStore()
store.loading = false
store.packages = [
{
id: '1',
registry: 'npm',
name: 'test-package',
version: '1.0.0',
size: 1024,
cached_at: '2025-01-01T00:00:00Z',
last_accessed: '2025-01-01T00:00:00Z',
download_count: 10,
},
]
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('test-package')
expect(wrapper.text()).toContain('1 version')
})
it('calls fetchPackages on mount', () => {
const store = usePackageStore()
const fetchSpy = vi.spyOn(store, 'fetchPackages')
mount(PackageList)
expect(fetchSpy).toHaveBeenCalled()
})
it('groups packages and displays version counts', async () => {
const wrapper = mount(PackageList)
const store = usePackageStore()
store.loading = false
store.packages = [
{
id: '1',
registry: 'npm',
name: 'test-package',
version: '1.0.0',
size: 1024,
cached_at: '2025-01-01T00:00:00Z',
last_accessed: '2025-01-01T00:00:00Z',
download_count: 10,
},
{
id: '2',
registry: 'npm',
name: 'test-package',
version: '2.0.0',
size: 2048,
cached_at: '2025-01-02T00:00:00Z',
last_accessed: '2025-01-02T00:00:00Z',
download_count: 20,
},
]
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('test-package')
expect(wrapper.text()).toContain('2 versions')
})
it('formats bytes correctly', async () => {
const wrapper = mount(PackageList)
const store = usePackageStore()
store.loading = false
store.packages = [
{
id: '1',
registry: 'npm',
name: 'test-package',
version: '1.0.0',
size: 1048576, // 1 MB
cached_at: '2025-01-01T00:00:00Z',
last_accessed: '2025-01-01T00:00:00Z',
download_count: 10,
},
]
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('1 MB')
})
it('applies correct registry badge classes', async () => {
const wrapper = mount(PackageList)
const store = usePackageStore()
store.loading = false
store.packages = [
{
id: '1',
registry: 'npm',
name: 'npm-package',
version: '1.0.0',
size: 1024,
cached_at: '2025-01-01T00:00:00Z',
last_accessed: '2025-01-01T00:00:00Z',
download_count: 10,
},
{
id: '2',
registry: 'pypi',
name: 'python-package',
version: '1.0.0',
size: 1024,
cached_at: '2025-01-01T00:00:00Z',
last_accessed: '2025-01-01T00:00:00Z',
download_count: 5,
},
{
id: '3',
registry: 'go',
name: 'go-module',
version: '1.0.0',
size: 1024,
cached_at: '2025-01-01T00:00:00Z',
last_accessed: '2025-01-01T00:00:00Z',
download_count: 3,
},
]
await wrapper.vm.$nextTick()
// Verify that all registry badges are displayed
// Packages are grouped and sorted alphabetically, so order is: go-module, npm-package, python-package
expect(wrapper.text()).toContain('npm')
expect(wrapper.text()).toContain('pypi')
expect(wrapper.text()).toContain('go')
// Verify badge component is used with correct classes
const html = wrapper.html()
expect(html).toContain('bg-blue-100') // npm badge
expect(html).toContain('bg-green-100') // pypi badge
expect(html).toContain('bg-yellow-100') // go badge
})
})
+418
View File
@@ -0,0 +1,418 @@
<template>
<div>
<div class="flex justify-between items-center mb-8">
<h2 class="text-3xl font-bold text-gray-900">Packages</h2>
<Button @click="store.fetchPackages()">
<i class="fas fa-sync-alt mr-2"></i>Refresh
</Button>
</div>
<!-- Filter and Search Section -->
<div class="mb-6 flex flex-col sm:flex-row items-start sm:items-center gap-4">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-gray-700">Filter by registry:</span>
<div class="flex gap-2">
<Button
@click="changeRegistryFilter('all')"
:variant="selectedRegistry === 'all' ? 'default' : 'outline'"
size="sm"
>
All
</Button>
<Button
@click="changeRegistryFilter('npm')"
:variant="selectedRegistry === 'npm' ? 'default' : 'outline'"
size="sm"
>
<i class="fab fa-npm mr-2"></i>NPM
</Button>
<Button
@click="changeRegistryFilter('pypi')"
:variant="selectedRegistry === 'pypi' ? 'default' : 'outline'"
size="sm"
>
<i class="fab fa-python mr-2"></i>PyPI
</Button>
<Button
@click="changeRegistryFilter('go')"
:variant="selectedRegistry === 'go' ? 'default' : 'outline'"
size="sm"
>
<i class="fas fa-code mr-2"></i>Go
</Button>
</div>
</div>
<div class="flex items-center gap-2 w-full sm:w-auto sm:ml-auto">
<i class="fas fa-search text-gray-500"></i>
<Input
v-model="searchTerm"
type="text"
placeholder="Search packages..."
class="w-full sm:w-64"
/>
</div>
</div>
<!-- Error Alert -->
<Alert v-if="error" variant="destructive" class="mb-4">
<i class="fas fa-exclamation-circle mr-2"></i>
<AlertDescription>{{ error }}</AlertDescription>
</Alert>
<!-- Loading State -->
<div v-if="loading" class="text-center py-12">
<i class="fas fa-spinner fa-spin text-4xl text-primary-600"></i>
<p class="mt-4 text-gray-600">Loading packages...</p>
</div>
<!-- Package List -->
<Card v-else>
<CardContent class="p-6">
<div v-if="packages.length === 0" class="text-center py-12 text-gray-500">
<i class="fas fa-inbox text-6xl mb-4"></i>
<p class="text-xl">No packages cached yet</p>
<p class="mt-2">Packages will appear here once they are downloaded through the proxy</p>
</div>
<Accordion v-else type="multiple" class="w-full">
<AccordionItem
v-for="group in groupedPackages"
:key="`${group.registry}:${group.name}`"
:value="`${group.registry}:${group.name}`"
class="border-b border-gray-200"
>
<AccordionTrigger class="px-4 py-4 hover:bg-gray-50">
<div class="flex items-center justify-between w-full pr-4">
<div class="flex items-center space-x-4">
<div class="text-left">
<h4 class="font-semibold text-gray-900">{{ group.name }}</h4>
<p class="text-sm text-gray-500">{{ group.versions.length }} version{{ group.versions.length > 1 ? 's' : '' }}</p>
</div>
</div>
<div class="flex items-center space-x-6">
<Badge variant="outline" :class="getRegistryBadgeClass(group.registry)">
{{ group.registry }}
</Badge>
<div class="text-right">
<p class="text-sm font-medium text-gray-900">{{ formatBytes(group.totalSize) }}</p>
<p class="text-xs text-gray-500">{{ formatNumber(group.totalDownloads) }} downloads</p>
</div>
</div>
</div>
</AccordionTrigger>
<AccordionContent class="px-4 pb-4">
<div class="space-y-3">
<div
v-for="version in group.versions"
:key="version.id"
class="flex items-center justify-between p-4 bg-gray-50 rounded-lg hover:bg-gray-100"
>
<div class="flex items-center space-x-4 flex-1">
<div class="flex-1">
<p class="font-medium text-gray-900">{{ version.version.startsWith('v') ? version.version : 'v' + version.version }}</p>
<div class="flex items-center space-x-4 mt-1 text-sm text-gray-500">
<span>
<i class="fas fa-download mr-1"></i>{{ formatNumber(version.download_count) }}
</span>
<span>
<i class="fas fa-hard-drive mr-1"></i>{{ formatBytes(version.size) }}
</span>
<span>
<i class="fas fa-clock mr-1"></i>{{ formatDate(version.cached_at) }}
</span>
</div>
<!-- Vulnerability Badge -->
<div v-if="version.vulnerabilities" class="mt-2">
<VulnerabilityBadge
:scanned="version.vulnerabilities.scanned"
:status="version.vulnerabilities.status"
:counts="version.vulnerabilities.counts"
:total="version.vulnerabilities.total"
:scannedAt="version.vulnerabilities.scannedAt"
@click="showVulnerabilityDetails(group.registry, group.name, version.version)"
/>
</div>
</div>
</div>
<button
@click="confirmDelete(version)"
class="text-red-600 hover:text-red-900 p-2"
title="Delete this version"
>
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
<!-- Pagination -->
<div v-if="totalPages > 1" class="mt-6 flex items-center justify-between border-t pt-4">
<div class="text-sm text-gray-700">
Page {{ currentPage }} of {{ totalPages }} ({{ allGroupedPackages.length }} total packages)
</div>
<div class="flex items-center gap-2">
<Button
@click="changePage(currentPage - 1)"
:disabled="currentPage === 1"
variant="outline"
size="sm"
>
<i class="fas fa-chevron-left mr-2"></i>Previous
</Button>
<div class="flex gap-1">
<!-- First page -->
<Button
v-if="currentPage > 3"
@click="changePage(1)"
variant="outline"
size="sm"
>
1
</Button>
<span v-if="currentPage > 4" class="px-2">...</span>
<!-- Page numbers around current page -->
<Button
v-for="page in getPageNumbers()"
:key="page"
@click="changePage(page)"
:variant="page === currentPage ? 'default' : 'outline'"
size="sm"
>
{{ page }}
</Button>
<span v-if="currentPage < totalPages - 3" class="px-2">...</span>
<!-- Last page -->
<Button
v-if="currentPage < totalPages - 2"
@click="changePage(totalPages)"
variant="outline"
size="sm"
>
{{ totalPages }}
</Button>
</div>
<Button
@click="changePage(currentPage + 1)"
:disabled="currentPage === totalPages"
variant="outline"
size="sm"
>
Next<i class="fas fa-chevron-right ml-2"></i>
</Button>
</div>
</div>
</CardContent>
</Card>
<!-- Delete Confirmation Dialog -->
<Dialog v-model:open="showDeleteModal">
<DialogContent>
<DialogHeader>
<DialogTitle>Confirm Deletion</DialogTitle>
<DialogDescription>
Are you sure you want to delete <strong>{{ packageToDelete?.name }}@{{ packageToDelete?.version }}</strong>?
This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button @click="showDeleteModal = false" variant="outline">
Cancel
</Button>
<Button @click="deletePackage" variant="destructive">
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, computed, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useRoute, useRouter } from 'vue-router'
import { usePackageStore, type Package } from '../stores/packages'
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import VulnerabilityBadge from './VulnerabilityBadge.vue'
import { getRegistryBadgeClass } from '@/composables/useBadgeStyles'
// Props from router
const props = defineProps<{
registry?: string
}>()
const route = useRoute()
const router = useRouter()
const store = usePackageStore()
const { packages, loading, error } = storeToRefs(store)
const showDeleteModal = ref(false)
const packageToDelete = ref<Package | null>(null)
const selectedRegistry = ref<string>(props.registry || 'all')
const searchTerm = ref<string>('')
const currentPage = ref<number>(1)
const itemsPerPage = ref<number>(10)
// Group packages by name
const allGroupedPackages = computed(() => {
const groups = new Map<string, Package[]>()
// Filter packages by selected registry and search term
let filteredPackages = selectedRegistry.value === 'all'
? packages.value
: packages.value.filter(pkg => pkg.registry === selectedRegistry.value)
// Apply search filter if search term exists
if (searchTerm.value.trim()) {
const searchLower = searchTerm.value.toLowerCase()
filteredPackages = filteredPackages.filter(pkg =>
pkg.name.toLowerCase().includes(searchLower) ||
pkg.version.toLowerCase().includes(searchLower)
)
}
filteredPackages.forEach(pkg => {
const key = `${pkg.registry}:${pkg.name}`
if (!groups.has(key)) {
groups.set(key, [])
}
groups.get(key)!.push(pkg)
})
// Convert to array and sort versions within each group
return Array.from(groups.entries()).map(([key, versions]) => {
const [registry, name] = key.split(':')
return {
registry,
name,
versions: versions.sort((a, b) =>
new Date(b.cached_at).getTime() - new Date(a.cached_at).getTime()
),
totalSize: versions.reduce((sum, v) => sum + v.size, 0),
totalDownloads: versions.reduce((sum, v) => sum + v.download_count, 0),
}
}).sort((a, b) => a.name.localeCompare(b.name))
})
// Pagination
const totalPages = computed(() => Math.ceil(allGroupedPackages.value.length / itemsPerPage.value))
const groupedPackages = computed(() => {
const start = (currentPage.value - 1) * itemsPerPage.value
const end = start + itemsPerPage.value
return allGroupedPackages.value.slice(start, end)
})
function changePage(page: number) {
if (page >= 1 && page <= totalPages.value) {
currentPage.value = page
// Scroll to top of packages list
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}
// Get page numbers to display around current page
function getPageNumbers(): number[] {
const pages: number[] = []
const start = Math.max(1, currentPage.value - 2)
const end = Math.min(totalPages.value, currentPage.value + 2)
for (let i = start; i <= end; i++) {
pages.push(i)
}
return pages
}
// Reset to page 1 when filters change
function resetPagination() {
currentPage.value = 1
}
// Watch for filter/search changes and reset pagination
watch([selectedRegistry, searchTerm], () => {
resetPagination()
})
// Watch for route parameter changes to update filter
watch(() => route.params.registry, (newRegistry) => {
selectedRegistry.value = (newRegistry as string) || 'all'
})
onMounted(async () => {
await store.fetchPackages()
})
function confirmDelete(pkg: Package) {
packageToDelete.value = pkg
showDeleteModal.value = true
}
async function deletePackage() {
if (packageToDelete.value) {
await store.deletePackage(
packageToDelete.value.registry,
packageToDelete.value.name,
packageToDelete.value.version
)
showDeleteModal.value = false
packageToDelete.value = null
}
}
function formatNumber(num: number): string {
return new Intl.NumberFormat().format(num)
}
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 Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]
}
function formatDate(date: string): string {
return new Date(date).toLocaleString()
}
function showVulnerabilityDetails(registry: string, name: string, version: string) {
// Navigate to the package details page
router.push({
name: 'package-details',
params: {
registry,
name,
version,
},
})
}
function changeRegistryFilter(registry: string) {
const path = registry === 'all' ? '/packages' : `/packages/${registry}`
router.push(path)
}
</script>
+192
View File
@@ -0,0 +1,192 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import Stats from './Stats.vue'
import { usePackageStore } from '../stores/packages'
describe('Stats.vue', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('renders stats component', () => {
const wrapper = mount(Stats)
expect(wrapper.find('h2').text()).toBe('Statistics')
})
it('displays loading state when loading', async () => {
const wrapper = mount(Stats)
const store = usePackageStore()
store.loading = true
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Loading statistics...')
})
it('displays error message when error occurs', async () => {
const wrapper = mount(Stats)
const store = usePackageStore()
store.error = 'Failed to fetch statistics'
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Failed to fetch statistics')
})
it('displays overall statistics', async () => {
const wrapper = mount(Stats)
const store = usePackageStore()
store.loading = false
store.stats = {
registry: '',
total_packages: 100,
total_size: 1073741824, // 1 GB
total_downloads: 500,
scanned_packages: 90,
vulnerable_packages: 5,
}
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Overall Statistics')
expect(wrapper.text()).toContain('100')
expect(wrapper.text()).toContain('500')
})
it('displays security scanning statistics', async () => {
const wrapper = mount(Stats)
const store = usePackageStore()
store.loading = false
store.stats = {
registry: '',
total_packages: 100,
total_size: 1073741824,
total_downloads: 500,
scanned_packages: 90,
vulnerable_packages: 5,
}
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Security Scanning')
expect(wrapper.text()).toContain('Scanned Packages')
expect(wrapper.text()).toContain('Vulnerable Packages')
})
it('displays registry breakdown', async () => {
const wrapper = mount(Stats)
const store = usePackageStore()
store.loading = false
store.stats = {
registry: '',
total_packages: 100,
total_size: 1073741824,
total_downloads: 500,
scanned_packages: 90,
vulnerable_packages: 5,
}
store.registries = {
npm: {
count: 50,
size: 536870912, // 512 MB
downloads: 300,
},
pypi: {
count: 30,
size: 322122547, // ~307 MB
downloads: 150,
},
go: {
count: 20,
size: 214748365, // ~205 MB
downloads: 50,
},
}
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Registry Breakdown')
expect(wrapper.text()).toContain('NPM Registry')
expect(wrapper.text()).toContain('PyPI Registry')
expect(wrapper.text()).toContain('Go Modules')
expect(wrapper.text()).toContain('50 packages')
expect(wrapper.text()).toContain('30 packages')
expect(wrapper.text()).toContain('20 packages')
})
it('formats bytes correctly in overall stats', async () => {
const wrapper = mount(Stats)
const store = usePackageStore()
store.loading = false
store.stats = {
registry: '',
total_packages: 100,
total_size: 1073741824, // 1 GB
total_downloads: 500,
scanned_packages: 90,
vulnerable_packages: 5,
}
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('1 GB')
})
it('calls fetchStats on mount', () => {
const store = usePackageStore()
const fetchSpy = vi.spyOn(store, 'fetchStats')
mount(Stats)
expect(fetchSpy).toHaveBeenCalled()
})
it('displays correct icon colors for different registries', async () => {
const wrapper = mount(Stats)
const store = usePackageStore()
store.loading = false
store.stats = {
registry: '',
total_packages: 3,
total_size: 0,
total_downloads: 0,
scanned_packages: 0,
vulnerable_packages: 0,
}
store.registries = {
npm: { count: 1, size: 0, downloads: 0 },
pypi: { count: 1, size: 0, downloads: 0 },
go: { count: 1, size: 0, downloads: 0 },
}
await wrapper.vm.$nextTick()
const containers = wrapper.findAll('.rounded-full')
expect(containers[0].classes()).toContain('bg-red-100') // npm
expect(containers[1].classes()).toContain('bg-blue-100') // pypi
expect(containers[2].classes()).toContain('bg-cyan-100') // go
})
it('handles empty registries data', async () => {
const wrapper = mount(Stats)
const store = usePackageStore()
store.loading = false
store.stats = {
registry: '',
total_packages: 0,
total_size: 0,
total_downloads: 0,
scanned_packages: 0,
vulnerable_packages: 0,
}
store.registries = {}
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Registry Breakdown')
// Should have no registry items
const registryItems = wrapper.findAll('.bg-gray-50')
expect(registryItems.length).toBe(3) // Only the overall stats cards
})
})
+194
View File
@@ -0,0 +1,194 @@
<template>
<div>
<h2 class="text-3xl font-bold text-gray-900 mb-8">Statistics</h2>
<!-- Error Alert -->
<Alert v-if="error" variant="destructive" class="mb-4">
<i class="fas fa-exclamation-circle mr-2"></i>
<AlertDescription>{{ error }}</AlertDescription>
</Alert>
<!-- Loading State -->
<div v-if="loading" class="text-center py-12">
<i class="fas fa-spinner fa-spin text-4xl text-primary-600"></i>
<p class="mt-4 text-gray-600">Loading statistics...</p>
</div>
<!-- Stats Cards -->
<div v-else>
<!-- Overall Stats -->
<Card class="mb-8">
<CardContent class="p-6">
<h3 class="text-xl font-semibold text-gray-900 mb-6">
<i class="fas fa-chart-bar mr-2"></i>Overall Statistics
</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="text-center p-6 bg-gray-50 rounded-lg">
<p class="text-4xl font-bold text-primary-600 mb-2">
{{ formatNumber(stats?.total_packages || 0) }}
</p>
<p class="text-sm text-gray-600">Total Packages</p>
</div>
<div class="text-center p-6 bg-gray-50 rounded-lg">
<p class="text-4xl font-bold text-blue-600 mb-2">
{{ formatBytes(stats?.total_size || 0) }}
</p>
<p class="text-sm text-gray-600">Total Storage Used</p>
</div>
<div class="text-center p-6 bg-gray-50 rounded-lg">
<p class="text-4xl font-bold text-green-600 mb-2">
{{ formatNumber(stats?.total_downloads || 0) }}
</p>
<p class="text-sm text-gray-600">Total Downloads</p>
</div>
</div>
</CardContent>
</Card>
<!-- Security Stats -->
<Card class="mb-8">
<CardContent class="p-6">
<h3 class="text-xl font-semibold text-gray-900 mb-6">
<i class="fas fa-shield-alt mr-2"></i>Security Scanning
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="flex items-center justify-between p-6 bg-green-50 rounded-lg border border-green-200">
<div>
<p class="text-3xl font-bold text-green-600">
{{ formatNumber(stats?.scanned_packages || 0) }}
</p>
<p class="text-sm text-gray-600 mt-1">Scanned Packages</p>
</div>
<i class="fas fa-check-circle text-5xl text-green-400"></i>
</div>
<div
@click="showVulnerablePackages"
class="flex items-center justify-between p-6 bg-red-50 rounded-lg border border-red-200 cursor-pointer hover:bg-red-100 transition-colors"
:class="{ 'opacity-50': (stats?.vulnerable_packages || 0) === 0 }"
>
<div>
<p class="text-3xl font-bold text-red-600">
{{ formatNumber(stats?.vulnerable_packages || 0) }}
</p>
<p class="text-sm text-gray-600 mt-1">
Vulnerable Packages
<span v-if="(stats?.vulnerable_packages || 0) > 0" class="text-xs ml-1">(click to view)</span>
</p>
</div>
<i class="fas fa-exclamation-triangle text-5xl text-red-400"></i>
</div>
</div>
</CardContent>
</Card>
<!-- Registry Breakdown -->
<Card>
<CardContent class="p-6">
<h3 class="text-xl font-semibold text-gray-900 mb-6">
<i class="fas fa-server mr-2"></i>Registry Breakdown
</h3>
<div class="space-y-4">
<div
v-for="registry in registries"
:key="registry.name"
class="flex items-center justify-between p-4 bg-gray-50 rounded-lg"
>
<div class="flex items-center space-x-4">
<div
class="w-12 h-12 rounded-full flex items-center justify-center"
:class="registry.color"
>
<i :class="registry.icon + ' text-2xl'"></i>
</div>
<div>
<p class="font-bold text-gray-900">{{ registry.label }}</p>
<p class="text-sm text-gray-600">{{ registry.packages }} packages</p>
</div>
</div>
<div class="text-right">
<p class="text-lg font-bold text-gray-900">{{ registry.size }}</p>
<p class="text-sm text-gray-600">{{ registry.downloads }} downloads</p>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useRouter } from 'vue-router'
import { usePackageStore } from '../stores/packages'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Card, CardContent } from '@/components/ui/card'
const store = usePackageStore()
const { stats, loading, error } = storeToRefs(store)
const router = useRouter()
onMounted(async () => {
await store.fetchStats()
})
function showVulnerablePackages() {
if ((stats.value?.vulnerable_packages || 0) === 0) {
return
}
router.push('/vulnerable-packages')
}
// Registry configuration for icons and colors
const registryConfig: Record<string, {label: string, icon: string, color: string}> = {
npm: {
label: 'NPM Registry',
icon: 'fab fa-npm text-red-500',
color: 'bg-red-100'
},
pypi: {
label: 'PyPI Registry',
icon: 'fab fa-python text-blue-500',
color: 'bg-blue-100'
},
go: {
label: 'Go Modules',
icon: 'fas fa-code text-cyan-500',
color: 'bg-cyan-100'
}
}
const registries = computed(() => {
const apiRegistries = store.registries || {}
return Object.entries(apiRegistries).map(([name, data]: [string, any]) => {
const config = registryConfig[name] || {
label: name.toUpperCase(),
icon: 'fas fa-box',
color: 'bg-gray-100'
}
return {
name,
label: config.label,
icon: config.icon,
color: config.color,
packages: data.count || 0,
size: formatBytes(data.size || 0),
downloads: data.downloads || 0
}
})
})
function formatNumber(num: number): string {
return new Intl.NumberFormat().format(num)
}
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 Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]
}
</script>
@@ -0,0 +1,129 @@
<template>
<div class="flex items-center gap-2">
<!-- Critical Vulnerabilities -->
<button
v-if="counts.critical > 0"
@click="handleClick('critical')"
class="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium bg-red-100 text-red-800 hover:bg-red-200 border border-red-300 transition-colors cursor-pointer"
:title="`${counts.critical} critical vulnerabilities - click for details`"
>
<i class="fas fa-shield-virus mr-1"></i>
CRITICAL: {{ counts.critical }}
</button>
<!-- High Vulnerabilities -->
<button
v-if="counts.high > 0"
@click="handleClick('high')"
class="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium bg-orange-100 text-orange-800 hover:bg-orange-200 border border-orange-300 transition-colors cursor-pointer"
:title="`${counts.high} high severity vulnerabilities - click for details`"
>
<i class="fas fa-exclamation-triangle mr-1"></i>
HIGH: {{ counts.high }}
</button>
<!-- Moderate Vulnerabilities -->
<button
v-if="counts.moderate > 0"
@click="handleClick('moderate')"
class="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium bg-yellow-100 text-yellow-800 hover:bg-yellow-200 border border-yellow-300 transition-colors cursor-pointer"
:title="`${counts.moderate} moderate severity vulnerabilities - click for details`"
>
<i class="fas fa-exclamation-circle mr-1"></i>
MODERATE: {{ counts.moderate }}
</button>
<!-- Low Vulnerabilities -->
<button
v-if="counts.low > 0"
@click="handleClick('low')"
class="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium bg-blue-100 text-blue-800 hover:bg-blue-200 border border-blue-300 transition-colors cursor-pointer"
:title="`${counts.low} low severity vulnerabilities - click for details`"
>
<i class="fas fa-info-circle mr-1"></i>
LOW: {{ counts.low }}
</button>
<!-- Clean Badge (no vulnerabilities) -->
<span
v-if="status === 'clean' && total === 0"
class="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium bg-green-100 text-green-800 border border-green-300"
:title="scannedAt ? `No vulnerabilities found - Scanned ${formatTimestamp(scannedAt)}` : 'No vulnerabilities found'"
>
<i class="fas fa-check-circle mr-1"></i>
CLEAN
<span v-if="scannedAt" class="ml-1 text-[10px] opacity-70">
({{ formatRelativeTime(scannedAt) }})
</span>
</span>
<!-- Pending Badge -->
<span
v-if="status === 'pending'"
class="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium bg-gray-100 text-gray-700 border border-gray-300"
title="Security scan in progress"
>
<i class="fas fa-spinner fa-spin mr-1"></i>
SCANNING...
</span>
<!-- Not Scanned Badge -->
<span
v-if="status === 'not_scanned' || !scanned"
class="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium bg-gray-100 text-gray-600 border border-gray-300"
title="Not yet scanned for vulnerabilities"
>
<i class="fas fa-question-circle mr-1"></i>
NOT SCANNED
</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { VulnerabilityCounts } from '../stores/packages'
interface Props {
scanned?: boolean
status?: 'clean' | 'vulnerable' | 'pending' | 'not_scanned'
counts?: VulnerabilityCounts
total?: number
scannedAt?: string // ISO 8601 timestamp
}
const props = withDefaults(defineProps<Props>(), {
scanned: false,
status: 'not_scanned',
total: 0,
})
const emit = defineEmits<{
click: [severity: string]
}>()
const counts = computed(() => props.counts || { critical: 0, high: 0, moderate: 0, low: 0 })
function handleClick(severity: string) {
emit('click', severity)
}
function formatTimestamp(timestamp: string): string {
const date = new Date(timestamp)
return date.toLocaleString()
}
function formatRelativeTime(timestamp: string): string {
const date = new Date(timestamp)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMs / 3600000)
const diffDays = Math.floor(diffMs / 86400000)
if (diffMins < 1) return 'just now'
if (diffMins < 60) return `${diffMins}m ago`
if (diffHours < 24) return `${diffHours}h ago`
if (diffDays < 7) return `${diffDays}d ago`
return formatTimestamp(timestamp).split(',')[0] // Just the date part
}
</script>
@@ -0,0 +1,307 @@
<template>
<div class="max-w-7xl mx-auto px-4 py-8">
<!-- Header -->
<div class="mb-6">
<Button @click="goBack" variant="ghost" class="mb-4">
<i class="fas fa-arrow-left mr-2"></i>
Back to Stats
</Button>
<div class="flex items-center gap-3">
<i class="fas fa-exclamation-triangle text-3xl text-red-600"></i>
<div>
<h1 class="text-3xl font-bold text-gray-900">Vulnerable Packages</h1>
<p class="text-gray-600 mt-1">
Packages with known security vulnerabilities, sorted by risk
</p>
</div>
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="text-center py-12">
<i class="fas fa-spinner fa-spin text-4xl text-primary-600"></i>
<p class="mt-4 text-gray-600">Loading vulnerable packages...</p>
</div>
<!-- Error State -->
<Alert v-else-if="error" variant="destructive" class="mb-4">
<i class="fas fa-exclamation-circle mr-2"></i>
<AlertDescription>{{ error }}</AlertDescription>
</Alert>
<!-- Empty State -->
<Card v-else-if="sortedVulnerablePackages.length === 0">
<CardContent class="text-center py-12">
<i class="fas fa-check-circle text-6xl text-green-500 mb-4"></i>
<p class="text-xl font-semibold text-gray-900">No Vulnerable Packages</p>
<p class="mt-2 text-gray-600">All your packages are clean and safe to use!</p>
</CardContent>
</Card>
<!-- Vulnerable Packages List -->
<div v-else class="space-y-6">
<!-- Summary Card -->
<Card>
<CardContent class="p-6">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="text-center p-4 bg-red-50 rounded-lg border border-red-200">
<p class="text-3xl font-bold text-red-600">{{ criticalCount }}</p>
<p class="text-sm text-gray-600 mt-1">Critical</p>
</div>
<div class="text-center p-4 bg-orange-50 rounded-lg border border-orange-200">
<p class="text-3xl font-bold text-orange-600">{{ highCount }}</p>
<p class="text-sm text-gray-600 mt-1">High</p>
</div>
<div class="text-center p-4 bg-yellow-50 rounded-lg border border-yellow-200">
<p class="text-3xl font-bold text-yellow-600">{{ moderateCount }}</p>
<p class="text-sm text-gray-600 mt-1">Moderate</p>
</div>
<div class="text-center p-4 bg-blue-50 rounded-lg border border-blue-200">
<p class="text-3xl font-bold text-blue-600">{{ lowCount }}</p>
<p class="text-sm text-gray-600 mt-1">Low</p>
</div>
</div>
</CardContent>
</Card>
<!-- Packages List -->
<Card>
<CardContent class="p-6">
<div class="mb-4">
<h3 class="text-xl font-semibold text-gray-900">
<i class="fas fa-list mr-2"></i>
Vulnerable Packages ({{ sortedVulnerablePackages.length }})
</h3>
<p class="text-sm text-gray-600 mt-1">
{{ groupedPackages.length }} unique package{{ groupedPackages.length !== 1 ? 's' : '' }} Sorted by risk: Critical High Moderate Low
</p>
</div>
<Accordion type="multiple" class="w-full">
<AccordionItem
v-for="group in groupedPackages"
:key="`${group.registry}:${group.name}`"
:value="`${group.registry}:${group.name}`"
class="border-b border-gray-200"
>
<AccordionTrigger class="px-4 py-4 hover:bg-gray-50">
<div class="flex items-center justify-between w-full pr-4">
<div class="flex items-center space-x-4 flex-1 min-w-0">
<div class="text-left flex-1 min-w-0">
<h4 class="font-semibold text-gray-900 break-words">{{ group.name }}</h4>
<p class="text-sm text-gray-500">{{ group.versions.length }} vulnerable version{{ group.versions.length > 1 ? 's' : '' }}</p>
</div>
</div>
<div class="flex items-center space-x-6 flex-shrink-0">
<Badge variant="outline" :class="getRegistryBadgeClass(group.registry)">
{{ group.registry }}
</Badge>
<div class="text-right whitespace-nowrap">
<p class="text-sm font-medium text-gray-900">{{ formatBytes(group.totalSize) }}</p>
<p class="text-xs text-gray-500">{{ formatNumber(group.totalDownloads) }} downloads</p>
</div>
</div>
</div>
</AccordionTrigger>
<AccordionContent class="px-4 pb-4">
<div class="space-y-3">
<div
v-for="version in group.versions"
:key="version.id"
class="flex items-center justify-between p-4 bg-gray-50 rounded-lg hover:bg-gray-100 cursor-pointer"
@click="navigateToPackage(version)"
>
<div class="flex items-center space-x-4 flex-1">
<div class="flex-1">
<p class="font-medium text-gray-900">{{ version.version.startsWith('v') ? version.version : 'v' + version.version }}</p>
<div class="flex items-center space-x-4 mt-1 text-sm text-gray-500">
<span>
<i class="fas fa-download mr-1"></i>{{ formatNumber(version.download_count) }}
</span>
<span>
<i class="fas fa-hard-drive mr-1"></i>{{ formatBytes(version.size) }}
</span>
<span>
<i class="fas fa-clock mr-1"></i>{{ formatDate(version.cached_at) }}
</span>
</div>
<!-- Vulnerability Badge -->
<div v-if="version.vulnerabilities" class="mt-2">
<VulnerabilityBadge
:scanned="version.vulnerabilities.scanned"
:status="version.vulnerabilities.status"
:counts="version.vulnerabilities.counts"
:total="version.vulnerabilities.total"
:scannedAt="version.vulnerabilities.scannedAt"
@click.stop="navigateToPackage(version)"
/>
</div>
</div>
</div>
<div class="ml-4 flex-shrink-0">
<i class="fas fa-chevron-right text-gray-400"></i>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</CardContent>
</Card>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { usePackageStore, type Package } from '../stores/packages'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion'
import VulnerabilityBadge from './VulnerabilityBadge.vue'
import { getRegistryBadgeClass } from '@/composables/useBadgeStyles'
const store = usePackageStore()
const router = useRouter()
const loading = ref(false)
const error = ref<string | null>(null)
const vulnerablePackages = ref<Package[]>([])
onMounted(async () => {
await fetchVulnerablePackages()
})
async function fetchVulnerablePackages() {
loading.value = true
error.value = null
try {
await store.fetchPackages()
vulnerablePackages.value = store.packages.filter(
pkg => pkg.vulnerabilities?.status === 'vulnerable'
)
} catch (err: any) {
console.error('Failed to load vulnerable packages:', err)
error.value = err.message || 'Failed to load vulnerable packages'
} finally {
loading.value = false
}
}
// Sort packages by risk: Critical count DESC, High count DESC, Moderate count DESC, Low count DESC
const sortedVulnerablePackages = computed(() => {
return [...vulnerablePackages.value].sort((a, b) => {
const aVulns = a.vulnerabilities?.counts || { critical: 0, high: 0, moderate: 0, low: 0 }
const bVulns = b.vulnerabilities?.counts || { critical: 0, high: 0, moderate: 0, low: 0 }
// Compare critical count (descending)
if (aVulns.critical !== bVulns.critical) {
return bVulns.critical - aVulns.critical
}
// Compare high count (descending)
if (aVulns.high !== bVulns.high) {
return bVulns.high - aVulns.high
}
// Compare moderate count (descending)
if (aVulns.moderate !== bVulns.moderate) {
return bVulns.moderate - aVulns.moderate
}
// Compare low count (descending)
return bVulns.low - aVulns.low
})
})
// Group packages by name and registry, with versions sorted by risk
const groupedPackages = computed(() => {
const groups = new Map<string, {
registry: string
name: string
versions: Package[]
totalSize: number
totalDownloads: number
}>()
sortedVulnerablePackages.value.forEach((pkg) => {
const key = `${pkg.registry}:${pkg.name}`
if (!groups.has(key)) {
groups.set(key, {
registry: pkg.registry,
name: pkg.name,
versions: [],
totalSize: 0,
totalDownloads: 0,
})
}
const group = groups.get(key)!
group.versions.push(pkg)
group.totalSize += pkg.size || 0
group.totalDownloads += pkg.download_count || 0
})
return Array.from(groups.values())
})
// Calculate total counts across all packages
const criticalCount = computed(() =>
vulnerablePackages.value.reduce((sum, pkg) => sum + (pkg.vulnerabilities?.counts?.critical || 0), 0)
)
const highCount = computed(() =>
vulnerablePackages.value.reduce((sum, pkg) => sum + (pkg.vulnerabilities?.counts?.high || 0), 0)
)
const moderateCount = computed(() =>
vulnerablePackages.value.reduce((sum, pkg) => sum + (pkg.vulnerabilities?.counts?.moderate || 0), 0)
)
const lowCount = computed(() =>
vulnerablePackages.value.reduce((sum, pkg) => sum + (pkg.vulnerabilities?.counts?.low || 0), 0)
)
function navigateToPackage(pkg: Package) {
router.push(`/package/${pkg.registry}/${pkg.name}/${pkg.version}`)
}
function goBack() {
router.push('/stats')
}
function formatNumber(num: number): string {
return new Intl.NumberFormat().format(num)
}
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 Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]
}
function formatDate(dateString: string): string {
if (!dateString) return 'N/A'
try {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
} catch {
return dateString
}
}
</script>
@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { AccordionRootEmits, AccordionRootProps } from "reka-ui"
import {
AccordionRoot,
useForwardPropsEmits,
} from "reka-ui"
const props = defineProps<AccordionRootProps>()
const emits = defineEmits<AccordionRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<AccordionRoot v-bind="forwarded">
<slot />
</AccordionRoot>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { AccordionContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { AccordionContent } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<AccordionContentProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<AccordionContent
v-bind="delegatedProps"
class="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
>
<div :class="cn('pb-4 pt-0', props.class)">
<slot />
</div>
</AccordionContent>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { AccordionItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { AccordionItem, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<AccordionItemProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<AccordionItem
v-bind="forwardedProps"
:class="cn('border-b', props.class)"
>
<slot />
</AccordionItem>
</template>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import type { AccordionTriggerProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronDown } from "lucide-vue-next"
import {
AccordionHeader,
AccordionTrigger,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<AccordionTriggerProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<AccordionHeader class="flex">
<AccordionTrigger
v-bind="delegatedProps"
:class="
cn(
'flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
props.class,
)
"
>
<slot />
<slot name="icon">
<ChevronDown
class="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200"
/>
</slot>
</AccordionTrigger>
</AccordionHeader>
</template>
@@ -0,0 +1,4 @@
export { default as Accordion } from "./Accordion.vue"
export { default as AccordionContent } from "./AccordionContent.vue"
export { default as AccordionItem } from "./AccordionItem.vue"
export { default as AccordionTrigger } from "./AccordionTrigger.vue"
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import type { AlertVariants } from "."
import { cn } from "@/lib/utils"
import { alertVariants } from "."
const props = defineProps<{
class?: HTMLAttributes["class"]
variant?: AlertVariants["variant"]
}>()
</script>
<template>
<div :class="cn(alertVariants({ variant }), props.class)" role="alert">
<slot />
</div>
</template>
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div :class="cn('text-sm [&_p]:leading-relaxed', props.class)">
<slot />
</div>
</template>
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<h5 :class="cn('mb-1 font-medium leading-none tracking-tight', props.class)">
<slot />
</h5>
</template>
+24
View File
@@ -0,0 +1,24 @@
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
export { default as Alert } from "./Alert.vue"
export { default as AlertDescription } from "./AlertDescription.vue"
export { default as AlertTitle } from "./AlertTitle.vue"
export const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
},
)
export type AlertVariants = VariantProps<typeof alertVariants>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import type { BadgeVariants } from "."
import { cn } from "@/lib/utils"
import { badgeVariants } from "."
const props = defineProps<{
variant?: BadgeVariants["variant"]
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div :class="cn(badgeVariants({ variant }), props.class)">
<slot />
</div>
</template>
+26
View File
@@ -0,0 +1,26 @@
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
export { default as Badge } from "./Badge.vue"
export const badgeVariants = cva(
"inline-flex gap-1 items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
)
export type BadgeVariants = VariantProps<typeof badgeVariants>
@@ -0,0 +1,28 @@
<script setup lang="ts">
import type { PrimitiveProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { ButtonVariants } from "."
import { Primitive } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from "."
interface Props extends PrimitiveProps {
variant?: ButtonVariants["variant"]
size?: ButtonVariants["size"]
class?: HTMLAttributes["class"]
}
const props = withDefaults(defineProps<Props>(), {
as: "button",
})
</script>
<template>
<Primitive
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,38 @@
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
export { default as Button } from "./Button.vue"
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
"default": "h-9 px-4 py-2",
"xs": "h-7 rounded px-2",
"sm": "h-8 rounded-md px-3 text-xs",
"lg": "h-10 rounded-md px-8",
"icon": "h-9 w-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
)
export type ButtonVariants = VariantProps<typeof buttonVariants>
+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
:class="
cn(
'rounded-xl border bg-card text-card-foreground shadow',
props.class,
)
"
>
<slot />
</div>
</template>
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div :class="cn('p-6 pt-0', props.class)">
<slot />
</div>
</template>
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<p :class="cn('text-sm text-muted-foreground', props.class)">
<slot />
</p>
</template>
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div :class="cn('flex items-center p-6 pt-0', props.class)">
<slot />
</div>
</template>
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div :class="cn('flex flex-col gap-y-1.5 p-6', props.class)">
<slot />
</div>
</template>
@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<h3
:class="
cn('font-semibold leading-none tracking-tight', props.class)
"
>
<slot />
</h3>
</template>
+6
View File
@@ -0,0 +1,6 @@
export { default as Card } from "./Card.vue"
export { default as CardContent } from "./CardContent.vue"
export { default as CardDescription } from "./CardDescription.vue"
export { default as CardFooter } from "./CardFooter.vue"
export { default as CardHeader } from "./CardHeader.vue"
export { default as CardTitle } from "./CardTitle.vue"
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DialogRootEmits, DialogRootProps } from "reka-ui"
import { DialogRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<DialogRootProps>()
const emits = defineEmits<DialogRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DialogRoot v-bind="forwarded">
<slot />
</DialogRoot>
</template>
@@ -0,0 +1,12 @@
<script setup lang="ts">
import type { DialogCloseProps } from "reka-ui"
import { DialogClose } from "reka-ui"
const props = defineProps<DialogCloseProps>()
</script>
<template>
<DialogClose v-bind="props">
<slot />
</DialogClose>
</template>
@@ -0,0 +1,46 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { X } from "lucide-vue-next"
import {
DialogClose,
DialogContent,
DialogOverlay,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
/>
<DialogContent
v-bind="forwarded"
:class="
cn(
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
props.class,
)"
>
<slot />
<DialogClose
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"
>
<X class="w-4 h-4" />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogPortal>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { DialogDescriptionProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogDescription, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DialogDescription
v-bind="forwardedProps"
:class="cn('text-sm text-muted-foreground', props.class)"
>
<slot />
</DialogDescription>
</template>
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>
<template>
<div
:class="
cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2',
props.class,
)
"
>
<slot />
</div>
</template>
@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
:class="cn('flex flex-col gap-y-1.5 text-center sm:text-left', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,55 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { X } from "lucide-vue-next"
import {
DialogClose,
DialogContent,
DialogOverlay,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
>
<DialogContent
:class="
cn(
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
props.class,
)
"
v-bind="forwarded"
@pointer-down-outside="(event) => {
const originalEvent = event.detail.originalEvent;
const target = originalEvent.target as HTMLElement;
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
event.preventDefault();
}
}"
>
<slot />
<DialogClose
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
>
<X class="w-4 h-4" />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</template>
@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { DialogTitleProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogTitle, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DialogTitle
v-bind="forwardedProps"
:class="
cn(
'text-lg font-semibold leading-none tracking-tight',
props.class,
)
"
>
<slot />
</DialogTitle>
</template>
@@ -0,0 +1,12 @@
<script setup lang="ts">
import type { DialogTriggerProps } from "reka-ui"
import { DialogTrigger } from "reka-ui"
const props = defineProps<DialogTriggerProps>()
</script>
<template>
<DialogTrigger v-bind="props">
<slot />
</DialogTrigger>
</template>
@@ -0,0 +1,9 @@
export { default as Dialog } from "./Dialog.vue"
export { default as DialogClose } from "./DialogClose.vue"
export { default as DialogContent } from "./DialogContent.vue"
export { default as DialogDescription } from "./DialogDescription.vue"
export { default as DialogFooter } from "./DialogFooter.vue"
export { default as DialogHeader } from "./DialogHeader.vue"
export { default as DialogScrollContent } from "./DialogScrollContent.vue"
export { default as DialogTitle } from "./DialogTitle.vue"
export { default as DialogTrigger } from "./DialogTrigger.vue"
@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { useVModel } from "@vueuse/core"
import { cn } from "@/lib/utils"
const props = defineProps<{
defaultValue?: string | number
modelValue?: string | number
class?: HTMLAttributes["class"]
}>()
const emits = defineEmits<{
(e: "update:modelValue", payload: string | number): void
}>()
const modelValue = useVModel(props, "modelValue", emits, {
passive: true,
defaultValue: props.defaultValue,
})
</script>
<template>
<input v-model="modelValue" :class="cn('flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50', props.class)">
</template>
@@ -0,0 +1 @@
export { default as Input } from "./Input.vue"
@@ -0,0 +1,29 @@
<script setup lang="ts">
import type { SeparatorProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Separator } from "reka-ui"
import { cn } from "@/lib/utils"
const props = withDefaults(defineProps<
SeparatorProps & { class?: HTMLAttributes["class"] }
>(), {
orientation: "horizontal",
decorative: true,
})
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<Separator
v-bind="delegatedProps"
:class="
cn(
'shrink-0 bg-border',
props.orientation === 'horizontal' ? 'h-px w-full' : 'w-px h-full',
props.class,
)
"
/>
</template>
@@ -0,0 +1 @@
export { default as Separator } from "./Separator.vue"
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
interface SkeletonProps {
class?: HTMLAttributes["class"]
}
const props = defineProps<SkeletonProps>()
</script>
<template>
<div :class="cn('animate-pulse rounded-md bg-primary/10', props.class)" />
</template>
@@ -0,0 +1 @@
export { default as Skeleton } from "./Skeleton.vue"
@@ -0,0 +1,15 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: string
}>()
</script>
<template>
<div class="relative w-full overflow-auto">
<table :class="cn('w-full caption-bottom text-sm', props.class)">
<slot />
</table>
</div>
</template>
@@ -0,0 +1,13 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: string
}>()
</script>
<template>
<tbody :class="cn('[&_tr:last-child]:border-0', props.class)">
<slot />
</tbody>
</template>
@@ -0,0 +1,13 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: string
}>()
</script>
<template>
<td :class="cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', props.class)">
<slot />
</td>
</template>
@@ -0,0 +1,13 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: string
}>()
</script>
<template>
<th :class="cn('h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0', props.class)">
<slot />
</th>
</template>
@@ -0,0 +1,13 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: string
}>()
</script>
<template>
<thead :class="cn('[&_tr]:border-b', props.class)">
<slot />
</thead>
</template>
@@ -0,0 +1,13 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: string
}>()
</script>
<template>
<tr :class="cn('border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted', props.class)">
<slot />
</tr>
</template>
@@ -0,0 +1,6 @@
export { default as Table } from './Table.vue'
export { default as TableHeader } from './TableHeader.vue'
export { default as TableBody } from './TableBody.vue'
export { default as TableRow } from './TableRow.vue'
export { default as TableHead } from './TableHead.vue'
export { default as TableCell } from './TableCell.vue'
@@ -0,0 +1,59 @@
/**
* Shared badge styling utilities for consistent UI across the application
*/
/**
* Get Tailwind CSS classes for severity badges (light theme)
* @param severity - Severity level (CRITICAL, HIGH, MODERATE/MEDIUM, LOW)
* @returns Tailwind CSS class string
*/
export function getSeverityBadgeClass(severity: string): string {
const classes: Record<string, string> = {
CRITICAL: 'bg-red-100 text-red-800 border-red-300',
HIGH: 'bg-orange-100 text-orange-800 border-orange-300',
MEDIUM: 'bg-yellow-100 text-yellow-800 border-yellow-300',
MODERATE: 'bg-yellow-100 text-yellow-800 border-yellow-300',
LOW: 'bg-blue-100 text-blue-800 border-blue-300',
}
return classes[severity.toUpperCase()] || 'bg-gray-100 text-gray-800 border-gray-300'
}
/**
* Get Tailwind CSS classes for registry badges (light theme)
* @param registry - Registry name (npm, pypi, go)
* @returns Tailwind CSS class string
*/
export function getRegistryBadgeClass(registry: string): string {
const classes: Record<string, string> = {
npm: 'bg-red-100 text-red-800 border-red-300',
pypi: 'bg-blue-100 text-blue-800 border-blue-300',
go: 'bg-cyan-100 text-cyan-800 border-cyan-300',
}
return classes[registry.toLowerCase()] || 'bg-gray-100 text-gray-800 border-gray-300'
}
/**
* Get Tailwind CSS classes for vulnerability border indicators
* @param severity - Severity level (CRITICAL, HIGH, MODERATE/MEDIUM, LOW)
* @returns Tailwind CSS class string for left border
*/
export function getVulnerabilityBorderClass(severity: string): string {
const classes: Record<string, string> = {
CRITICAL: 'border-l-4 border-l-red-600',
HIGH: 'border-l-4 border-l-orange-500',
MEDIUM: 'border-l-4 border-l-yellow-500',
MODERATE: 'border-l-4 border-l-yellow-500',
LOW: 'border-l-4 border-l-blue-500',
}
return classes[severity.toUpperCase()] || 'border-l-4 border-l-gray-500'
}
/**
* Format severity name for display (title case)
* @param severity - Severity level (e.g., "CRITICAL", "HIGH")
* @returns Formatted severity name (e.g., "Critical", "High")
*/
export function formatSeverityName(severity: string): string {
const normalized = severity.toUpperCase()
return normalized.charAt(0) + normalized.slice(1).toLowerCase()
}
+7
View File
@@ -0,0 +1,7 @@
import type { ClassValue } from "clsx"
import { clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+11
View File
@@ -0,0 +1,11 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import App from './App.vue'
import './styles/main.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
+48
View File
@@ -0,0 +1,48 @@
import { createRouter, createWebHistory } from 'vue-router'
import Dashboard from '../components/Dashboard.vue'
import PackageList from '../components/PackageList.vue'
import PackageDetails from '../components/PackageDetails.vue'
import Stats from '../components/Stats.vue'
import VulnerablePackages from '../components/VulnerablePackages.vue'
import BypassManagementPanel from '../components/BypassManagementPanel.vue'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
name: 'dashboard',
component: Dashboard,
},
{
path: '/packages/:registry?',
name: 'packages',
component: PackageList,
props: true,
},
{
// Separate route for package details - supports names with slashes (Go packages)
path: '/package/:registry/:name+/:version',
name: 'package-details',
component: PackageDetails,
props: true,
},
{
path: '/stats',
name: 'stats',
component: Stats,
},
{
path: '/vulnerable-packages',
name: 'vulnerable-packages',
component: VulnerablePackages,
},
{
path: '/admin/bypasses',
name: 'bypasses',
component: BypassManagementPanel,
},
],
})
export default router
+115
View File
@@ -0,0 +1,115 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import axios from 'axios'
export interface VulnerabilityCounts {
critical: number
high: number
moderate: number
low: number
}
export interface VulnerabilityInfo {
scanned: boolean
status: 'clean' | 'vulnerable' | 'pending' | 'not_scanned'
counts?: VulnerabilityCounts
total?: number
scannedAt?: string // ISO 8601 timestamp
}
export interface Package {
id: string
registry: string
name: string
version: string
size: number
cached_at: string
last_accessed: string
download_count: number
vulnerabilities?: VulnerabilityInfo
}
export interface Stats {
registry: string
total_packages: number
total_size: number
total_downloads: number
scanned_packages: number
vulnerable_packages: number
}
export const usePackageStore = defineStore('packages', () => {
const packages = ref<Package[]>([])
const stats = ref<Stats | null>(null)
const registries = ref<Record<string, any>>({})
const loading = ref(false)
const error = ref<string | null>(null)
async function fetchPackages() {
loading.value = true
error.value = null
try {
const response = await axios.get('/api/packages')
// Only update packages if we got valid data
if (response.data && Array.isArray(response.data.packages)) {
packages.value = response.data.packages
} else {
console.warn('Unexpected API response format:', response.data)
error.value = 'Unexpected response format from server'
}
} catch (err: any) {
console.error('Failed to fetch packages:', err)
error.value = err.message
// Don't clear packages on error - keep showing the cached data
} finally {
loading.value = false
}
}
async function fetchStats(registry = '') {
loading.value = true
error.value = null
try {
const url = registry ? `/api/stats?registry=${registry}` : '/api/stats'
const response = await axios.get(url)
// Only update stats if we got valid data
if (response.data && response.data.stats) {
stats.value = response.data.stats
registries.value = response.data.registries || {}
} else {
console.warn('Unexpected stats response format:', response.data)
error.value = 'Unexpected stats response format from server'
}
} catch (err: any) {
console.error('Failed to fetch stats:', err)
error.value = err.message
// Don't clear stats on error - keep showing the cached data
} finally {
loading.value = false
}
}
async function deletePackage(registry: string, name: string, version: string) {
loading.value = true
error.value = null
try {
await axios.delete(`/api/packages/${registry}/${name}/${version}`)
await fetchPackages()
} catch (err: any) {
error.value = err.message
} finally {
loading.value = false
}
}
return {
packages,
stats,
registries,
loading,
error,
fetchPackages,
fetchStats,
deletePackage,
}
})
+83
View File
@@ -0,0 +1,83 @@
@import '@fortawesome/fontawesome-free/css/all.min.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
/* Tailwind slate-50 for background */
--background: 210 40% 98%;
/* Tailwind slate-900 for foreground */
--foreground: 222 47% 11%;
/* White for cards */
--card: 0 0% 100%;
--card-foreground: 222 47% 11%;
--popover: 0 0% 100%;
--popover-foreground: 222 47% 11%;
/* Tailwind slate-700 for primary */
--primary: 215 25% 27%;
--primary-foreground: 0 0% 100%;
/* Tailwind slate-100 for secondary */
--secondary: 210 40% 96%;
--secondary-foreground: 222 47% 11%;
/* Tailwind slate-100 for muted */
--muted: 210 40% 96%;
/* Tailwind slate-500 for muted-foreground */
--muted-foreground: 215 20% 65%;
--accent: 210 40% 96%;
--accent-foreground: 222 47% 11%;
/* Tailwind red-500 for destructive */
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 98%;
/* Tailwind slate-200 for border */
--border: 214 32% 91%;
--input: 214 32% 91%;
/* Tailwind slate-700 for ring */
--ring: 215 25% 27%;
/* Chart colors using Tailwind palette with slate base */
--chart-1: 215 25% 27%;
--chart-2: 200 98% 39%;
--chart-3: 142 71% 45%;
--chart-4: 25 95% 53%;
--chart-5: 262 83% 58%;
--radius: 0.75rem;
}
.dark {
--background: 222 47% 11%;
--foreground: 210 40% 98%;
--card: 222 47% 11%;
--card-foreground: 210 40% 98%;
--popover: 222 47% 11%;
--popover-foreground: 210 40% 98%;
--primary: 200 98% 39%;
--primary-foreground: 0 0% 100%;
--secondary: 217 33% 17%;
--secondary-foreground: 210 40% 98%;
--muted: 217 33% 17%;
--muted-foreground: 215 20% 65%;
--accent: 217 33% 17%;
--accent-foreground: 210 40% 98%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 98%;
--border: 217 33% 17%;
--input: 217 33% 17%;
--ring: 200 98% 39%;
--chart-1: 200 98% 39%;
--chart-2: 142 71% 45%;
--chart-3: 262 83% 58%;
--chart-4: 25 95% 53%;
--chart-5: 340 82% 52%;
}
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
/* Custom component styles have been replaced with shadcn-vue components */
+11
View File
@@ -0,0 +1,11 @@
import { expect, afterEach } from 'vitest'
import { cleanup } from '@testing-library/vue'
import * as matchers from '@testing-library/jest-dom/matchers'
// Extend Vitest's expect with jest-dom matchers
expect.extend(matchers)
// Cleanup after each test case
afterEach(() => {
cleanup()
})
+91
View File
@@ -0,0 +1,91 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ["class"],
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {
screens: {
'3xl': '2560px',
},
fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
},
colors: {
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
},
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
chart: {
'1': 'hsl(var(--chart-1))',
'2': 'hsl(var(--chart-2))',
'3': 'hsl(var(--chart-3))',
'4': 'hsl(var(--chart-4))',
'5': 'hsl(var(--chart-5))'
}
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
},
keyframes: {
'accordion-down': {
from: {
height: '0'
},
to: {
height: 'var(--reka-accordion-content-height)'
}
},
'accordion-up': {
from: {
height: 'var(--reka-accordion-content-height)'
},
to: {
height: '0'
}
}
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out'
}
}
},
plugins: [
require("tailwindcss-animate"),
require("@tailwindcss/typography"),
],
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
},
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }],
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+34
View File
@@ -0,0 +1,34 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
// Get backend URL from environment or use default
const BACKEND_URL = process.env.VITE_BACKEND_URL || 'http://localhost:8080'
const FRONTEND_PORT = parseInt(process.env.VITE_PORT || '5173')
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: FRONTEND_PORT,
proxy: {
'/api': {
target: BACKEND_URL,
changeOrigin: true,
},
'/ws': {
target: BACKEND_URL.replace('http', 'ws'),
ws: true,
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: false,
},
})
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({
plugins: [vue()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})
+85
View File
@@ -0,0 +1,85 @@
module github.com/lukaszraczylo/gohoarder
go 1.25.5
require (
github.com/aws/aws-sdk-go-v2 v1.41.0
github.com/aws/aws-sdk-go-v2/config v1.32.6
github.com/aws/aws-sdk-go-v2/credentials v1.19.6
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0
github.com/goccy/go-json v0.10.5
github.com/gofiber/fiber/v2 v2.52.10
github.com/gorilla/websocket v1.5.3
github.com/hirochachacha/go-smb2 v1.1.0
github.com/prometheus/client_golang v1.23.2
github.com/redis/go-redis/v9 v9.17.2
github.com/rs/zerolog v1.34.0
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.46.0
golang.org/x/sync v0.19.0
golang.org/x/time v0.14.0
modernc.org/sqlite v1.42.2
)
require (
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect
github.com/aws/smithy-go v1.24.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/geoffgarside/ber v1.2.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.4 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.68.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.67.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+223
View File
@@ -0,0 +1,223 @@
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4=
github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
github.com/aws/aws-sdk-go-v2/config v1.32.6 h1:hFLBGUKjmLAekvi1evLi5hVvFQtSo3GYwi+Bx4lpJf8=
github.com/aws/aws-sdk-go-v2/config v1.32.6/go.mod h1:lcUL/gcd8WyjCrMnxez5OXkO3/rwcNmvfno62tnXNcI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.6 h1:F9vWao2TwjV2MyiyVS+duza0NIRtAslgLUM0vTA1ZaE=
github.com/aws/aws-sdk-go-v2/credentials v1.19.6/go.mod h1:SgHzKjEVsdQr6Opor0ihgWtkWdfRAIwxYzSJ8O85VHY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 h1:CjMzUs78RDDv4ROu3JnJn/Ig1r6ZD7/T2DXLLRpejic=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16/go.mod h1:uVW4OLBqbJXSHJYA9svT9BluSvvwbzLQ2Crf6UPzR3c=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 h1:DIBqIrJ7hv+e4CmIk2z3pyKT+3B6qVMgRsawHiR3qso=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7/go.mod h1:vLm00xmBke75UmpNvOcZQ/Q30ZFjbczeLFqGx5urmGo=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A=
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0 h1:MIWra+MSq53CFaXXAywB2qg9YvVZifkk6vEGl/1Qor0=
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk=
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
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/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/geoffgarside/ber v1.1.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNecGT85ZCc=
github.com/geoffgarside/ber v1.2.0 h1:/loowoRcs/MWLYmGX9QtIAbA+V/FrnVLsMMPhwiRm64=
github.com/geoffgarside/ber v1.2.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNecGT85ZCc=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofiber/fiber/v2 v2.52.10 h1:jRHROi2BuNti6NYXmZ6gbNSfT3zj/8c0xy94GOU5elY=
github.com/gofiber/fiber/v2 v2.52.10/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
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/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/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/hirochachacha/go-smb2 v1.1.0 h1:b6hs9qKIql9eVXAiN0M2wSFY5xnhbHAQoCwRKbaRTZI=
github.com/hirochachacha/go-smb2 v1.1.0/go.mod h1:8F1A4d5EZzrGu5R7PU163UcMRDJQl4FtcxjBfsY8TZE=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
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/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
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/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
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/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFnok=
github.com/valyala/fasthttp v1.68.0/go.mod h1:5EXiRfYQAoiO/khu4oU9VISC/eVY6JqmSpPJoHCKsz4=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
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-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
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.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
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.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
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.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/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.67.4 h1:zZGmCMUVPORtKv95c2ReQN5VDjvkoRm9GWPTEPuvlWg=
modernc.org/libc v1.67.4/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
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.42.2 h1:7hkZUNJvJFN2PgfUdjni9Kbvd4ef4mNLOu0B9FGxM74=
modernc.org/sqlite v1.42.2/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8=
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=
+31
View File
@@ -0,0 +1,31 @@
# Patterns to ignore when building packages.
# This supports shell glob matching, relative path matching, and
# negation (prefixed with !). Only one pattern per line.
.DS_Store
# Common VCS dirs
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
# Common backup files
*.swp
*.bak
*.tmp
*.orig
*~
# Various IDEs
.project
.idea/
*.tmproj
.vscode/
# CI/CD
.github/
.gitlab-ci.yml
.travis.yml
# Documentation (keep README.md for chart documentation)
# README.md should be included in the chart package
docs/
examples/
+22
View File
@@ -0,0 +1,22 @@
apiVersion: v2
name: gohoarder
description: A universal package cache proxy supporting npm, PyPI, and Go modules with security scanning
type: application
version: 1.0.0
appVersion: "1.0.0"
keywords:
- package-manager
- cache
- proxy
- npm
- pypi
- go-modules
- security
- vulnerability-scanning
home: https://github.com/lukaszraczylo/gohoarder
sources:
- https://github.com/lukaszraczylo/gohoarder
maintainers:
- name: Lukasz Raczylo
email: lukasz@raczylo.com
icon: https://raw.githubusercontent.com/lukaszraczylo/gohoarder/main/docs/logo.png

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