mirror of
https://github.com/lukaszraczylo/gohoarder.git
synced 2026-10-14 00:57:40 +00:00
Compare commits
@@ -1,19 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
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"
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
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
@@ -1,82 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
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"
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
# 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;"]
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
# 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;'"]
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
# 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 with proper permissions
|
|
||||||
RUN mkdir -p /var/cache/gohoarder \
|
|
||||||
/var/lib/gohoarder/metadata \
|
|
||||||
/var/lib/trivy \
|
|
||||||
/tmp/gohoarder && \
|
|
||||||
chown -R scanner:scanner /var/cache/gohoarder \
|
|
||||||
/var/lib/gohoarder \
|
|
||||||
/var/lib/trivy \
|
|
||||||
/tmp/gohoarder && \
|
|
||||||
chmod -R 750 /var/cache/gohoarder \
|
|
||||||
/var/lib/gohoarder \
|
|
||||||
/var/lib/trivy
|
|
||||||
|
|
||||||
# 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 /var/cache/gohoarder
|
|
||||||
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
|
|
||||||
# The scanner runs the same serve command but uses SCANNER_MODE env var
|
|
||||||
# and configuration to determine its role
|
|
||||||
ENTRYPOINT ["/usr/local/bin/gohoarder"]
|
|
||||||
CMD ["serve"]
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
# 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 with proper permissions
|
|
||||||
RUN mkdir -p /var/cache/gohoarder \
|
|
||||||
/var/lib/gohoarder/metadata \
|
|
||||||
/tmp/gohoarder && \
|
|
||||||
chown -R gohoarder:gohoarder /var/cache/gohoarder \
|
|
||||||
/var/lib/gohoarder \
|
|
||||||
/tmp/gohoarder && \
|
|
||||||
chmod -R 750 /var/cache/gohoarder \
|
|
||||||
/var/lib/gohoarder
|
|
||||||
|
|
||||||
# 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 /var/cache/gohoarder
|
|
||||||
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"]
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
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.
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
.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
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
# 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"
|
|
||||||
@@ -1,416 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,502 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
---
|
|
||||||
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
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
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
|
|
||||||
+4298
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,281 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1, user-scalable=yes" />
|
||||||
|
<style>
|
||||||
|
html {
|
||||||
|
font-family: BlinkMacSystemFont,-apple-system,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,"Fira Sans","Droid Sans","Helvetica Neue",Helvetica,Arial,sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
background-color: #fff;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
color: #4a4a4a;
|
||||||
|
margin: 8px;
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: #3273dc;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
color: #fff;
|
||||||
|
background-color: #3298dc;
|
||||||
|
border-color: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
background-color: #2793da;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.spacer {
|
||||||
|
flex: auto;
|
||||||
|
}
|
||||||
|
.small {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
footer {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.header-label {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
.benchmark-set {
|
||||||
|
margin: 8px 0;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.benchmark-title {
|
||||||
|
font-size: 3rem;
|
||||||
|
font-weight: 600;
|
||||||
|
word-break: break-word;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.benchmark-graphs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-around;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.benchmark-chart {
|
||||||
|
max-width: 1000px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<title>Benchmarks</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<header id="header">
|
||||||
|
<div class="header-item">
|
||||||
|
<strong class="header-label">Last Update:</strong>
|
||||||
|
<span id="last-update"></span>
|
||||||
|
</div>
|
||||||
|
<div class="header-item">
|
||||||
|
<strong class="header-label">Repository:</strong>
|
||||||
|
<a id="repository-link" rel="noopener"></a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main id="main"></main>
|
||||||
|
<footer>
|
||||||
|
<button id="dl-button">Download data as JSON</button>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<div class="small">Powered by <a rel="noopener" href="https://github.com/marketplace/actions/continuous-benchmark">github-action-benchmark</a></div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/Chart.min.js"></script>
|
||||||
|
<script src="data.js"></script>
|
||||||
|
<script id="main-script">
|
||||||
|
'use strict';
|
||||||
|
(function() {
|
||||||
|
// Colors from https://github.com/github/linguist/blob/master/lib/linguist/languages.yml
|
||||||
|
const toolColors = {
|
||||||
|
cargo: '#dea584',
|
||||||
|
go: '#00add8',
|
||||||
|
benchmarkjs: '#f1e05a',
|
||||||
|
benchmarkluau: '#000080',
|
||||||
|
pytest: '#3572a5',
|
||||||
|
googlecpp: '#f34b7d',
|
||||||
|
catch2: '#f34b7d',
|
||||||
|
julia: '#a270ba',
|
||||||
|
jmh: '#b07219',
|
||||||
|
benchmarkdotnet: '#178600',
|
||||||
|
customBiggerIsBetter: '#38ff38',
|
||||||
|
customSmallerIsBetter: '#ff3838',
|
||||||
|
_: '#333333'
|
||||||
|
};
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
function collectBenchesPerTestCase(entries) {
|
||||||
|
const map = new Map();
|
||||||
|
for (const entry of entries) {
|
||||||
|
const {commit, date, tool, benches} = entry;
|
||||||
|
for (const bench of benches) {
|
||||||
|
const result = { commit, date, tool, bench };
|
||||||
|
const arr = map.get(bench.name);
|
||||||
|
if (arr === undefined) {
|
||||||
|
map.set(bench.name, [result]);
|
||||||
|
} else {
|
||||||
|
arr.push(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = window.BENCHMARK_DATA;
|
||||||
|
|
||||||
|
// Render header
|
||||||
|
document.getElementById('last-update').textContent = new Date(data.lastUpdate).toString();
|
||||||
|
const repoLink = document.getElementById('repository-link');
|
||||||
|
repoLink.href = data.repoUrl;
|
||||||
|
repoLink.textContent = data.repoUrl;
|
||||||
|
|
||||||
|
// Render footer
|
||||||
|
document.getElementById('dl-button').onclick = () => {
|
||||||
|
const dataUrl = 'data:,' + JSON.stringify(data, null, 2);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = dataUrl;
|
||||||
|
a.download = 'benchmark_data.json';
|
||||||
|
a.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prepare data points for charts
|
||||||
|
return Object.keys(data.entries).map(name => ({
|
||||||
|
name,
|
||||||
|
dataSet: collectBenchesPerTestCase(data.entries[name]),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAllChars(dataSets) {
|
||||||
|
|
||||||
|
function renderGraph(parent, name, dataset) {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.className = 'benchmark-chart';
|
||||||
|
parent.appendChild(canvas);
|
||||||
|
|
||||||
|
const color = toolColors[dataset.length > 0 ? dataset[0].tool : '_'];
|
||||||
|
const data = {
|
||||||
|
labels: dataset.map(d => d.commit.id.slice(0, 7)),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: name,
|
||||||
|
data: dataset.map(d => d.bench.value),
|
||||||
|
borderColor: color,
|
||||||
|
backgroundColor: color + '60', // Add alpha for #rrggbbaa
|
||||||
|
}
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const options = {
|
||||||
|
scales: {
|
||||||
|
xAxes: [
|
||||||
|
{
|
||||||
|
scaleLabel: {
|
||||||
|
display: true,
|
||||||
|
labelString: 'commit',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
yAxes: [
|
||||||
|
{
|
||||||
|
scaleLabel: {
|
||||||
|
display: true,
|
||||||
|
labelString: dataset.length > 0 ? dataset[0].bench.unit : '',
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
beginAtZero: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
tooltips: {
|
||||||
|
callbacks: {
|
||||||
|
afterTitle: items => {
|
||||||
|
const {index} = items[0];
|
||||||
|
const data = dataset[index];
|
||||||
|
return '\n' + data.commit.message + '\n\n' + data.commit.timestamp + ' committed by @' + data.commit.committer.username + '\n';
|
||||||
|
},
|
||||||
|
label: item => {
|
||||||
|
let label = item.value;
|
||||||
|
const { range, unit } = dataset[item.index].bench;
|
||||||
|
label += ' ' + unit;
|
||||||
|
if (range) {
|
||||||
|
label += ' (' + range + ')';
|
||||||
|
}
|
||||||
|
return label;
|
||||||
|
},
|
||||||
|
afterLabel: item => {
|
||||||
|
const { extra } = dataset[item.index].bench;
|
||||||
|
return extra ? '\n' + extra : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onClick: (_mouseEvent, activeElems) => {
|
||||||
|
if (activeElems.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// XXX: Undocumented. How can we know the index?
|
||||||
|
const index = activeElems[0]._index;
|
||||||
|
const url = dataset[index].commit.url;
|
||||||
|
window.open(url, '_blank');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
new Chart(canvas, {
|
||||||
|
type: 'line',
|
||||||
|
data,
|
||||||
|
options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBenchSet(name, benchSet, main) {
|
||||||
|
const setElem = document.createElement('div');
|
||||||
|
setElem.className = 'benchmark-set';
|
||||||
|
main.appendChild(setElem);
|
||||||
|
|
||||||
|
const nameElem = document.createElement('h1');
|
||||||
|
nameElem.className = 'benchmark-title';
|
||||||
|
nameElem.textContent = name;
|
||||||
|
setElem.appendChild(nameElem);
|
||||||
|
|
||||||
|
const graphsElem = document.createElement('div');
|
||||||
|
graphsElem.className = 'benchmark-graphs';
|
||||||
|
setElem.appendChild(graphsElem);
|
||||||
|
|
||||||
|
for (const [benchName, benches] of benchSet.entries()) {
|
||||||
|
renderGraph(graphsElem, benchName, benches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const main = document.getElementById('main');
|
||||||
|
for (const {name, dataSet} of dataSets) {
|
||||||
|
renderBenchSet(name, dataSet, main);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderAllChars(init()); // Start
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"$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": {}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Generated
-3795
File diff suppressed because it is too large
Load Diff
@@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,609 +0,0 @@
|
|||||||
<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/[email protected]'"
|
|
||||||
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/[email protected] 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/[email protected]"
|
|
||||||
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="[email protected]"
|
|
||||||
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>
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,305 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,416 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
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
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,418 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
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
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,307 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
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"
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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>
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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>
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
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>
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
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"
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
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"
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default as Input } from "./Input.vue"
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default as Separator } from "./Separator.vue"
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default as Skeleton } from "./Skeleton.vue"
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
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'
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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()
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import type { ClassValue } from "clsx"
|
|
||||||
import { clsx } from "clsx"
|
|
||||||
import { twMerge } from "tailwind-merge"
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
|
||||||
return twMerge(clsx(inputs))
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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')
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
@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 */
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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()
|
|
||||||
})
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
/** @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"),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"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" }],
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"composite": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowSyntheticDefaultImports": true
|
|
||||||
},
|
|
||||||
"include": ["vite.config.ts"]
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
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'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
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=
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user