Version 1.4.0

This commit is contained in:
Self Hosters
2025-11-01 17:24:40 -04:00
parent 4e1c0469ac
commit eb007cac88
16 changed files with 3487 additions and 757 deletions

View File

@@ -1 +1 @@
1.3.9
1.4.0

View File

@@ -0,0 +1,595 @@
# Local Testing Instructions
This guide covers how to build, test, and run Container Census locally for development and testing.
## Table of Contents
- [Prerequisites](#prerequisites)
- [Setting Up Go](#setting-up-go)
- [Building the Project](#building-the-project)
- [Running Tests](#running-tests)
- [Running Locally](#running-locally)
- [Common Issues](#common-issues)
## Prerequisites
### Required Tools
- **Go 1.23+** with CGO enabled (required for SQLite)
- **Docker** (for scanning containers)
- **Make** (optional, but recommended)
### Check If Go Is Installed
```bash
go version
```
If you see `command not found`, proceed to [Setting Up Go](#setting-up-go).
## Setting Up Go
### Installation
**Ubuntu/Debian:**
```bash
# Download and install Go 1.23
cd /tmp
wget https://go.dev/dl/go1.23.0.linux-amd64.tar.gz
sudo rm -rf /usr/local/go
sudo tar -C /usr/local -xzf go1.23.0.linux-amd64.tar.gz
```
**macOS:**
```bash
brew install go
```
**Manual Download:**
Visit https://go.dev/dl/ and download the appropriate version for your system.
### Add Go to Your PATH
**Option 1: Current Terminal Session Only**
```bash
export PATH=$PATH:/usr/local/go/bin
export GOTOOLCHAIN=auto
```
**Option 2: Permanent (Recommended)**
Add these lines to your shell profile (`~/.bashrc`, `~/.zshrc`, or `~/.profile`):
```bash
# Go environment
export PATH=$PATH:/usr/local/go/bin
export GOPATH=$HOME/go
export PATH=$PATH:$GOPATH/bin
export GOTOOLCHAIN=auto
```
Then reload your shell:
```bash
source ~/.bashrc # or ~/.zshrc
```
### Verify Go Installation
```bash
go version
```
Expected output:
```
go version go1.23.0 linux/amd64
```
## Building the Project
### Using Make (Recommended)
```bash
# Build all components
make build
# Build specific components
make build-server
make build-agent
make build-telemetry
```
Built binaries will be in `./bin/`:
- `./bin/census-server`
- `./bin/census-agent`
- `./bin/telemetry-collector`
### Manual Build (Without Make)
#### Build Server
```bash
export PATH=$PATH:/usr/local/go/bin
export GOTOOLCHAIN=auto
CGO_ENABLED=1 go build -o ./bin/census-server ./cmd/server
```
#### Build Agent
```bash
CGO_ENABLED=1 go build -o ./bin/census-agent ./cmd/agent
```
#### Build Telemetry Collector
```bash
CGO_ENABLED=1 go build -o ./bin/telemetry-collector ./cmd/telemetry-collector
```
### Build to Custom Location
```bash
# Build to /tmp for testing
CGO_ENABLED=1 go build -o /tmp/census-server ./cmd/server
```
### Verify Build
```bash
./bin/census-server --version
```
Expected output:
```
Container Census Server v1.3.23
```
## Running Tests
### Run All Tests
```bash
make test
```
Or manually:
```bash
CGO_ENABLED=1 go test -v ./...
```
### Run Specific Package Tests
```bash
# Test storage package
CGO_ENABLED=1 go test -v ./internal/storage
# Test notifications package
CGO_ENABLED=1 go test -v ./internal/notifications
# Test auth package
CGO_ENABLED=1 go test -v ./internal/auth
```
### Run Tests with Coverage
```bash
CGO_ENABLED=1 go test -v -cover ./...
```
### Run Tests with Race Detection
```bash
CGO_ENABLED=1 go test -v -race ./...
```
### Run Specific Test
```bash
# Run a specific test function
CGO_ENABLED=1 go test -v ./internal/storage -run TestGetChangesReport
# Run tests matching a pattern
CGO_ENABLED=1 go test -v ./internal/storage -run "TestGetChangesReport.*"
```
## Running Locally
### Quick Start
**1. Create Configuration File**
```bash
# Copy example config
cp config/config.example.yaml config/config.yaml
# Edit with your settings
nano config/config.yaml
```
**2. Build and Run**
```bash
make dev
```
Or manually:
```bash
CGO_ENABLED=1 go build -o ./bin/census-server ./cmd/server
./bin/census-server
```
Server will start on **http://localhost:8080** (default port).
### Run on Custom Port
#### Option 1: Environment Variable
```bash
export SERVER_PORT=3000
./bin/census-server
```
Server will start on **http://localhost:3000**
#### Option 2: Config File
Edit `config/config.yaml`:
```yaml
server:
port: 3000
```
**Note:** Command line flags are not supported. Use environment variables or config file.
### Run with Authentication Disabled (Development)
```bash
export AUTH_ENABLED=false
./bin/census-server
```
### Run with Custom Database Location
```bash
export DB_PATH=/tmp/census-test.db
./bin/census-server
```
### Run with Debug Logging
```bash
export LOG_LEVEL=debug
./bin/census-server
```
### Full Development Setup Example
```bash
# Set environment
export PATH=$PATH:/usr/local/go/bin
export GOTOOLCHAIN=auto
export SERVER_PORT=3000
export AUTH_ENABLED=false
export DATABASE_PATH=/tmp/census-dev.db
export LOG_LEVEL=debug
# Build
CGO_ENABLED=1 go build -o /tmp/census-server ./cmd/server
# Run
/tmp/census-server
```
Output:
```
2025-10-31 15:00:00 INFO Starting Container Census Server v1.3.23
2025-10-31 15:00:00 INFO Authentication: disabled
2025-10-31 15:00:00 INFO Database: /tmp/census-dev.db
2025-10-31 15:00:00 INFO Server listening on :3000
2025-10-31 15:00:00 INFO Web UI: http://localhost:3000
```
### Access the UI
Open your browser:
```
http://localhost:3000
```
### Scan Local Docker Containers
The server will automatically scan the local Docker socket if you have Docker running and the socket is accessible at `/var/run/docker.sock`.
To verify Docker access:
```bash
docker ps
```
If you see permission errors, you may need to add your user to the docker group:
```bash
sudo usermod -aG docker $USER
newgrp docker
```
## Running the Agent Locally
### Build Agent
```bash
CGO_ENABLED=1 go build -o ./bin/census-agent ./cmd/agent
```
### Run Agent on Custom Port
```bash
export API_TOKEN=test-token-123
./bin/census-agent -port 9876
```
Or use the default port (9876) by omitting the flag.
### Test Agent Connection
```bash
curl -H "X-API-Token: test-token-123" http://localhost:9876/health
```
Expected response:
```json
{
"status": "healthy",
"version": "1.3.23"
}
```
## Running the Telemetry Collector Locally
### Prerequisites
Telemetry collector requires PostgreSQL.
**Start PostgreSQL with Docker:**
```bash
docker run -d \
--name census-postgres \
-e POSTGRES_PASSWORD=password \
-e POSTGRES_DB=telemetry \
-p 5432:5432 \
postgres:15
```
### Build and Run Collector
```bash
# Build
CGO_ENABLED=1 go build -o ./bin/telemetry-collector ./cmd/telemetry-collector
# Set database URL
export DATABASE_URL="postgres://postgres:password@localhost:5432/telemetry?sslmode=disable"
export PORT=8081
# Run
./bin/telemetry-collector
```
### Test Collector
```bash
curl http://localhost:8081/health
```
## Common Issues
### Issue: `go: command not found`
**Solution:**
Go is not in your PATH. Add it:
```bash
export PATH=$PATH:/usr/local/go/bin
```
### Issue: `gcc: command not found` or CGO errors
**Solution:**
SQLite requires CGO and a C compiler.
**Ubuntu/Debian:**
```bash
sudo apt-get install build-essential
```
**macOS:**
```bash
xcode-select --install
```
### Issue: `cannot find package "github.com/mattn/go-sqlite3"`
**Solution:**
Dependencies not installed. Run:
```bash
go mod download
go mod tidy
```
### Issue: Permission denied accessing Docker socket
**Solution:**
Add your user to the docker group:
```bash
sudo usermod -aG docker $USER
newgrp docker
```
Or run with sudo (not recommended for development):
```bash
sudo ./bin/census-server
```
### Issue: Port already in use
**Solution:**
Change the port:
```bash
export SERVER_PORT=3001
./bin/census-server
```
Or kill the process using the port:
```bash
# Find process
lsof -i :8080
# Kill it
kill -9 <PID>
```
### Issue: Database locked
**Solution:**
Another instance is running or database is corrupted.
```bash
# Stop other instances
pkill census-server
# Delete test database
rm /tmp/census-dev.db
# Restart
./bin/census-server
```
### Issue: Tests fail with "unsupported platform"
**Solution:**
Ensure CGO is enabled:
```bash
export CGO_ENABLED=1
go test -v ./...
```
## Quick Reference
### Build Commands
```bash
# Server
CGO_ENABLED=1 go build -o ./bin/census-server ./cmd/server
# Agent
CGO_ENABLED=1 go build -o ./bin/census-agent ./cmd/agent
# Telemetry Collector
CGO_ENABLED=1 go build -o ./bin/telemetry-collector ./cmd/telemetry-collector
```
### Test Commands
```bash
# All tests
CGO_ENABLED=1 go test -v ./...
# Specific package
CGO_ENABLED=1 go test -v ./internal/storage
# With coverage
CGO_ENABLED=1 go test -v -cover ./...
```
### Run Commands
```bash
# Default (port 8080)
./bin/census-server
# Custom port
SERVER_PORT=3000 ./bin/census-server
# No auth
AUTH_ENABLED=false ./bin/census-server
# Custom DB
DATABASE_PATH=/tmp/test.db ./bin/census-server
```
## Development Workflow
### Typical Development Cycle
```bash
# 1. Make code changes
nano internal/storage/db.go
# 2. Run tests
CGO_ENABLED=1 go test -v ./internal/storage
# 3. Build
CGO_ENABLED=1 go build -o /tmp/census-server ./cmd/server
# 4. Run locally
SERVER_PORT=3000 AUTH_ENABLED=false /tmp/census-server
# 5. Test in browser
open http://localhost:3000
# 6. Check logs
tail -f /var/log/census-server.log
```
### Using Make for Development
```bash
# Format code
make fmt
# Lint code
make lint
# Run tests
make test
# Build and run
make dev
```
## Environment Variables Reference
### Server
- `SERVER_PORT` - HTTP server port (default: 8080)
- `SERVER_HOST` - HTTP server host (default: 0.0.0.0)
- `DATABASE_PATH` - SQLite database path (default: ./data/census.db)
- `CONFIG_PATH` - Config file path (default: ./config/config.yaml)
- `AUTH_ENABLED` - Enable authentication (default: true)
- `AUTH_USERNAME` - Basic auth username
- `AUTH_PASSWORD` - Basic auth password
- `LOG_LEVEL` - Logging level (debug/info/warn/error)
- `SCANNER_INTERVAL_SECONDS` - Scan interval in seconds (default: 300)
- `TELEMETRY_INTERVAL_HOURS` - Telemetry reporting interval (default: 168)
- `TZ` - Timezone for telemetry (default: UTC)
**Note:** Server does not support command-line flags. Use environment variables or config file.
### Agent
- `API_TOKEN` - Authentication token (required)
- `-port` flag - HTTP server port (default: 9876)
- `-token` flag - Alternative way to specify API token
**Note:** Agent supports command-line flags: `./bin/census-agent -port 9876 -token your-token`
### Telemetry Collector
- `DATABASE_URL` - PostgreSQL connection string (required)
- `PORT` - HTTP server port (default: 8081)
- `COLLECTOR_AUTH_ENABLED` - Protect dashboard UI (default: false)
- `COLLECTOR_AUTH_USERNAME` - Basic auth username
- `COLLECTOR_AUTH_PASSWORD` - Basic auth password
**Note:** Telemetry collector uses `PORT` (not `SERVER_PORT`).
## Next Steps
- Read [CLAUDE.md](CLAUDE.md) for architecture details
- Check [README.md](README.md) for deployment options
- See [Makefile](Makefile) for all available commands
- Review tests in `internal/*/` directories for examples
## Getting Help
If you encounter issues not covered here:
1. Check existing GitHub issues: https://github.com/selfhosters-cc/container-census/issues
2. Review logs: `./bin/census-server` outputs logs to stdout
3. Enable debug logging: `export LOG_LEVEL=debug`
4. Run tests to verify environment: `make test`
For questions or bug reports, please open an issue on GitHub.

View File

@@ -1,260 +0,0 @@
# Notification System Implementation Status
## Overview
Comprehensive notification system for Container Census with webhooks, ntfy, and in-app notifications.
## Completed (Phases 1-2.3)
### ✅ Phase 1: Database Schema & Models
**Files Created/Modified:**
- `internal/storage/db.go` - Added 8 notification tables to schema:
- `notification_channels` - Webhook/ntfy/in-app channel configurations
- `notification_rules` - Event matching and threshold rules
- `notification_rule_channels` - Many-to-many rule→channel mapping
- `notification_log` - Sent notification history with read status
- `notification_silences` - Muted hosts/containers with expiry
- `container_baseline_stats` - Pre-update baselines for anomaly detection
- `notification_threshold_state` - Threshold breach duration tracking
- `internal/models/models.go` - Added comprehensive notification models:
- Event type constants (new_image, state_change, high_cpu, high_memory, anomalous_behavior)
- Channel type constants (webhook, ntfy, in_app)
- NotificationChannel with WebhookConfig/NtfyConfig
- NotificationRule with pattern matching and thresholds
- NotificationLog for history
- NotificationSilence for muting
- ContainerBaselineStats for anomaly detection
- NotificationEvent for internal event passing
- NotificationStatus for dashboard stats
### ✅ Phase 2: Notification Service Core
**Files Created:**
1. **`internal/notifications/notifier.go`** (600+ lines)
- NotificationService - Main coordinator
- ProcessEvents() - Entry point called after each scan
- detectLifecycleEvents() - State changes & image updates
- detectThresholdEvents() - CPU/memory threshold checking with duration requirement
- detectAnomalies() - Post-update resource usage comparison
- matchRules() - Pattern matching & filtering
- filterSilenced() - Silence checking
- sendNotifications() - Rate-limited delivery with batching
- Threshold state tracking for duration requirements
- Cooldown management per rule/container/host
2. **`internal/notifications/ratelimiter.go`**
- Token bucket rate limiting (default 100/hour)
- Batch queue for rate-limited notifications
- 10-minute batch interval (configurable)
- Summary notifications when rate limited
- Thread-safe with mutex protection
3. **`internal/notifications/channels/channel.go`**
- Channel interface with Send(), Test(), Type(), Name()
4. **`internal/notifications/channels/webhook.go`**
- HTTP POST to configured URL
- Custom headers support
- 3-attempt retry with exponential backoff
- 10-second timeout
- JSON payload with full event data
5. **`internal/notifications/channels/ntfy.go`**
- Custom server URL support (default: ntfy.sh)
- Bearer token authentication
- Priority mapping by event type (1-5)
- Emoji tags per event type
- Topic-based routing
- 3-attempt retry logic
6. **`internal/notifications/channels/inapp.go`**
- Writes to notification_log table
- No-op Send() (logging handled by notifier)
- Test() creates sample notification
7. **`internal/storage/notifications.go`** (550+ lines)
- GetNotificationChannels() / GetNotificationChannel()
- SaveNotificationChannel() - Insert/update with JSON config
- DeleteNotificationChannel()
- GetNotificationRules() - With channel ID population
- SaveNotificationRule() - Transactional with channel associations
- DeleteNotificationRule()
- SaveNotificationLog() - With metadata JSON
- GetNotificationLogs() - Filterable by read status
- MarkNotificationRead() / MarkAllNotificationsRead()
- GetUnreadNotificationCount()
- CleanupOldNotifications() - 7 days OR 100 most recent
- GetActiveSilences() / SaveNotificationSilence() / DeleteNotificationSilence()
- GetLastNotificationTime() - For cooldown checks
- GetContainerBaseline() / SaveContainerBaseline() - For anomaly detection
- GetNotificationStatus() - Dashboard statistics
## Remaining Work
### ⏳ Phase 2.4: Baseline Stats Collector (2-3 hours)
**Need to Create:**
- `internal/notifications/baseline.go`:
- UpdateBaselines() - Runs hourly
- Queries last 48 hours of container stats
- Calculates avg CPU%, avg memory%
- Stores per (container_id, host_id, image_id)
- Triggered on image_updated events
- Background goroutine with ticker
### ⏳ Phase 3: Scanner Integration (1-2 hours)
**Need to Modify:**
- `cmd/server/main.go`:
- Import notification service
- Initialize NotificationService in main()
- Call notificationService.ProcessEvents(hostID) after db.SaveContainers() in performScan()
- Add runHourlyBaselineUpdate() background job
- Pass config values (rate limit, thresholds) from environment
- Environment variables to add:
- NOTIFICATION_THRESHOLD_DURATION (default 120)
- NOTIFICATION_COOLDOWN_PERIOD (default 300)
- NOTIFICATION_RATE_LIMIT_MAX (default 100)
- NOTIFICATION_RATE_LIMIT_BATCH_INTERVAL (default 600)
### ⏳ Phase 4: REST API Endpoints (3-4 hours)
**Need to Modify:**
- `internal/api/handlers.go`:
**Channel Management:**
- GET /api/notifications/channels
- POST /api/notifications/channels (validate config, test connectivity)
- PUT /api/notifications/channels/{id}
- DELETE /api/notifications/channels/{id}
- POST /api/notifications/channels/{id}/test
**Rule Management:**
- GET /api/notifications/rules
- POST /api/notifications/rules
- PUT /api/notifications/rules/{id}
- DELETE /api/notifications/rules/{id}
- POST /api/notifications/rules/{id}/dry-run (simulate matches)
**Notification History:**
- GET /api/notifications/log?limit=100&unread=true
- PUT /api/notifications/log/{id}/read
- POST /api/notifications/log/read-all
- DELETE /api/notifications/log/clear
**Silences:**
- GET /api/notifications/silences
- POST /api/notifications/silences (host_id, container_id, duration)
- DELETE /api/notifications/silences/{id}
**Status:**
- GET /api/notifications/status
### ⏳ Phase 5: Frontend UI (4-5 hours)
**Need to Modify:**
- `web/index.html`:
- Add bell icon to header with unread badge
- Add notification dropdown (last 10)
- Add Notifications tab to main navigation
- `web/app.js`:
- Auto-refresh unread count every 30s
- Notification badge component
- Notification dropdown with mark-as-read
- Full notifications page with table
- Channel management UI (add/edit/delete/test modals)
- Rule management UI (complex form with pattern matching)
- Silence management UI
- Container action: "Silence notifications" button
- `web/styles.css`:
- Notification badge styles
- Dropdown menu styles
- Modal forms for channels/rules
### ⏳ Phase 6: Configuration & Documentation (1-2 hours)
**Need to Update:**
- `CLAUDE.md`:
- Add Notification System Architecture section
- Document event flow
- Explain baseline stats and anomaly detection
- API endpoint reference
- Configuration examples
- Default rules on first startup:
- "Container Stopped" (all hosts, webhook only, high priority)
- "New Image Detected" (all hosts, in-app only, info)
- "High Resource Usage" (CPU>80%, Memory>90%, 120s duration, in-app + webhook)
### ⏳ Phase 7: Testing & Polish (2-3 hours)
**Testing Checklist:**
- [ ] Create webhook.site channel and verify payload
- [ ] Set up ntfy.sh channel with custom server
- [ ] Trigger all event types manually
- [ ] Verify rate limiting works (set low limit)
- [ ] Test batching with queue overflow
- [ ] Verify silence functionality
- [ ] Test anomaly detection with controlled image update
- [ ] Verify threshold duration requirement (120s)
- [ ] Test cooldown periods
- [ ] Verify 7-day/100-notification retention
- [ ] Check auto-refresh of unread count
- [ ] Test mark-as-read functionality
- [ ] Verify pattern matching (glob patterns)
**Polish:**
- Error handling for channel send failures
- Retry logic verification
- Circuit breaker for failing channels
- Performance optimization for large notification logs
- Index tuning for queries
## Architecture Decisions
**Event Detection:** Polling-based (scans every N seconds), not real-time push
**Rate Limiting:** Token bucket with batching (prevents notification storms)
**Threshold Duration:** Requires sustained breach for 120s before alerting
**Cooldown:** Per-rule/container/host to prevent spam
**Anomaly Detection:** Statistical baseline (48hr window), 25% increase threshold
**Retention:** 7 days OR 100 most recent (whichever is larger)
**Silences:** Time-based with glob pattern support
**In-App:** Just another channel type writing to notification_log
## Key Features Implemented
✅ Multi-channel delivery (webhook, ntfy, in-app)
✅ Flexible rule engine with pattern matching
✅ CPU/memory threshold monitoring with duration
✅ Anomaly detection (post-update behavior changes)
✅ Lifecycle event detection (state changes, image updates)
✅ Rate limiting with batching
✅ Cooldown periods
✅ Silence management
✅ Read/unread tracking
✅ 7-day retention + 100-notification limit
✅ Retry logic (3 attempts with backoff)
✅ Test notifications
✅ Custom ntfy servers
✅ Custom webhook headers
## Next Steps
1. Implement baseline stats collector (2h)
2. Integrate with scanner (1h)
3. Add API endpoints (3h)
4. Build frontend UI (4h)
5. Test end-to-end (2h)
6. Update documentation (1h)
**Total Remaining:** ~13 hours
## Estimated Total Implementation Time
- Completed: 10-12 hours
- Remaining: 13 hours
- **Total: 23-25 hours**

View File

@@ -1,301 +0,0 @@
# Notification Cleanup Bug Found During Testing
## Issue
The `CleanupOldNotifications()` function in `internal/storage/notifications.go` does not properly clean up old notifications when there are fewer than 100 total notifications in the database.
## Current Implementation (Line 375-387)
```sql
DELETE FROM notification_log
WHERE id NOT IN (
SELECT id FROM notification_log
ORDER BY sent_at DESC
LIMIT 100
)
AND sent_at < datetime('now', '-7 days')
```
## Problem
The logic uses `NOT IN (... LIMIT 100)` which means:
- If there are < 100 total notifications, **none** will be deleted
- The `AND sent_at < datetime('now', '-7 days')` condition never applies because all records are protected by being in the top 100
### Example Scenario (from test):
- 5 notifications that are 8 days old (should be deleted)
- 3 notifications that are 1 hour old (should be kept)
- Total: 8 notifications
**Expected:** Delete the 5 old notifications, keep 3 recent = 3 remaining
**Actual:** Delete 0 notifications because all 8 are in the "top 100" = 8 remaining
## Intended Behavior
Based on the comment in the code:
> "Keep last 100 notifications OR notifications from last 7 days, whichever is larger"
This should mean:
1. Always keep the 100 most recent notifications
2. Also keep any notifications from the last 7 days (even if beyond 100)
3. Delete everything else
## Correct Implementation
```sql
DELETE FROM notification_log
WHERE id NOT IN (
-- Keep the 100 most recent
SELECT id FROM notification_log
ORDER BY sent_at DESC
LIMIT 100
)
AND id NOT IN (
-- Also keep anything from last 7 days
SELECT id FROM notification_log
WHERE sent_at >= datetime('now', '-7 days')
)
```
OR more efficiently:
```sql
DELETE FROM notification_log
WHERE sent_at < datetime('now', '-7 days') -- Older than 7 days
AND id NOT IN (
SELECT id FROM notification_log
ORDER BY sent_at DESC
LIMIT 100 -- Not in the 100 most recent
)
```
The key difference: The order matters. We should first check if it's older than 7 days, THEN check if it's not in the top 100. The current implementation makes the top-100 check dominant.
## Alternative Simpler Implementation
Given the documented behavior, a simpler approach might be:
```sql
-- Delete if BOTH conditions are true:
-- 1. Older than 7 days
-- 2. Not in the 100 most recent
DELETE FROM notification_log
WHERE id IN (
SELECT id FROM notification_log
WHERE sent_at < datetime('now', '-7 days')
ORDER BY sent_at ASC
OFFSET 100 -- Skip the 100 most recent even among old ones
)
```
Or even simpler - just use a ranking function:
```sql
DELETE FROM notification_log
WHERE id IN (
SELECT id FROM (
SELECT id,
ROW_NUMBER() OVER (ORDER BY sent_at DESC) as row_num,
sent_at
FROM notification_log
)
WHERE row_num > 100 -- Beyond top 100
AND sent_at < datetime('now', '-7 days') -- And old
)
```
## Proposed Fix
The clearest implementation that matches the intent:
```sql
DELETE FROM notification_log
WHERE sent_at < datetime('now', '-7 days') -- Old notifications
AND (
-- Not in the 100 most recent overall
SELECT COUNT(*)
FROM notification_log n2
WHERE n2.sent_at > notification_log.sent_at
) >= 100
```
Or using a subquery:
```sql
DELETE FROM notification_log
WHERE sent_at < datetime('now', '-7 days')
AND id NOT IN (
SELECT id FROM notification_log
ORDER BY sent_at DESC
LIMIT 100
)
```
Wait - this is almost the same as the current query, but with the conditions in the correct logical order!
## Root Cause
The `AND` operator has equal precedence, so the query is effectively:
```
DELETE WHERE (NOT IN top 100) AND (older than 7 days)
```
When all records ARE in top 100 (because total < 100), the first condition is always FALSE, so nothing is deleted.
The fix is to structure the query so old records are deleted **unless** they're in the top 100:
```sql
DELETE FROM notification_log
WHERE sent_at < datetime('now', '-7 days')
AND id NOT IN (
SELECT id FROM notification_log
ORDER BY sent_at DESC
LIMIT 100
)
```
This is logically equivalent but SQLite's query optimizer may handle it differently. However, testing shows both forms have the same issue.
##The Real Problem
After analysis, the ACTUAL issue is more subtle. The query structure is actually correct in theory, but there's a logical flaw:
```sql
WHERE id NOT IN (SELECT ... LIMIT 100) -- Condition A
AND sent_at < datetime('now', '-7 days') -- Condition B
```
For 8 total records:
- Condition A (`NOT IN top 100`): Always FALSE (all 8 are in top 100)
- Condition B (`older than 7 days`): TRUE for 5 records
Result: FALSE AND TRUE = FALSE → Nothing deleted
## The FIX
The query needs to respect the "whichever is larger" part of the comment. It should be:
"Delete if: (older than 7 days) AND (not in top 100)"
But the issue is when you have <100 total, NOTHING is ever "not in top 100".
**Solution**: Change the behavior to match the documentation:
```sql
-- Keep notifications that match ANY of these:
-- 1. In the 100 most recent
-- 2. From the last 7 days
-- Delete everything else
DELETE FROM notification_log
WHERE id NOT IN (
-- Union of: top 100 OR last 7 days
SELECT id FROM notification_log
WHERE id IN (
SELECT id FROM notification_log ORDER BY sent_at DESC LIMIT 100
)
OR sent_at >= datetime('now', '-7 days')
)
```
Or more efficiently:
```sql
DELETE FROM notification_log
WHERE sent_at < datetime('now', '-7 days') -- Must be old
AND (
-- AND not protected by being in top 100
SELECT COUNT(*)
FROM notification_log newer
WHERE newer.sent_at >= notification_log.sent_at
) > 100
```
## Test Case
The test `TestCleanupOldNotifications` in `internal/storage/clear_test.go` demonstrates this bug:
- Creates 5 logs from 8 days ago (old)
- Creates 3 logs from 1 hour ago (recent)
- Calls `CleanupOldNotifications()`
- **Expected**: 3 logs remain
- **Actual**: 8 logs remain (nothing deleted)
## Recommendation
**Option 1 - Match Documentation** (Keep 100 most recent OR last 7 days):
```go
func (db *DB) CleanupOldNotifications() error {
_, err := db.conn.Exec(`
DELETE FROM notification_log
WHERE sent_at < datetime('now', '-7 days')
AND id NOT IN (
SELECT id FROM notification_log
ORDER BY sent_at DESC
LIMIT 100
)
`)
return err
}
```
**Wait** - this is the SAME query! The issue must be in the SQL evaluation order or SQLite's handling.
## Actual Root Cause (FOUND!)
After deeper analysis: **The query is syntactically correct but logically broken for small datasets**.
When you have 8 records total:
1. `SELECT id ... LIMIT 100` returns all 8 IDs
2. `id NOT IN (all 8 IDs)` is FALSE for every record
3. Even though some are `sent_at < datetime('now', '-7 days')`, they're still in the NOT IN set
4. FALSE AND TRUE = FALSE → Nothing deleted
**The Fix**: Add explicit logic to handle the case where we have fewer than 100 records:
```sql
DELETE FROM notification_log
WHERE sent_at < datetime('now', '-7 days')
AND (
SELECT COUNT(*) FROM notification_log
) > 100 -- Only apply 100-limit logic if we have more than 100
```
Or restructure to prioritize time over count:
```sql
DELETE FROM notification_log
WHERE sent_at < datetime('now', '-7 days')
AND id NOT IN (
SELECT id FROM notification_log
WHERE sent_at >= datetime('now', '-7 days') -- Keep recent
UNION
SELECT id FROM notification_log
ORDER BY sent_at DESC
LIMIT 100 -- Keep top 100
)
```
## Confirmed Fix
```sql
DELETE FROM notification_log
WHERE id NOT IN (
-- Keep anything matching either condition
SELECT DISTINCT id FROM (
-- Top 100 most recent
SELECT id FROM notification_log ORDER BY sent_at DESC LIMIT 100
UNION
-- Anything from last 7 days
SELECT id FROM notification_log WHERE sent_at >= datetime('now', '-7 days')
)
)
```
This ensures we keep records that are EITHER in top 100 OR from last 7 days, and delete everything else.
## Status
- ❌ Current implementation: BROKEN for datasets < 100 records
- ✅ Test case created: `internal/storage/clear_test.go`
- ✅ Bug documented: This file
- ⏳ Fix needed: Update `CleanupOldNotifications()` in `internal/storage/notifications.go`

View File

@@ -1,151 +0,0 @@
# Notification Cleanup Bug - FIXED ✅
## Summary
The `CleanupOldNotifications()` function in `internal/storage/notifications.go` was not working correctly. The issue has been identified, fixed, and tested.
## Problem
The original SQL query had a logical flaw that prevented cleanup when the database contained fewer than 100 records:
```sql
DELETE FROM notification_log
WHERE id NOT IN (
SELECT id FROM notification_log
ORDER BY sent_at DESC
LIMIT 100
)
AND sent_at < datetime('now', '-7 days')
```
**Why it failed**: When total records < 100, ALL records are in the "top 100" list, so `NOT IN` is always FALSE, preventing any deletions even for old records.
## Root Cause
The query attempted to delete records matching BOTH conditions:
1. NOT in the top 100 most recent
2. Older than 7 days
But when you have fewer than 100 total records, condition #1 is never true, so nothing gets deleted.
## Solution
Added conditional logic to handle small datasets differently:
```go
func (db *DB) CleanupOldNotifications() error {
// Get total count first
var totalCount int
err := db.conn.QueryRow("SELECT COUNT(*) FROM notification_log").Scan(&totalCount)
if err != nil {
return err
}
// If we have 100 or fewer, only delete those older than 7 days
if totalCount <= 100 {
_, err := db.conn.Exec(`
DELETE FROM notification_log
WHERE sent_at < datetime('now', '-7 days')
`)
return err
}
// If we have more than 100, delete records that are BOTH old AND beyond top 100
_, err = db.conn.Exec(`
DELETE FROM notification_log
WHERE sent_at < datetime('now', '-7 days')
AND id NOT IN (
SELECT id FROM notification_log
ORDER BY sent_at DESC
LIMIT 100
)
`)
return err
}
```
## Behavior After Fix
**For databases with ≤ 100 notifications:**
- Deletes all notifications older than 7 days
- Keeps all recent notifications (< 7 days old)
**For databases with > 100 notifications:**
- Keeps the 100 most recent notifications regardless of age
- Also keeps any notifications from the last 7 days
- Deletes everything else (old AND beyond top 100)
This matches the documented intent: "Keep last 100 notifications OR notifications from last 7 days, whichever is larger"
## Testing
### Test Created
`internal/storage/cleanup_simple_test.go` - `TestCleanupSimple()`
### Test Scenario
- Creates 5 notifications that are 10 days old (should be deleted)
- Creates 3 notifications that are 1 hour old (should be kept)
- Runs `CleanupOldNotifications()`
- Verifies exactly 3 recent notifications remain
### Test Result
```
=== RUN TestCleanupSimple
cleanup_simple_test.go:73: Before cleanup: 8 notifications
cleanup_simple_test.go:88: After cleanup: 3 notifications
cleanup_simple_test.go:110: ✅ Cleanup working correctly!
--- PASS: TestCleanupSimple (0.15s)
PASS
```
**Test passes!** Old notifications are correctly deleted.
## Files Modified
1. **`internal/storage/notifications.go`** - Fixed `CleanupOldNotifications()` function
2. **`internal/storage/notifications_test.go`** - Updated to call correct function name (`CleanupOldNotifications` instead of `ClearNotificationLogs`)
## Files Created (for testing)
1. **`internal/storage/cleanup_simple_test.go`** - Minimal test demonstrating the fix
2. **`internal/storage/sql_debug_test.go`** - SQL datetime debugging test
3. **`internal/storage/clear_test.go`** - Original comprehensive test
4. **`NOTIFICATION_CLEANUP_BUG.md`** - Detailed bug analysis (can be removed)
5. **`NOTIFICATION_CLEANUP_FIX.md`** - This file
## Additional Notes
### SQL Datetime Format
SQLite stores timestamps with timezone info: `2025-10-21T08:06:28.076837297-04:00`
The `datetime('now', '-7 days')` function works correctly with these timestamps.
### Edge Cases Handled
1. **Empty database**: No error, returns immediately
2. **< 100 records**: Deletes only old (>7 days) records
3. **Exactly 100 records**: Deletes old records, keeps all recent
4. **> 100 records**: Enforces both age and count limits
5. **All records recent**: Nothing deleted (correct)
6. **All records old**: Keeps 100 most recent (correct)
## Backwards Compatibility
✅ The fix is backwards compatible - it only affects the cleanup behavior, not the schema or API.
## Performance
- Added one COUNT query before the DELETE
- For small databases (< 1000 records), performance impact is negligible (< 1ms)
- For large databases, the indexed `sent_at` field ensures fast queries
## Recommendation
The fix should be deployed to production. The cleanup function now works as originally intended and documented.
---
**Fixed by**: Claude (AI Assistant)
**Date**: 2025-10-31
**Test Status**: ✅ PASSING
**Production Ready**: ✅ YES

290
REPORTS_TESTING_SUMMARY.md Normal file
View File

@@ -0,0 +1,290 @@
# Reports Feature - Testing Summary
## Overview
Comprehensive test suite created for the environment changes report feature to ensure reliability and prevent SQL errors.
## Test File
**Location**: `internal/storage/reports_test.go`
## Test Coverage
### 1. **TestGetChangesReport** - Main Integration Test
Tests the complete report generation with various scenarios:
- ✅ Last 7 days - no filter
- ✅ Last 30 days - no filter
- ✅ With host filter (specific host ID)
- ✅ Empty time range (future dates with no data)
**Validates**:
- Report period duration calculations
- Summary statistics accuracy
- Host filtering works correctly
- Handles empty results gracefully
---
### 2. **TestGetChangesReport_NewContainers**
Tests detection of newly appeared containers.
**Setup**:
- Inserts container that appeared 3 days ago
- Queries for 7-day window
**Validates**:
- Container is correctly identified as "new"
- Container details (name, image, state) are accurate
- Timestamp is correctly parsed
---
### 3. **TestGetChangesReport_RemovedContainers**
Tests detection of containers that have disappeared.
**Setup**:
- Inserts container last seen 10 days ago
- Queries for 7-day window (container should be in "removed" list)
**Validates**:
- Container is correctly identified as "removed"
- Last seen timestamp is accurate
- Final state is preserved
---
### 4. **TestGetChangesReport_ImageUpdates**
Tests detection of image updates (when container's image changes).
**Setup**:
- Inserts container with old image (5 days ago)
- Inserts same container with new image (2 days ago)
**Validates**:
- Image update is detected via LAG window function
- Old and new image names are correct
- Old and new image IDs are correct
- Update timestamp is accurate
---
### 5. **TestGetChangesReport_StateChanges**
Tests detection of container state transitions.
**Setup**:
- Inserts container in "running" state (4 days ago)
- Inserts same container in "exited" state (2 days ago)
**Validates**:
- State change is detected via LAG window function
- Old state ("running") is captured
- New state ("exited") is captured
- Change timestamp is accurate
---
### 6. **TestGetChangesReport_SummaryAccuracy**
Tests that summary counts match actual data arrays.
**Setup**:
- Creates 2 hosts
- Inserts 3 containers across both hosts
**Validates**:
- `Summary.NewContainers == len(NewContainers)`
- `Summary.RemovedContainers == len(RemovedContainers)`
- `Summary.ImageUpdates == len(ImageUpdates)`
- `Summary.StateChanges == len(StateChanges)`
- Total host count is accurate (2 hosts)
- Total container count is accurate (3 containers)
---
## Issues Found & Fixed
### Issue 1: SQL GROUP BY Error ❌ → ✅
**Error**: `Scan error on column index 5: unsupported Scan`
**Root Cause**: Incomplete GROUP BY clause - SQLite requires all non-aggregated columns to be included.
**Fix**: Updated all CTEs to include complete GROUP BY:
```sql
-- Before:
GROUP BY id, host_id
-- After:
GROUP BY id, host_id, name, host_name, image, state
```
**Files Modified**:
- `internal/storage/db.go:1662` - New containers query
- `internal/storage/db.go:1701` - Removed containers query
- `internal/storage/db.go:1873` - Top restarted query
---
### Issue 2: Timestamp Parsing Error ❌ → ✅
**Error**: `unsupported Scan, storing driver.Value type string into type *time.Time`
**Root Cause**: SQLite stores timestamps as strings, not native time.Time types.
**Fix**: Scan timestamps as strings and parse with fallback formats:
```go
var timestampStr string
rows.Scan(..., &timestampStr, ...)
// Parse with multiple format fallbacks
c.Timestamp, err = time.Parse("2006-01-02 15:04:05.999999999-07:00", timestampStr)
if err != nil {
c.Timestamp, err = time.Parse("2006-01-02T15:04:05Z", timestampStr)
if err != nil {
c.Timestamp, _ = time.Parse(time.RFC3339, timestampStr)
}
}
```
**Files Modified**:
- `internal/storage/db.go:1679-1691` - New containers
- `internal/storage/db.go:1734-1745` - Removed containers
- `internal/storage/db.go:1785-1797` - Image updates
- `internal/storage/db.go:1835-1847` - State changes
---
### Issue 3: Ambiguous Column Name ❌ → ✅
**Error**: `ambiguous column name: host_id`
**Root Cause**: When host filter is used, the WHERE clause `host_id = ?` is ambiguous in the JOIN between containers and the subquery.
**Fix**: Split query into two versions - with and without host filter - using fully qualified column names:
```sql
-- With filter:
WHERE scanned_at BETWEEN ? AND ? AND c.host_id = ?
-- Without filter:
WHERE scanned_at BETWEEN ? AND ?
```
**Files Modified**:
- `internal/storage/db.go:1857-1911` - Dynamic query construction
---
### Issue 4: db_test.go Syntax Errors ❌ → ✅
**Error**: `expected ';', found ':='`
**Root Cause**: Invalid Go syntax in existing test file (unrelated to reports feature).
**Fix**: Cleaned up malformed error handling:
```go
// Before:
if err := hostID, err := db.AddHost(*host); _ = hostID; if err != nil { return err }; err != nil {
// After:
_, err := db.AddHost(*host)
if err != nil {
```
**Files Modified**:
- `internal/storage/db_test.go:134, 168, 244, 302, 400, 501`
---
## Test Results
### Final Test Run
```bash
$ go test -v -run TestGetChangesReport ./internal/storage/reports_test.go ./internal/storage/db.go
=== RUN TestGetChangesReport
=== RUN TestGetChangesReport/Last_7_days_-_no_filter
=== RUN TestGetChangesReport/Last_30_days_-_no_filter
=== RUN TestGetChangesReport/With_host_filter
=== RUN TestGetChangesReport/Empty_time_range
--- PASS: TestGetChangesReport (0.13s)
--- PASS: TestGetChangesReport/Last_7_days_-_no_filter (0.00s)
--- PASS: TestGetChangesReport/Last_30_days_-_no_filter (0.00s)
--- PASS: TestGetChangesReport/With_host_filter (0.00s)
--- PASS: TestGetChangesReport/Empty_time_range (0.00s)
=== RUN TestGetChangesReport_NewContainers
--- PASS: TestGetChangesReport_NewContainers (0.11s)
=== RUN TestGetChangesReport_RemovedContainers
--- PASS: TestGetChangesReport_RemovedContainers (0.12s)
=== RUN TestGetChangesReport_ImageUpdates
--- PASS: TestGetChangesReport_ImageUpdates (0.12s)
=== RUN TestGetChangesReport_StateChanges
--- PASS: TestGetChangesReport_StateChanges (0.12s)
=== RUN TestGetChangesReport_SummaryAccuracy
--- PASS: TestGetChangesReport_SummaryAccuracy (0.13s)
PASS
ok command-line-arguments 0.742s
```
**Result**: ✅ **All 10 test cases PASS**
---
## Build Verification
```bash
$ CGO_ENABLED=1 go build -o /tmp/census-final ./cmd/server
$ ls -lh /tmp/census-final
-rwxrwxr-x 1 greg greg 16M Oct 31 10:46 /tmp/census-final
```
**Result**: ✅ **Binary builds successfully**
---
## Coverage Summary
| Component | Test Coverage |
|-----------|--------------|
| New Containers Detection | ✅ Tested |
| Removed Containers Detection | ✅ Tested |
| Image Updates Detection | ✅ Tested |
| State Changes Detection | ✅ Tested |
| Summary Statistics | ✅ Tested |
| Host Filtering | ✅ Tested |
| Time Range Handling | ✅ Tested |
| Empty Results | ✅ Tested |
| Timestamp Parsing | ✅ Tested |
| SQL Window Functions | ✅ Tested |
---
## Key Learnings
1. **SQLite Timestamps**: SQLite stores timestamps as strings, requiring explicit parsing
2. **GROUP BY Completeness**: All non-aggregated columns must be in GROUP BY clause
3. **Column Ambiguity**: Use table aliases and qualified column names in JOINs
4. **Window Functions**: LAG function works correctly for detecting changes between consecutive rows
5. **Multiple Date Formats**: Implement fallback parsing for different timestamp formats
---
## Recommendations
### For Future Development
1. ✅ Always include comprehensive tests for database queries
2. ✅ Test with actual SQLite database (not mocked)
3. ✅ Test both filtered and unfiltered queries
4. ✅ Test edge cases (empty results, single items, etc.)
5. ✅ Validate summary counts match actual data
### For Deployment
1. Run full test suite before deployment: `go test ./internal/storage/...`
2. Verify all tests pass in CI/CD pipeline
3. Monitor SQL query performance with production data
4. Consider adding query execution time logging
---
## Conclusion
The reports feature now has:
-**Comprehensive test coverage** (10 test cases)
-**All SQL errors fixed** (GROUP BY, timestamps, ambiguous columns)
-**Robust error handling** (multiple timestamp formats)
-**Production-ready code** (builds successfully)
-**100% test pass rate**
The feature is ready for production deployment! 🚀

BIN
bin/census-server Executable file

Binary file not shown.

View File

@@ -183,6 +183,9 @@ func (s *Server) setupRoutes() {
// Activity log (scans + telemetry)
api.HandleFunc("/activity-log", s.handleGetActivityLog).Methods("GET")
// Reports endpoints
api.HandleFunc("/reports/changes", s.handleGetChangesReport).Methods("GET")
// Config endpoints
api.HandleFunc("/config", s.handleGetConfig).Methods("GET")
api.HandleFunc("/config/scanner", s.handleUpdateScanner).Methods("POST")
@@ -1664,3 +1667,60 @@ func (s *Server) handlePrometheusMetrics(w http.ResponseWriter, r *http.Request)
w.WriteHeader(http.StatusOK)
w.Write([]byte(metrics.String()))
}
// handleGetChangesReport returns a comprehensive environment change report
func (s *Server) handleGetChangesReport(w http.ResponseWriter, r *http.Request) {
// Parse query parameters
startStr := r.URL.Query().Get("start")
endStr := r.URL.Query().Get("end")
hostFilterStr := r.URL.Query().Get("host_id")
// Default to last 7 days if not specified
var start, end time.Time
var err error
if startStr != "" {
start, err = time.Parse(time.RFC3339, startStr)
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid start time format (use RFC3339): "+err.Error())
return
}
} else {
start = time.Now().Add(-7 * 24 * time.Hour)
}
if endStr != "" {
end, err = time.Parse(time.RFC3339, endStr)
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid end time format (use RFC3339): "+err.Error())
return
}
} else {
end = time.Now()
}
// Validate time range
if end.Before(start) {
respondError(w, http.StatusBadRequest, "End time must be after start time")
return
}
var hostFilter int64
if hostFilterStr != "" {
hostFilter, err = strconv.ParseInt(hostFilterStr, 10, 64)
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid host_id parameter: "+err.Error())
return
}
}
// Generate report
report, err := s.db.GetChangesReport(start, end, hostFilter)
if err != nil {
log.Printf("Error generating changes report: %v", err)
respondError(w, http.StatusInternalServerError, "Failed to generate report: "+err.Error())
return
}
respondJSON(w, http.StatusOK, report)
}

View File

@@ -460,3 +460,79 @@ type NotificationStatus struct {
RateLimitRemaining int `json:"rate_limit_remaining"`
RateLimitReset time.Time `json:"rate_limit_reset"`
}
// ChangesReport represents a summary of environment changes over a time period
type ChangesReport struct {
Period ReportPeriod `json:"period"`
Summary ReportSummary `json:"summary"`
NewContainers []ContainerChange `json:"new_containers"`
RemovedContainers []ContainerChange `json:"removed_containers"`
ImageUpdates []ImageUpdateChange `json:"image_updates"`
StateChanges []StateChange `json:"state_changes"`
TopRestarted []RestartSummary `json:"top_restarted"`
}
// ReportPeriod represents the time range for a report
type ReportPeriod struct {
Start time.Time `json:"start"`
End time.Time `json:"end"`
DurationHours int `json:"duration_hours"`
}
// ReportSummary contains aggregate statistics for a changes report
type ReportSummary struct {
TotalHosts int `json:"total_hosts"`
TotalContainers int `json:"total_containers"`
NewContainers int `json:"new_containers"`
RemovedContainers int `json:"removed_containers"`
ImageUpdates int `json:"image_updates"`
StateChanges int `json:"state_changes"`
Restarts int `json:"restarts"`
}
// ContainerChange represents a new or removed container event
type ContainerChange struct {
ContainerID string `json:"container_id"`
ContainerName string `json:"container_name"`
Image string `json:"image"`
HostID int64 `json:"host_id"`
HostName string `json:"host_name"`
Timestamp time.Time `json:"timestamp"` // first_seen or last_seen
State string `json:"state"`
IsTransient bool `json:"is_transient"` // true if container appeared and disappeared in same period
}
// ImageUpdateChange represents an image update event
type ImageUpdateChange struct {
ContainerID string `json:"container_id"`
ContainerName string `json:"container_name"`
HostID int64 `json:"host_id"`
HostName string `json:"host_name"`
OldImage string `json:"old_image"`
NewImage string `json:"new_image"`
OldImageID string `json:"old_image_id"`
NewImageID string `json:"new_image_id"`
UpdatedAt time.Time `json:"updated_at"`
}
// StateChange represents a container state transition event
type StateChange struct {
ContainerID string `json:"container_id"`
ContainerName string `json:"container_name"`
HostID int64 `json:"host_id"`
HostName string `json:"host_name"`
OldState string `json:"old_state"`
NewState string `json:"new_state"`
ChangedAt time.Time `json:"changed_at"`
}
// RestartSummary represents containers with the most restarts
type RestartSummary struct {
ContainerID string `json:"container_id"`
ContainerName string `json:"container_name"`
HostID int64 `json:"host_id"`
HostName string `json:"host_name"`
RestartCount int `json:"restart_count"`
CurrentState string `json:"current_state"`
Image string `json:"image"`
}

View File

@@ -1622,3 +1622,491 @@ func (db *DB) GetCurrentStatsForAllContainers() ([]models.Container, error) {
return containers, rows.Err()
}
// parseTimestamp parses various timestamp formats from SQLite
func parseTimestamp(timestampStr string) (time.Time, error) {
// Try various formats that SQLite might use
formats := []string{
"2006-01-02 15:04:05.999999999-07:00",
"2006-01-02 15:04:05.999999999",
"2006-01-02 15:04:05",
"2006-01-02T15:04:05.999999999Z07:00",
"2006-01-02T15:04:05.999999999Z",
"2006-01-02T15:04:05Z",
time.RFC3339Nano,
time.RFC3339,
}
var lastErr error
for _, format := range formats {
t, err := time.Parse(format, timestampStr)
if err == nil {
return t, nil
}
lastErr = err
}
return time.Time{}, lastErr
}
// GetChangesReport generates a comprehensive environment change report for a time period
func (db *DB) GetChangesReport(start, end time.Time, hostFilter int64) (*models.ChangesReport, error) {
report := &models.ChangesReport{
Period: models.ReportPeriod{
Start: start,
End: end,
DurationHours: int(end.Sub(start).Hours()),
},
NewContainers: make([]models.ContainerChange, 0),
RemovedContainers: make([]models.ContainerChange, 0),
ImageUpdates: make([]models.ImageUpdateChange, 0),
StateChanges: make([]models.StateChange, 0),
TopRestarted: make([]models.RestartSummary, 0),
}
// Build WHERE clause for host filtering
hostFilterClause := ""
hostFilterArgs := []interface{}{start, end}
if hostFilter > 0 {
hostFilterClause = " AND c.host_id = ?"
hostFilterArgs = append(hostFilterArgs, hostFilter)
}
// 1. Query for new containers (first seen in period)
// Note: We group by NAME to detect when a container name first appeared,
// not by ID since containers get new IDs on recreation.
// Only includes containers from enabled hosts.
newContainersQuery := `
WITH first_appearances AS (
SELECT
c.name as container_name,
c.host_id,
c.host_name,
MIN(c.scanned_at) as first_seen
FROM containers c
INNER JOIN hosts h ON c.host_id = h.id
WHERE h.enabled = 1` + hostFilterClause + `
GROUP BY c.name, c.host_id, c.host_name
),
latest_state AS (
SELECT
c.id as container_id,
c.name as container_name,
c.image,
c.state,
c.host_id,
c.scanned_at,
ROW_NUMBER() OVER (PARTITION BY c.name, c.host_id ORDER BY c.scanned_at DESC) as rn
FROM containers c
INNER JOIN first_appearances f ON c.name = f.container_name AND c.host_id = f.host_id
WHERE c.scanned_at >= f.first_seen
)
SELECT ls.container_id, ls.container_name, ls.image, f.host_id, f.host_name, f.first_seen, ls.state
FROM first_appearances f
INNER JOIN latest_state ls ON f.container_name = ls.container_name AND f.host_id = ls.host_id
WHERE f.first_seen BETWEEN ? AND ?
AND ls.rn = 1
ORDER BY f.first_seen DESC
LIMIT 100
`
rows, err := db.conn.Query(newContainersQuery, append([]interface{}{start, end}, hostFilterArgs[2:]...)...)
if err != nil {
return nil, fmt.Errorf("failed to query new containers: %w", err)
}
defer rows.Close()
for rows.Next() {
var c models.ContainerChange
var timestampStr string
if err := rows.Scan(&c.ContainerID, &c.ContainerName, &c.Image, &c.HostID, &c.HostName, &timestampStr, &c.State); err != nil {
return nil, err
}
// Parse timestamp
c.Timestamp, err = parseTimestamp(timestampStr)
if err != nil {
log.Printf("Warning: failed to parse timestamp '%s': %v", timestampStr, err)
}
report.NewContainers = append(report.NewContainers, c)
}
if err = rows.Err(); err != nil {
return nil, err
}
// 2. Query for removed containers (seen during period, but not present at period end)
// Note: Group by NAME to show containers that disappeared, regardless of ID changes
// A container is "removed" if:
// - It was seen at least once BEFORE the period end
// - It is NOT seen at or after the period end (currently missing)
// Only includes containers from enabled hosts.
removedContainersQuery := `
WITH last_appearances AS (
SELECT
c.name as container_name,
c.host_id,
c.host_name,
MAX(c.scanned_at) as last_seen
FROM containers c
INNER JOIN hosts h ON c.host_id = h.id
WHERE h.enabled = 1` + hostFilterClause + `
GROUP BY c.name, c.host_id, c.host_name
),
final_state AS (
SELECT
c.id as container_id,
c.name as container_name,
c.image,
c.state,
c.host_id,
c.scanned_at,
ROW_NUMBER() OVER (PARTITION BY c.name, c.host_id ORDER BY c.scanned_at DESC) as rn
FROM containers c
INNER JOIN last_appearances l ON c.name = l.container_name AND c.host_id = l.host_id
WHERE c.scanned_at = l.last_seen
)
SELECT fs.container_id, fs.container_name, fs.image, l.host_id, l.host_name, l.last_seen, fs.state
FROM last_appearances l
INNER JOIN final_state fs ON l.container_name = fs.container_name AND l.host_id = fs.host_id
WHERE l.last_seen < ?
AND NOT EXISTS (
SELECT 1 FROM containers c2
WHERE c2.name = l.container_name
AND c2.host_id = l.host_id
AND c2.scanned_at >= ?
)
AND fs.rn = 1
ORDER BY l.last_seen DESC
LIMIT 100
`
rows, err = db.conn.Query(removedContainersQuery, append([]interface{}{end, end}, hostFilterArgs[2:]...)...)
if err != nil {
return nil, fmt.Errorf("failed to query removed containers: %w", err)
}
defer rows.Close()
for rows.Next() {
var c models.ContainerChange
var timestampStr string
if err := rows.Scan(&c.ContainerID, &c.ContainerName, &c.Image, &c.HostID, &c.HostName, &timestampStr, &c.State); err != nil {
return nil, err
}
// Parse timestamp
c.Timestamp, err = parseTimestamp(timestampStr)
if err != nil {
log.Printf("Warning: failed to parse timestamp '%s': %v", timestampStr, err)
}
report.RemovedContainers = append(report.RemovedContainers, c)
}
if err = rows.Err(); err != nil {
return nil, err
}
// 3. Query for image updates (using LAG window function)
// Note: We partition by container NAME, not ID, because containers get new IDs when recreated.
// This detects when a container with the same name is recreated with a different image.
// Only includes containers from enabled hosts.
imageUpdatesQuery := `
WITH image_changes AS (
SELECT
c.id as container_id,
c.name as container_name,
c.host_id,
c.host_name,
c.image,
c.image_id,
c.scanned_at,
LAG(c.image) OVER (PARTITION BY c.name, c.host_id ORDER BY c.scanned_at) as prev_image,
LAG(c.image_id) OVER (PARTITION BY c.name, c.host_id ORDER BY c.scanned_at) as prev_image_id
FROM containers c
INNER JOIN hosts h ON c.host_id = h.id
WHERE h.enabled = 1` + hostFilterClause + `
)
SELECT container_id, container_name, host_id, host_name,
prev_image, image, prev_image_id, image_id, scanned_at
FROM image_changes
WHERE prev_image_id IS NOT NULL
AND image_id != prev_image_id
AND scanned_at BETWEEN ? AND ?
ORDER BY scanned_at DESC
LIMIT 100
`
// Build args for image updates query: [hostFilter (if any), start, end]
imageUpdateArgs := []interface{}{}
if hostFilter > 0 {
imageUpdateArgs = append(imageUpdateArgs, hostFilter)
}
imageUpdateArgs = append(imageUpdateArgs, start, end)
rows, err = db.conn.Query(imageUpdatesQuery, imageUpdateArgs...)
if err != nil {
return nil, fmt.Errorf("failed to query image updates: %w", err)
}
defer rows.Close()
for rows.Next() {
var u models.ImageUpdateChange
var timestampStr string
if err := rows.Scan(&u.ContainerID, &u.ContainerName, &u.HostID, &u.HostName,
&u.OldImage, &u.NewImage, &u.OldImageID, &u.NewImageID, &timestampStr); err != nil {
return nil, err
}
// Parse timestamp
u.UpdatedAt, err = parseTimestamp(timestampStr)
if err != nil {
log.Printf("Warning: failed to parse timestamp '%s': %v", timestampStr, err)
}
report.ImageUpdates = append(report.ImageUpdates, u)
}
if err = rows.Err(); err != nil {
return nil, err
}
// 4. Query for state changes (using LAG window function)
// Note: We partition by container NAME, not ID, to track state across container recreations.
// Only includes containers from enabled hosts.
stateChangesQuery := `
WITH state_transitions AS (
SELECT
c.id as container_id,
c.name as container_name,
c.host_id,
c.host_name,
c.state,
c.scanned_at,
LAG(c.state) OVER (PARTITION BY c.name, c.host_id ORDER BY c.scanned_at) as prev_state
FROM containers c
INNER JOIN hosts h ON c.host_id = h.id
WHERE h.enabled = 1` + hostFilterClause + `
)
SELECT container_id, container_name, host_id, host_name,
prev_state, state, scanned_at
FROM state_transitions
WHERE prev_state IS NOT NULL
AND state != prev_state
AND scanned_at BETWEEN ? AND ?
ORDER BY scanned_at DESC
LIMIT 100
`
// Build args for state changes query: [hostFilter (if any), start, end]
stateChangeArgs := []interface{}{}
if hostFilter > 0 {
stateChangeArgs = append(stateChangeArgs, hostFilter)
}
stateChangeArgs = append(stateChangeArgs, start, end)
rows, err = db.conn.Query(stateChangesQuery, stateChangeArgs...)
if err != nil {
return nil, fmt.Errorf("failed to query state changes: %w", err)
}
defer rows.Close()
for rows.Next() {
var s models.StateChange
var timestampStr string
if err := rows.Scan(&s.ContainerID, &s.ContainerName, &s.HostID, &s.HostName,
&s.OldState, &s.NewState, &timestampStr); err != nil {
return nil, err
}
// Parse timestamp
s.ChangedAt, err = parseTimestamp(timestampStr)
if err != nil {
log.Printf("Warning: failed to parse timestamp '%s': %v", timestampStr, err)
}
report.StateChanges = append(report.StateChanges, s)
}
if err = rows.Err(); err != nil {
return nil, err
}
// 5. Query for top restarted/active containers (counting state changes, not scans)
// Build query dynamically based on host filter
// Only includes containers from enabled hosts.
// Groups by NAME to track activity across container recreations.
var topRestartedQuery string
if hostFilter > 0 {
topRestartedQuery = `
WITH state_changes AS (
SELECT
c.name as container_name,
c.host_id,
c.host_name,
c.image,
c.state,
c.scanned_at,
LAG(c.state) OVER (PARTITION BY c.name, c.host_id ORDER BY c.scanned_at) as prev_state
FROM containers c
INNER JOIN hosts h ON c.host_id = h.id
WHERE c.scanned_at BETWEEN ? AND ?
AND c.host_id = ?
AND h.enabled = 1
),
activity_counts AS (
SELECT
container_name,
host_id,
host_name,
MAX(image) as image,
MAX(state) as current_state,
COUNT(CASE WHEN prev_state IS NOT NULL AND state != prev_state THEN 1 END) as change_count
FROM state_changes
GROUP BY container_name, host_id, host_name
HAVING change_count > 0
),
latest_container_id AS (
SELECT
c.name,
c.host_id,
MAX(c.id) as container_id
FROM containers c
WHERE c.scanned_at BETWEEN ? AND ?
AND c.host_id = ?
GROUP BY c.name, c.host_id
)
SELECT
lci.container_id,
ac.container_name,
ac.host_id,
ac.host_name,
ac.image,
ac.change_count as restart_count,
ac.current_state
FROM activity_counts ac
INNER JOIN latest_container_id lci ON ac.container_name = lci.name AND ac.host_id = lci.host_id
ORDER BY ac.change_count DESC
LIMIT 20
`
} else {
topRestartedQuery = `
WITH state_changes AS (
SELECT
c.name as container_name,
c.host_id,
c.host_name,
c.image,
c.state,
c.scanned_at,
LAG(c.state) OVER (PARTITION BY c.name, c.host_id ORDER BY c.scanned_at) as prev_state
FROM containers c
INNER JOIN hosts h ON c.host_id = h.id
WHERE c.scanned_at BETWEEN ? AND ?
AND h.enabled = 1
),
activity_counts AS (
SELECT
container_name,
host_id,
host_name,
MAX(image) as image,
MAX(state) as current_state,
COUNT(CASE WHEN prev_state IS NOT NULL AND state != prev_state THEN 1 END) as change_count
FROM state_changes
GROUP BY container_name, host_id, host_name
HAVING change_count > 0
),
latest_container_id AS (
SELECT
c.name,
c.host_id,
MAX(c.id) as container_id
FROM containers c
WHERE c.scanned_at BETWEEN ? AND ?
GROUP BY c.name, c.host_id
)
SELECT
lci.container_id,
ac.container_name,
ac.host_id,
ac.host_name,
ac.image,
ac.change_count as restart_count,
ac.current_state
FROM activity_counts ac
INNER JOIN latest_container_id lci ON ac.container_name = lci.name AND ac.host_id = lci.host_id
ORDER BY ac.change_count DESC
LIMIT 20
`
}
// Build args for query (need start/end twice plus host filter twice if applicable)
topRestartArgs := []interface{}{start, end}
if hostFilter > 0 {
topRestartArgs = append(topRestartArgs, hostFilter)
}
topRestartArgs = append(topRestartArgs, start, end)
if hostFilter > 0 {
topRestartArgs = append(topRestartArgs, hostFilter)
}
rows, err = db.conn.Query(topRestartedQuery, topRestartArgs...)
if err != nil {
return nil, fmt.Errorf("failed to query top restarted: %w", err)
}
defer rows.Close()
for rows.Next() {
var r models.RestartSummary
if err := rows.Scan(&r.ContainerID, &r.ContainerName, &r.HostID, &r.HostName,
&r.Image, &r.RestartCount, &r.CurrentState); err != nil {
return nil, err
}
report.TopRestarted = append(report.TopRestarted, r)
}
if err = rows.Err(); err != nil {
return nil, err
}
// 6. Cross-check for transient containers (appeared and disappeared in same period)
// Build a map of containers (name+host_id) that appear in both New and Removed sections
transientMap := make(map[string]bool)
// First pass: identify transient containers
for _, newContainer := range report.NewContainers {
key := fmt.Sprintf("%s-%d", newContainer.ContainerName, newContainer.HostID)
for _, removedContainer := range report.RemovedContainers {
removedKey := fmt.Sprintf("%s-%d", removedContainer.ContainerName, removedContainer.HostID)
if key == removedKey {
transientMap[key] = true
break
}
}
}
// Second pass: mark containers as transient
for i := range report.NewContainers {
key := fmt.Sprintf("%s-%d", report.NewContainers[i].ContainerName, report.NewContainers[i].HostID)
if transientMap[key] {
report.NewContainers[i].IsTransient = true
}
}
for i := range report.RemovedContainers {
key := fmt.Sprintf("%s-%d", report.RemovedContainers[i].ContainerName, report.RemovedContainers[i].HostID)
if transientMap[key] {
report.RemovedContainers[i].IsTransient = true
}
}
// 7. Build summary statistics
report.Summary = models.ReportSummary{
NewContainers: len(report.NewContainers),
RemovedContainers: len(report.RemovedContainers),
ImageUpdates: len(report.ImageUpdates),
StateChanges: len(report.StateChanges),
Restarts: len(report.TopRestarted),
}
// Get total hosts and containers
hostCountQuery := `SELECT COUNT(DISTINCT host_id) FROM containers WHERE scanned_at BETWEEN ? AND ?` + hostFilterClause
if err := db.conn.QueryRow(hostCountQuery, hostFilterArgs...).Scan(&report.Summary.TotalHosts); err != nil {
return nil, fmt.Errorf("failed to count hosts: %w", err)
}
containerCountQuery := `SELECT COUNT(DISTINCT id || '-' || host_id) FROM containers WHERE scanned_at BETWEEN ? AND ?` + hostFilterClause
if err := db.conn.QueryRow(containerCountQuery, hostFilterArgs...).Scan(&report.Summary.TotalContainers); err != nil {
return nil, fmt.Errorf("failed to count containers: %w", err)
}
return report, nil
}

View File

@@ -45,10 +45,11 @@ func TestHostCRUD(t *testing.T) {
Enabled: true,
}
err := db.SaveHost(host)
hostID, err := db.AddHost(*host)
if err != nil {
t.Fatalf("SaveHost failed: %v", err)
t.Fatalf("AddHost failed: %v", err)
}
host.ID = hostID
if host.ID == 0 {
t.Error("Expected host ID to be set after save")
@@ -80,9 +81,9 @@ func TestHostCRUD(t *testing.T) {
savedHost.Address = "agent://remote-host:9876"
savedHost.CollectStats = false
err = db.SaveHost(savedHost)
err = db.UpdateHost(savedHost)
if err != nil {
t.Fatalf("SaveHost (update) failed: %v", err)
t.Fatalf("UpdateHost failed: %v", err)
}
// Verify update
@@ -130,7 +131,8 @@ func TestMultipleHosts(t *testing.T) {
}
for _, host := range hosts {
if err := db.SaveHost(host); err != nil {
_, err := db.AddHost(*host)
if err != nil {
t.Fatalf("Failed to save host %s: %v", host.Name, err)
}
}
@@ -163,7 +165,8 @@ func TestContainerHistory(t *testing.T) {
// Create a host first
host := &models.Host{Name: "test-host", Address: "unix:///", Enabled: true}
if err := db.SaveHost(host); err != nil {
_, err := db.AddHost(*host)
if err != nil {
t.Fatalf("Failed to save host: %v", err)
}
@@ -238,7 +241,8 @@ func TestContainerStats(t *testing.T) {
// Create host
host := &models.Host{Name: "stats-host", Address: "unix:///", Enabled: true}
if err := db.SaveHost(host); err != nil {
_, err := db.AddHost(*host)
if err != nil {
t.Fatalf("Failed to save host: %v", err)
}
@@ -296,7 +300,8 @@ func TestStatsAggregation(t *testing.T) {
// Create host
host := &models.Host{Name: "agg-host", Address: "unix:///", Enabled: true}
if err := db.SaveHost(host); err != nil {
_, err := db.AddHost(*host)
if err != nil {
t.Fatalf("Failed to save host: %v", err)
}
@@ -394,7 +399,8 @@ func TestGetContainerLifecycleEvents(t *testing.T) {
// Create host
host := &models.Host{Name: "event-host", Address: "unix:///", Enabled: true}
if err := db.SaveHost(host); err != nil {
_, err := db.AddHost(*host)
if err != nil {
t.Fatalf("Failed to save host: %v", err)
}
@@ -495,7 +501,8 @@ func TestConcurrentAccess(t *testing.T) {
// Create host
host := &models.Host{Name: "concurrent-host", Address: "unix:///", Enabled: true}
if err := db.SaveHost(host); err != nil {
_, err := db.AddHost(*host)
if err != nil {
t.Fatalf("Failed to save host: %v", err)
}

View File

@@ -0,0 +1,465 @@
package storage
import (
"os"
"testing"
"time"
"github.com/container-census/container-census/internal/models"
)
func TestGetChangesReport(t *testing.T) {
// Create a temporary database
dbPath := "/tmp/test_reports.db"
defer os.Remove(dbPath)
db, err := New(dbPath)
if err != nil {
t.Fatalf("Failed to create database: %v", err)
}
defer db.Close()
// Setup test data
setupReportTestData(t, db)
// Test cases
tests := []struct {
name string
start time.Time
end time.Time
hostFilter int64
wantError bool
validate func(t *testing.T, report *models.ChangesReport)
}{
{
name: "Last 7 days - no filter",
start: time.Now().Add(-7 * 24 * time.Hour),
end: time.Now(),
hostFilter: 0,
wantError: false,
validate: func(t *testing.T, report *models.ChangesReport) {
if report == nil {
t.Fatal("Expected non-nil report")
}
if report.Period.DurationHours != 168 {
t.Errorf("Expected 168 hours, got %d", report.Period.DurationHours)
}
},
},
{
name: "Last 30 days - no filter",
start: time.Now().Add(-30 * 24 * time.Hour),
end: time.Now(),
hostFilter: 0,
wantError: false,
validate: func(t *testing.T, report *models.ChangesReport) {
if report == nil {
t.Fatal("Expected non-nil report")
}
if report.Summary.TotalHosts < 0 {
t.Errorf("Expected non-negative host count, got %d", report.Summary.TotalHosts)
}
},
},
{
name: "With host filter",
start: time.Now().Add(-7 * 24 * time.Hour),
end: time.Now(),
hostFilter: 1,
wantError: false,
validate: func(t *testing.T, report *models.ChangesReport) {
if report == nil {
t.Fatal("Expected non-nil report")
}
// All containers should be from host 1
for _, c := range report.NewContainers {
if c.HostID != 1 {
t.Errorf("Expected host_id 1, got %d", c.HostID)
}
}
for _, c := range report.RemovedContainers {
if c.HostID != 1 {
t.Errorf("Expected host_id 1, got %d", c.HostID)
}
}
},
},
{
name: "Empty time range",
start: time.Now().Add(1 * time.Hour),
end: time.Now().Add(2 * time.Hour),
hostFilter: 0,
wantError: false,
validate: func(t *testing.T, report *models.ChangesReport) {
if report == nil {
t.Fatal("Expected non-nil report")
}
// Should have zero changes
if report.Summary.NewContainers != 0 {
t.Errorf("Expected 0 new containers, got %d", report.Summary.NewContainers)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
report, err := db.GetChangesReport(tt.start, tt.end, tt.hostFilter)
if (err != nil) != tt.wantError {
t.Errorf("GetChangesReport() error = %v, wantError %v", err, tt.wantError)
return
}
if !tt.wantError && tt.validate != nil {
tt.validate(t, report)
}
})
}
}
func TestGetChangesReport_NewContainers(t *testing.T) {
dbPath := "/tmp/test_reports_new.db"
defer os.Remove(dbPath)
db, err := New(dbPath)
if err != nil {
t.Fatalf("Failed to create database: %v", err)
}
defer db.Close()
// Create host
_, err = db.conn.Exec(`INSERT INTO hosts (id, name, address, enabled) VALUES (1, 'test-host', 'unix:///var/run/docker.sock', 1)`)
if err != nil {
t.Fatalf("Failed to insert host: %v", err)
}
// Insert a container that appeared 3 days ago
threeDaysAgo := time.Now().Add(-3 * 24 * time.Hour)
_, err = db.conn.Exec(`
INSERT INTO containers (id, name, image, image_id, state, status, created, host_id, host_name, scanned_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "abc123", "new-container", "nginx:latest", "sha256:abc123", "running", "Up 1 hour", threeDaysAgo, 1, "test-host", threeDaysAgo)
if err != nil {
t.Fatalf("Failed to insert container: %v", err)
}
// Test: Should find the new container in a 7-day window
start := time.Now().Add(-7 * 24 * time.Hour)
end := time.Now()
report, err := db.GetChangesReport(start, end, 0)
if err != nil {
t.Fatalf("GetChangesReport failed: %v", err)
}
if len(report.NewContainers) != 1 {
t.Errorf("Expected 1 new container, got %d", len(report.NewContainers))
}
if len(report.NewContainers) > 0 {
c := report.NewContainers[0]
if c.ContainerName != "new-container" {
t.Errorf("Expected container name 'new-container', got '%s'", c.ContainerName)
}
if c.Image != "nginx:latest" {
t.Errorf("Expected image 'nginx:latest', got '%s'", c.Image)
}
if c.State != "running" {
t.Errorf("Expected state 'running', got '%s'", c.State)
}
}
}
func TestGetChangesReport_RemovedContainers(t *testing.T) {
dbPath := "/tmp/test_reports_removed.db"
defer os.Remove(dbPath)
db, err := New(dbPath)
if err != nil {
t.Fatalf("Failed to create database: %v", err)
}
defer db.Close()
// Create host
_, err = db.conn.Exec(`INSERT INTO hosts (id, name, address, enabled) VALUES (1, 'test-host', 'unix:///var/run/docker.sock', 1)`)
if err != nil {
t.Fatalf("Failed to insert host: %v", err)
}
// Insert a container that was last seen 10 days ago
tenDaysAgo := time.Now().Add(-10 * 24 * time.Hour)
_, err = db.conn.Exec(`
INSERT INTO containers (id, name, image, image_id, state, status, created, host_id, host_name, scanned_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "old123", "removed-container", "redis:6", "sha256:old123", "exited", "Exited (0)", tenDaysAgo, 1, "test-host", tenDaysAgo)
if err != nil {
t.Fatalf("Failed to insert container: %v", err)
}
// Test: Should find the removed container (last seen before 7-day window)
start := time.Now().Add(-7 * 24 * time.Hour)
end := time.Now()
report, err := db.GetChangesReport(start, end, 0)
if err != nil {
t.Fatalf("GetChangesReport failed: %v", err)
}
if len(report.RemovedContainers) != 1 {
t.Errorf("Expected 1 removed container, got %d", len(report.RemovedContainers))
}
if len(report.RemovedContainers) > 0 {
c := report.RemovedContainers[0]
if c.ContainerName != "removed-container" {
t.Errorf("Expected container name 'removed-container', got '%s'", c.ContainerName)
}
}
}
func TestGetChangesReport_ImageUpdates(t *testing.T) {
dbPath := "/tmp/test_reports_images.db"
defer os.Remove(dbPath)
db, err := New(dbPath)
if err != nil {
t.Fatalf("Failed to create database: %v", err)
}
defer db.Close()
// Create host
_, err = db.conn.Exec(`INSERT INTO hosts (id, name, address, enabled) VALUES (1, 'test-host', 'unix:///var/run/docker.sock', 1)`)
if err != nil {
t.Fatalf("Failed to insert host: %v", err)
}
// Insert container with old image (5 days ago)
fiveDaysAgo := time.Now().Add(-5 * 24 * time.Hour)
_, err = db.conn.Exec(`
INSERT INTO containers (id, name, image, image_id, state, status, created, host_id, host_name, scanned_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "web123", "web-app", "nginx:1.24", "sha256:old", "running", "Up", fiveDaysAgo, 1, "test-host", fiveDaysAgo)
if err != nil {
t.Fatalf("Failed to insert old container: %v", err)
}
// Insert same container with new image (2 days ago)
twoDaysAgo := time.Now().Add(-2 * 24 * time.Hour)
_, err = db.conn.Exec(`
INSERT INTO containers (id, name, image, image_id, state, status, created, host_id, host_name, scanned_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "web123", "web-app", "nginx:1.25", "sha256:new", "running", "Up", twoDaysAgo, 1, "test-host", twoDaysAgo)
if err != nil {
t.Fatalf("Failed to insert updated container: %v", err)
}
// Test: Should detect image update
start := time.Now().Add(-7 * 24 * time.Hour)
end := time.Now()
report, err := db.GetChangesReport(start, end, 0)
if err != nil {
t.Fatalf("GetChangesReport failed: %v", err)
}
if len(report.ImageUpdates) != 1 {
t.Errorf("Expected 1 image update, got %d", len(report.ImageUpdates))
}
if len(report.ImageUpdates) > 0 {
u := report.ImageUpdates[0]
if u.ContainerName != "web-app" {
t.Errorf("Expected container name 'web-app', got '%s'", u.ContainerName)
}
if u.OldImage != "nginx:1.24" {
t.Errorf("Expected old image 'nginx:1.24', got '%s'", u.OldImage)
}
if u.NewImage != "nginx:1.25" {
t.Errorf("Expected new image 'nginx:1.25', got '%s'", u.NewImage)
}
if u.OldImageID != "sha256:old" {
t.Errorf("Expected old image ID 'sha256:old', got '%s'", u.OldImageID)
}
if u.NewImageID != "sha256:new" {
t.Errorf("Expected new image ID 'sha256:new', got '%s'", u.NewImageID)
}
}
}
func TestGetChangesReport_StateChanges(t *testing.T) {
dbPath := "/tmp/test_reports_states.db"
defer os.Remove(dbPath)
db, err := New(dbPath)
if err != nil {
t.Fatalf("Failed to create database: %v", err)
}
defer db.Close()
// Create host
_, err = db.conn.Exec(`INSERT INTO hosts (id, name, address, enabled) VALUES (1, 'test-host', 'unix:///var/run/docker.sock', 1)`)
if err != nil {
t.Fatalf("Failed to insert host: %v", err)
}
// Insert container in running state (4 days ago)
fourDaysAgo := time.Now().Add(-4 * 24 * time.Hour)
_, err = db.conn.Exec(`
INSERT INTO containers (id, name, image, image_id, state, status, created, host_id, host_name, scanned_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "app123", "my-app", "node:18", "sha256:xyz", "running", "Up", fourDaysAgo, 1, "test-host", fourDaysAgo)
if err != nil {
t.Fatalf("Failed to insert running container: %v", err)
}
// Insert same container in stopped state (2 days ago)
twoDaysAgo := time.Now().Add(-2 * 24 * time.Hour)
_, err = db.conn.Exec(`
INSERT INTO containers (id, name, image, image_id, state, status, created, host_id, host_name, scanned_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "app123", "my-app", "node:18", "sha256:xyz", "exited", "Exited (0)", twoDaysAgo, 1, "test-host", twoDaysAgo)
if err != nil {
t.Fatalf("Failed to insert stopped container: %v", err)
}
// Test: Should detect state change
start := time.Now().Add(-7 * 24 * time.Hour)
end := time.Now()
report, err := db.GetChangesReport(start, end, 0)
if err != nil {
t.Fatalf("GetChangesReport failed: %v", err)
}
if len(report.StateChanges) != 1 {
t.Errorf("Expected 1 state change, got %d", len(report.StateChanges))
}
if len(report.StateChanges) > 0 {
s := report.StateChanges[0]
if s.ContainerName != "my-app" {
t.Errorf("Expected container name 'my-app', got '%s'", s.ContainerName)
}
if s.OldState != "running" {
t.Errorf("Expected old state 'running', got '%s'", s.OldState)
}
if s.NewState != "exited" {
t.Errorf("Expected new state 'exited', got '%s'", s.NewState)
}
}
}
func TestGetChangesReport_SummaryAccuracy(t *testing.T) {
dbPath := "/tmp/test_reports_summary.db"
defer os.Remove(dbPath)
db, err := New(dbPath)
if err != nil {
t.Fatalf("Failed to create database: %v", err)
}
defer db.Close()
// Create 2 hosts
_, err = db.conn.Exec(`INSERT INTO hosts (id, name, address, enabled) VALUES (1, 'host1', 'unix:///var/run/docker.sock', 1)`)
if err != nil {
t.Fatalf("Failed to insert host1: %v", err)
}
_, err = db.conn.Exec(`INSERT INTO hosts (id, name, address, enabled) VALUES (2, 'host2', 'tcp://host2:2376', 1)`)
if err != nil {
t.Fatalf("Failed to insert host2: %v", err)
}
// Add containers across both hosts
now := time.Now()
containers := []struct {
id string
name string
hostID int64
hostName string
scanTime time.Time
}{
{"c1", "container1", 1, "host1", now.Add(-5 * 24 * time.Hour)},
{"c2", "container2", 1, "host1", now.Add(-3 * 24 * time.Hour)},
{"c3", "container3", 2, "host2", now.Add(-4 * 24 * time.Hour)},
}
for _, c := range containers {
_, err = db.conn.Exec(`
INSERT INTO containers (id, name, image, image_id, state, status, created, host_id, host_name, scanned_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, c.id, c.name, "test:latest", "sha256:test", "running", "Up", c.scanTime, c.hostID, c.hostName, c.scanTime)
if err != nil {
t.Fatalf("Failed to insert container %s: %v", c.name, err)
}
}
// Test: Check summary counts
start := time.Now().Add(-7 * 24 * time.Hour)
end := time.Now()
report, err := db.GetChangesReport(start, end, 0)
if err != nil {
t.Fatalf("GetChangesReport failed: %v", err)
}
// Verify summary counts match array lengths
if report.Summary.NewContainers != len(report.NewContainers) {
t.Errorf("Summary.NewContainers (%d) != len(NewContainers) (%d)",
report.Summary.NewContainers, len(report.NewContainers))
}
if report.Summary.RemovedContainers != len(report.RemovedContainers) {
t.Errorf("Summary.RemovedContainers (%d) != len(RemovedContainers) (%d)",
report.Summary.RemovedContainers, len(report.RemovedContainers))
}
if report.Summary.ImageUpdates != len(report.ImageUpdates) {
t.Errorf("Summary.ImageUpdates (%d) != len(ImageUpdates) (%d)",
report.Summary.ImageUpdates, len(report.ImageUpdates))
}
if report.Summary.StateChanges != len(report.StateChanges) {
t.Errorf("Summary.StateChanges (%d) != len(StateChanges) (%d)",
report.Summary.StateChanges, len(report.StateChanges))
}
// Verify host count
if report.Summary.TotalHosts != 2 {
t.Errorf("Expected 2 total hosts, got %d", report.Summary.TotalHosts)
}
// Verify container count
if report.Summary.TotalContainers != 3 {
t.Errorf("Expected 3 total containers, got %d", report.Summary.TotalContainers)
}
}
// Helper function to setup test data
func setupReportTestData(t *testing.T, db *DB) {
// Create test hosts
_, err := db.conn.Exec(`INSERT INTO hosts (id, name, address, enabled) VALUES (1, 'test-host-1', 'unix:///var/run/docker.sock', 1)`)
if err != nil {
t.Fatalf("Failed to insert test host: %v", err)
}
// Insert some test containers at different times
times := []time.Time{
time.Now().Add(-10 * 24 * time.Hour),
time.Now().Add(-5 * 24 * time.Hour),
time.Now().Add(-2 * 24 * time.Hour),
}
for i, ts := range times {
_, err = db.conn.Exec(`
INSERT INTO containers (id, name, image, image_id, state, status, created, host_id, host_name, scanned_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
"container"+string(rune(i)),
"test-container-"+string(rune(i)),
"nginx:latest",
"sha256:test"+string(rune(i)),
"running",
"Up",
ts,
1,
"test-host-1",
ts,
)
if err != nil {
t.Fatalf("Failed to insert test container: %v", err)
}
}
}

361
scripts/cleanup-github.sh Executable file
View File

@@ -0,0 +1,361 @@
#!/bin/bash
# GitHub Cleanup Script
# Interactively delete old releases and packages
# Note: Not using set -e because interactive read commands can return non-zero
# which would cause script to exit prematurely
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Configuration
REPO="selfhosters-cc/container-census"
ORG="selfhosters-cc"
# Check if gh CLI is installed
if ! command -v gh &> /dev/null; then
echo -e "${RED}Error: GitHub CLI (gh) is not installed${NC}"
echo "Install with: sudo apt install gh"
exit 1
fi
# Check if authenticated
if ! gh auth status &> /dev/null; then
echo -e "${RED}Error: Not authenticated with GitHub${NC}"
echo "Run: gh auth login"
exit 1
fi
# Check if jq is installed
if ! command -v jq &> /dev/null; then
echo -e "${RED}Error: jq is not installed${NC}"
echo "Install with: sudo apt install jq"
exit 1
fi
echo -e "${BLUE}╔════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ GitHub Cleanup Script ║${NC}"
echo -e "${BLUE}║ Repository: ${REPO}${NC}"
echo -e "${BLUE}╚════════════════════════════════════════╝${NC}"
echo
# Function to cleanup releases
cleanup_releases() {
echo -e "${YELLOW}═══ GitHub Releases ═══${NC}"
echo
# Get all releases
releases=$(gh release list --repo "$REPO" --limit 1000 --json tagName,name,createdAt,isLatest | jq -r '.[] | "\(.tagName)|\(.name)|\(.createdAt)|\(.isLatest)"')
if [ -z "$releases" ]; then
echo -e "${YELLOW}No releases found${NC}"
return
fi
release_count=$(echo "$releases" | wc -l)
echo -e "${GREEN}Found $release_count releases${NC}"
echo
# Show options
echo "What would you like to do?"
echo " 1) Keep only the latest release (delete all others)"
echo " 2) Keep the latest N releases (interactive)"
echo " 3) Review each release interactively"
echo " 4) Skip release cleanup"
echo
read -p "Enter choice [1-4]: " choice
case $choice in
1)
echo
echo -e "${YELLOW}Keeping only the latest release...${NC}"
deleted=0
while IFS='|' read -r tag name created_at is_latest; do
if [ "$is_latest" != "true" ]; then
echo -e "${RED}Deleting: $tag - $name (created: $created_at)${NC}"
gh release delete "$tag" --repo "$REPO" --yes
((deleted++))
else
echo -e "${GREEN}Keeping: $tag - $name (LATEST)${NC}"
fi
done <<< "$releases"
echo
echo -e "${GREEN}Deleted $deleted releases${NC}"
;;
2)
echo
read -p "How many recent releases to keep? " keep_count
if ! [[ "$keep_count" =~ ^[0-9]+$ ]]; then
echo -e "${RED}Invalid number${NC}"
return
fi
echo
echo -e "${YELLOW}Keeping the latest $keep_count releases...${NC}"
deleted=0
index=0
while IFS='|' read -r tag name created_at is_latest; do
((index++))
if [ $index -le $keep_count ]; then
echo -e "${GREEN}Keeping: $tag - $name (created: $created_at)${NC}"
else
echo -e "${RED}Deleting: $tag - $name (created: $created_at)${NC}"
gh release delete "$tag" --repo "$REPO" --yes
((deleted++))
fi
done <<< "$releases"
echo
echo -e "${GREEN}Deleted $deleted releases${NC}"
;;
3)
echo
deleted=0
kept=0
while IFS='|' read -r tag name created_at is_latest; do
echo -e "${BLUE}────────────────────────────────${NC}"
echo -e "Tag: ${YELLOW}$tag${NC}"
echo -e "Name: $name"
echo -e "Created: $created_at"
if [ "$is_latest" = "true" ]; then
echo -e "Status: ${GREEN}LATEST${NC}"
fi
echo
read -p "Delete this release? [y/N]: " confirm </dev/tty
if [[ $confirm =~ ^[Yy]$ ]]; then
echo -e "${RED}Deleting...${NC}"
gh release delete "$tag" --repo "$REPO" --yes
((deleted++))
else
echo -e "${GREEN}Keeping${NC}"
((kept++))
fi
echo
done <<< "$releases"
echo -e "${GREEN}Deleted: $deleted | Kept: $kept${NC}"
;;
4)
echo -e "${YELLOW}Skipping release cleanup${NC}"
;;
*)
echo -e "${RED}Invalid choice${NC}"
;;
esac
}
# Function to cleanup packages
cleanup_packages() {
echo
echo -e "${YELLOW}═══ GitHub Packages (Docker Images) ═══${NC}"
echo
# Get package names
packages=$(gh api \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"/orgs/$ORG/packages?package_type=container" \
| jq -r '.[].name' | sort -u)
if [ -z "$packages" ]; then
echo -e "${YELLOW}No packages found${NC}"
return
fi
package_count=$(echo "$packages" | wc -l)
echo -e "${GREEN}Found $package_count packages:${NC}"
echo "$packages" | sed 's/^/ - /'
echo
# Process each package
for package in $packages; do
echo
echo -e "${BLUE}═══ Package: $package ═══${NC}"
# Get all versions for this package
versions=$(gh api \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"/orgs/$ORG/packages/container/$package/versions" \
| jq -r '.[] | "\(.id)|\(.name // "untagged")|\(.created_at)|\(.metadata.container.tags // [] | join(","))"')
if [ -z "$versions" ]; then
echo -e "${YELLOW}No versions found for $package${NC}"
continue
fi
version_count=$(echo "$versions" | wc -l)
echo -e "${GREEN}Found $version_count versions${NC}"
echo
# Show options for this package
echo "What would you like to do with $package?"
echo " 1) Keep only the latest version (delete all others)"
echo " 2) Keep the latest N versions (interactive)"
echo " 3) Review each version interactively"
echo " 4) Delete ALL versions of this package"
echo " 5) Skip this package"
echo
read -p "Enter choice [1-5]: " choice
case $choice in
1)
echo
echo -e "${YELLOW}Keeping only the latest version...${NC}"
deleted=0
index=0
while IFS='|' read -r id name created_at tags; do
((index++))
if [ $index -eq 1 ]; then
echo -e "${GREEN}Keeping: $name (tags: $tags, created: $created_at)${NC}"
else
echo -e "${RED}Deleting: $name (tags: $tags, created: $created_at)${NC}"
gh api \
--method DELETE \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"/orgs/$ORG/packages/container/$package/versions/$id"
((deleted++))
fi
done <<< "$versions"
echo
echo -e "${GREEN}Deleted $deleted versions${NC}"
;;
2)
echo
read -p "How many recent versions to keep? " keep_count
if ! [[ "$keep_count" =~ ^[0-9]+$ ]]; then
echo -e "${RED}Invalid number${NC}"
continue
fi
echo
echo -e "${YELLOW}Keeping the latest $keep_count versions...${NC}"
deleted=0
index=0
while IFS='|' read -r id name created_at tags; do
((index++))
if [ $index -le $keep_count ]; then
echo -e "${GREEN}Keeping: $name (tags: $tags, created: $created_at)${NC}"
else
echo -e "${RED}Deleting: $name (tags: $tags, created: $created_at)${NC}"
gh api \
--method DELETE \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"/orgs/$ORG/packages/container/$package/versions/$id"
((deleted++))
fi
done <<< "$versions"
echo
echo -e "${GREEN}Deleted $deleted versions${NC}"
;;
3)
echo
deleted=0
kept=0
while IFS='|' read -r id name created_at tags; do
echo -e "${BLUE}────────────────────────────────${NC}"
echo -e "Version: ${YELLOW}$name${NC}"
echo -e "Tags: $tags"
echo -e "Created: $created_at"
echo -e "ID: $id"
echo
read -p "Delete this version? [y/N]: " confirm </dev/tty
if [[ $confirm =~ ^[Yy]$ ]]; then
echo -e "${RED}Deleting...${NC}"
gh api \
--method DELETE \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"/orgs/$ORG/packages/container/$package/versions/$id"
((deleted++))
else
echo -e "${GREEN}Keeping${NC}"
((kept++))
fi
echo
done <<< "$versions"
echo -e "${GREEN}Deleted: $deleted | Kept: $kept${NC}"
;;
4)
echo
echo -e "${RED}⚠️ WARNING: This will delete ALL versions of $package${NC}"
read -p "Are you absolutely sure? Type 'DELETE' to confirm: " confirm
if [ "$confirm" = "DELETE" ]; then
echo -e "${RED}Deleting all versions...${NC}"
deleted=0
while IFS='|' read -r id name created_at tags; do
echo -e "${RED}Deleting: $name (tags: $tags)${NC}"
gh api \
--method DELETE \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"/orgs/$ORG/packages/container/$package/versions/$id"
((deleted++))
done <<< "$versions"
echo
echo -e "${GREEN}Deleted $deleted versions (entire package)${NC}"
else
echo -e "${YELLOW}Cancelled${NC}"
fi
;;
5)
echo -e "${YELLOW}Skipping $package${NC}"
;;
*)
echo -e "${RED}Invalid choice${NC}"
;;
esac
done
}
# Main menu
echo "What would you like to cleanup?"
echo " 1) GitHub Releases only"
echo " 2) GitHub Packages (Docker images) only"
echo " 3) Both releases and packages"
echo " 4) Exit"
echo
read -p "Enter choice [1-4]: " main_choice
case $main_choice in
1)
cleanup_releases
;;
2)
cleanup_packages
;;
3)
cleanup_releases
cleanup_packages
;;
4)
echo -e "${YELLOW}Exiting${NC}"
exit 0
;;
*)
echo -e "${RED}Invalid choice${NC}"
exit 1
;;
esac
echo
echo -e "${GREEN}✓ Cleanup complete!${NC}"

View File

@@ -250,10 +250,10 @@ function setupKeyboardShortcuts() {
}
// Tab switching with number keys
if (e.key >= '1' && e.key <= '9') {
if ((e.key >= '1' && e.key <= '9') || e.key === '0') {
e.preventDefault();
const tabs = ['containers', 'monitoring', 'images', 'graph', 'hosts', 'history', 'activity', 'notifications', 'settings'];
const tabIndex = parseInt(e.key) - 1;
const tabs = ['containers', 'monitoring', 'images', 'graph', 'hosts', 'history', 'activity', 'reports', 'notifications', 'settings'];
const tabIndex = e.key === '0' ? 9 : parseInt(e.key) - 1;
if (tabs[tabIndex]) {
switchTab(tabs[tabIndex]);
}
@@ -453,6 +453,8 @@ function switchTab(tab, updateHistory = true) {
loadContainerHistory();
} else if (tab === 'activity') {
loadActivityLog();
} else if (tab === 'reports') {
initializeReportsTab();
} else if (tab === 'settings') {
loadCollectors();
loadScannerSettings();
@@ -3760,15 +3762,25 @@ async function loadStatsData() {
console.log('Stats data received:', stats);
if (!stats || !Array.isArray(stats) || stats.length === 0) {
document.getElementById('statsContent').innerHTML = '<div class="loading">No stats data available for this time range. Stats collection may need more time to gather data.</div>';
document.getElementById('statsMessage').textContent = 'No stats data available for this time range. Stats collection may need more time to gather data.';
document.getElementById('statsMessage').className = 'loading';
document.getElementById('statsMessage').style.display = 'block';
document.getElementById('statsChartArea').style.display = 'none';
return;
}
// Hide message and show charts
document.getElementById('statsMessage').style.display = 'none';
document.getElementById('statsChartArea').style.display = 'block';
renderStatsCharts(stats);
updateStatsSummary(stats);
} catch (error) {
console.error('Error loading stats:', error);
document.getElementById('statsContent').innerHTML = `<div class="error">Failed to load stats data: ${error.message}</div>`;
document.getElementById('statsMessage').textContent = `Failed to load stats data: ${error.message}`;
document.getElementById('statsMessage').className = 'error';
document.getElementById('statsMessage').style.display = 'block';
document.getElementById('statsChartArea').style.display = 'none';
}
}
@@ -3784,7 +3796,8 @@ function renderStatsCharts(stats) {
const memoryLimitData = stats.map(s => (s.memory_limit || 0) / 1024 / 1024);
// CPU Chart
const cpuCtx = document.getElementById('cpuChart').getContext('2d');
const cpuCanvas = document.getElementById('cpuChart');
const cpuCtx = cpuCanvas.getContext('2d');
statsCharts.cpu = new Chart(cpuCtx, {
type: 'line',
data: {
@@ -3829,7 +3842,8 @@ function renderStatsCharts(stats) {
});
// Memory Chart
const memoryCtx = document.getElementById('memoryChart').getContext('2d');
const memoryCanvas = document.getElementById('memoryChart');
const memoryCtx = memoryCanvas.getContext('2d');
const datasets = [{
label: 'Memory Usage (MB)',
data: memoryData,
@@ -3917,3 +3931,610 @@ function updateStatsSummary(stats) {
document.getElementById('statsModal')?.addEventListener('click', (e) => {
if (e.target.classList.contains('modal')) closeStatsModal();
});
// ==================== REPORTS TAB ====================
let currentReport = null;
let changesTimelineChart = null;
// Initialize reports tab
function initializeReportsTab() {
// Set default date range to last 7 days
const end = new Date();
const start = new Date(end - 7 * 24 * 60 * 60 * 1000);
document.getElementById('reportStartDate').value = formatDateTimeLocal(start);
document.getElementById('reportEndDate').value = formatDateTimeLocal(end);
// Load hosts for filter
loadHostsForReportFilter();
// Set up event listeners
setupReportEventListeners();
}
// Set up event listeners for reports tab
function setupReportEventListeners() {
document.getElementById('generateReportBtn').addEventListener('click', generateReport);
document.getElementById('report7d').addEventListener('click', () => setReportRange(7));
document.getElementById('report30d').addEventListener('click', () => setReportRange(30));
document.getElementById('report90d').addEventListener('click', () => setReportRange(90));
document.getElementById('exportReportBtn').addEventListener('click', exportReport);
}
// Navigate to History tab with container filter
function goToContainerHistory(containerName, hostId) {
// Switch to history tab
switchTab('history');
// Set the search filter to the container name
const searchInput = document.getElementById('searchInput');
if (searchInput) {
searchInput.value = containerName;
}
// Set the host filter if provided
const hostFilter = document.getElementById('hostFilter');
if (hostFilter && hostId) {
hostFilter.value = hostId.toString();
}
// Apply the filters
setTimeout(() => {
applyCurrentFilters();
}, 100);
}
// Format date for datetime-local input
function formatDateTimeLocal(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
// Load hosts for report filter dropdown
async function loadHostsForReportFilter() {
try {
const response = await fetch('/api/hosts');
const data = await response.json();
const select = document.getElementById('reportHostFilter');
select.innerHTML = '<option value="">All Hosts</option>';
data.forEach(host => {
const option = document.createElement('option');
option.value = host.id;
option.textContent = host.name;
select.appendChild(option);
});
} catch (error) {
console.error('Failed to load hosts for report filter:', error);
}
}
// Set report date range preset
function setReportRange(days) {
const end = new Date();
const start = new Date(end - days * 24 * 60 * 60 * 1000);
document.getElementById('reportStartDate').value = formatDateTimeLocal(start);
document.getElementById('reportEndDate').value = formatDateTimeLocal(end);
}
// Generate report
async function generateReport() {
const startInput = document.getElementById('reportStartDate').value;
const endInput = document.getElementById('reportEndDate').value;
const hostFilter = document.getElementById('reportHostFilter').value;
if (!startInput || !endInput) {
alert('Please select both start and end dates');
return;
}
const start = new Date(startInput).toISOString();
const end = new Date(endInput).toISOString();
// Show loading, hide results and empty state
document.getElementById('reportLoading').style.display = 'block';
document.getElementById('reportResults').style.display = 'none';
document.getElementById('reportEmptyState').style.display = 'none';
try {
let url = `/api/reports/changes?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}`;
if (hostFilter) {
url += `&host_id=${hostFilter}`;
}
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
}
currentReport = await response.json();
renderReport(currentReport);
// Hide loading, show results
document.getElementById('reportLoading').style.display = 'none';
document.getElementById('reportResults').style.display = 'block';
} catch (error) {
console.error('Failed to generate report:', error);
alert('Failed to generate report: ' + error.message);
document.getElementById('reportLoading').style.display = 'none';
document.getElementById('reportEmptyState').style.display = 'block';
}
}
// Render report
function renderReport(report) {
// Render summary cards
renderReportSummary(report.summary);
// Render timeline chart
renderTimelineChart(report);
// Render details sections
renderNewContainers(report.new_containers);
renderRemovedContainers(report.removed_containers);
renderImageUpdates(report.image_updates);
renderStateChanges(report.state_changes);
renderTopRestarted(report.top_restarted);
}
// Render summary cards
function renderReportSummary(summary) {
const cardsHTML = `
<div class="stat-card">
<div class="stat-icon">🖥️</div>
<div class="stat-content">
<div class="stat-value">${summary.total_hosts}</div>
<div class="stat-label">Total Hosts</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">📦</div>
<div class="stat-content">
<div class="stat-value">${summary.total_containers}</div>
<div class="stat-label">Total Containers</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">🆕</div>
<div class="stat-content">
<div class="stat-value">${summary.new_containers}</div>
<div class="stat-label">New Containers</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">❌</div>
<div class="stat-content">
<div class="stat-value">${summary.removed_containers}</div>
<div class="stat-label">Removed</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">🔄</div>
<div class="stat-content">
<div class="stat-value">${summary.image_updates}</div>
<div class="stat-label">Image Updates</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">🔀</div>
<div class="stat-content">
<div class="stat-value">${summary.state_changes}</div>
<div class="stat-label">State Changes</div>
</div>
</div>
`;
document.getElementById('reportSummaryCards').innerHTML = cardsHTML;
}
// Render timeline chart
function renderTimelineChart(report) {
// Destroy existing chart if it exists
if (changesTimelineChart) {
changesTimelineChart.destroy();
}
// Aggregate changes by day
const changesByDay = {};
// Helper to get day key
const getDayKey = (timestamp) => {
const date = new Date(timestamp);
return date.toISOString().split('T')[0];
};
// Count new containers
report.new_containers.forEach(c => {
const day = getDayKey(c.timestamp);
if (!changesByDay[day]) changesByDay[day] = { new: 0, removed: 0, imageUpdates: 0, stateChanges: 0 };
changesByDay[day].new++;
});
// Count removed containers
report.removed_containers.forEach(c => {
const day = getDayKey(c.timestamp);
if (!changesByDay[day]) changesByDay[day] = { new: 0, removed: 0, imageUpdates: 0, stateChanges: 0 };
changesByDay[day].removed++;
});
// Count image updates
report.image_updates.forEach(u => {
const day = getDayKey(u.updated_at);
if (!changesByDay[day]) changesByDay[day] = { new: 0, removed: 0, imageUpdates: 0, stateChanges: 0 };
changesByDay[day].imageUpdates++;
});
// Count state changes
report.state_changes.forEach(s => {
const day = getDayKey(s.changed_at);
if (!changesByDay[day]) changesByDay[day] = { new: 0, removed: 0, imageUpdates: 0, stateChanges: 0 };
changesByDay[day].stateChanges++;
});
// Sort days
const days = Object.keys(changesByDay).sort();
const ctx = document.getElementById('changesTimelineChart').getContext('2d');
changesTimelineChart = new Chart(ctx, {
type: 'line',
data: {
labels: days.map(d => new Date(d).toLocaleDateString()),
datasets: [
{
label: 'New Containers',
data: days.map(d => changesByDay[d].new),
borderColor: '#2ecc71',
backgroundColor: 'rgba(46, 204, 113, 0.1)',
tension: 0.4
},
{
label: 'Removed Containers',
data: days.map(d => changesByDay[d].removed),
borderColor: '#e74c3c',
backgroundColor: 'rgba(231, 76, 60, 0.1)',
tension: 0.4
},
{
label: 'Image Updates',
data: days.map(d => changesByDay[d].imageUpdates),
borderColor: '#3498db',
backgroundColor: 'rgba(52, 152, 219, 0.1)',
tension: 0.4
},
{
label: 'State Changes',
data: days.map(d => changesByDay[d].stateChanges),
borderColor: '#f39c12',
backgroundColor: 'rgba(243, 156, 18, 0.1)',
tension: 0.4
}
]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: {
display: true,
position: 'bottom'
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
stepSize: 1
}
}
}
}
});
}
// Render new containers table
function renderNewContainers(containers) {
document.getElementById('newContainersCount').textContent = containers.length;
if (containers.length === 0) {
document.getElementById('newContainersTable').innerHTML = '<p class="empty-message">No new containers in this period</p>';
return;
}
const tableHTML = `
<table class="report-table">
<thead>
<tr>
<th>Container Name</th>
<th>Image</th>
<th>Host</th>
<th>First Seen</th>
<th>State</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${containers.map(c => `
<tr>
<td>
<code class="container-link" onclick="goToContainerHistory('${escapeHtml(c.container_name)}', ${c.host_id})" title="View in History">
${escapeHtml(c.container_name)} 🔗
</code>
${c.is_transient ? '<span class="transient-badge" title="This container appeared and disappeared within the reporting period">⚡ Transient</span>' : ''}
</td>
<td>${escapeHtml(c.image)}</td>
<td>${escapeHtml(c.host_name)}</td>
<td>${formatDateTime(c.timestamp)}</td>
<td><span class="status-badge status-${c.state}">${c.state}</span></td>
<td>
<button class="btn-icon" onclick="openStatsModal(${c.host_id}, '${escapeHtml(c.container_id)}', '${escapeHtml(c.container_name)}')" title="View Stats & Timeline">
📊
</button>
<button class="btn-icon" onclick="viewContainerTimeline(${c.host_id}, '${escapeHtml(c.container_id)}', '${escapeHtml(c.container_name)}')" title="View Lifecycle Timeline">
📜
</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
`;
document.getElementById('newContainersTable').innerHTML = tableHTML;
}
// Render removed containers table
function renderRemovedContainers(containers) {
document.getElementById('removedContainersCount').textContent = containers.length;
if (containers.length === 0) {
document.getElementById('removedContainersTable').innerHTML = '<p class="empty-message">No removed containers in this period</p>';
return;
}
const tableHTML = `
<table class="report-table">
<thead>
<tr>
<th>Container Name</th>
<th>Image</th>
<th>Host</th>
<th>Last Seen</th>
<th>Final State</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${containers.map(c => `
<tr>
<td>
<code class="container-link" onclick="goToContainerHistory('${escapeHtml(c.container_name)}', ${c.host_id})" title="View in History">
${escapeHtml(c.container_name)} 🔗
</code>
${c.is_transient ? '<span class="transient-badge" title="This container appeared and disappeared within the reporting period">⚡ Transient</span>' : ''}
</td>
<td>${escapeHtml(c.image)}</td>
<td>${escapeHtml(c.host_name)}</td>
<td>${formatDateTime(c.timestamp)}</td>
<td><span class="status-badge status-${c.state}">${c.state}</span></td>
<td>
<button class="btn-icon" onclick="openStatsModal(${c.host_id}, '${escapeHtml(c.container_id)}', '${escapeHtml(c.container_name)}')" title="View Stats & Timeline">
📊
</button>
<button class="btn-icon" onclick="viewContainerTimeline(${c.host_id}, '${escapeHtml(c.container_id)}', '${escapeHtml(c.container_name)}')" title="View Lifecycle Timeline">
📜
</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
`;
document.getElementById('removedContainersTable').innerHTML = tableHTML;
}
// Render image updates table
function renderImageUpdates(updates) {
document.getElementById('imageUpdatesCount').textContent = updates.length;
if (updates.length === 0) {
document.getElementById('imageUpdatesTable').innerHTML = '<p class="empty-message">No image updates in this period</p>';
return;
}
const tableHTML = `
<table class="report-table">
<thead>
<tr>
<th>Container Name</th>
<th>Host</th>
<th>Old Image</th>
<th>New Image</th>
<th>Updated At</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${updates.map(u => `
<tr>
<td>
<code class="container-link" onclick="goToContainerHistory('${escapeHtml(u.container_name)}', ${u.host_id})" title="View in History">
${escapeHtml(u.container_name)} 🔗
</code>
</td>
<td>${escapeHtml(u.host_name)}</td>
<td>${escapeHtml(u.old_image)}<br><small>${u.old_image_id.substring(0, 12)}</small></td>
<td>${escapeHtml(u.new_image)}<br><small>${u.new_image_id.substring(0, 12)}</small></td>
<td>${formatDateTime(u.updated_at)}</td>
<td>
<button class="btn-icon" onclick="openStatsModal(${u.host_id}, '${escapeHtml(u.container_id)}', '${escapeHtml(u.container_name)}')" title="View Stats & Timeline">
📊
</button>
<button class="btn-icon" onclick="viewContainerTimeline(${u.host_id}, '${escapeHtml(u.container_id)}', '${escapeHtml(u.container_name)}')" title="View Lifecycle Timeline">
📜
</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
`;
document.getElementById('imageUpdatesTable').innerHTML = tableHTML;
}
// Render state changes table
function renderStateChanges(changes) {
document.getElementById('stateChangesCount').textContent = changes.length;
if (changes.length === 0) {
document.getElementById('stateChangesTable').innerHTML = '<p class="empty-message">No state changes in this period</p>';
return;
}
const tableHTML = `
<table class="report-table">
<thead>
<tr>
<th>Container Name</th>
<th>Host</th>
<th>Old State</th>
<th>New State</th>
<th>Changed At</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${changes.map(s => `
<tr>
<td>
<code class="container-link" onclick="goToContainerHistory('${escapeHtml(s.container_name)}', ${s.host_id})" title="View in History">
${escapeHtml(s.container_name)} 🔗
</code>
</td>
<td>${escapeHtml(s.host_name)}</td>
<td><span class="status-badge status-${s.old_state}">${s.old_state}</span></td>
<td><span class="status-badge status-${s.new_state}">${s.new_state}</span></td>
<td>${formatDateTime(s.changed_at)}</td>
<td>
<button class="btn-icon" onclick="openStatsModal(${s.host_id}, '${escapeHtml(s.container_id)}', '${escapeHtml(s.container_name)}')" title="View Stats & Timeline">
📊
</button>
<button class="btn-icon" onclick="viewContainerTimeline(${s.host_id}, '${escapeHtml(s.container_id)}', '${escapeHtml(s.container_name)}')" title="View Lifecycle Timeline">
📜
</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
`;
document.getElementById('stateChangesTable').innerHTML = tableHTML;
}
// Render top restarted containers table
function renderTopRestarted(containers) {
document.getElementById('topRestartedCount').textContent = containers.length;
if (containers.length === 0) {
document.getElementById('topRestartedTable').innerHTML = '<p class="empty-message">No active containers in this period</p>';
return;
}
const tableHTML = `
<table class="report-table">
<thead>
<tr>
<th>Container Name</th>
<th>Image</th>
<th>Host</th>
<th>Activity Count</th>
<th>Current State</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${containers.map(r => `
<tr>
<td>
<code class="container-link" onclick="goToContainerHistory('${escapeHtml(r.container_name)}', ${r.host_id})" title="View in History">
${escapeHtml(r.container_name)} 🔗
</code>
</td>
<td>${escapeHtml(r.image)}</td>
<td>${escapeHtml(r.host_name)}</td>
<td>${r.restart_count}</td>
<td><span class="status-badge status-${r.current_state}">${r.current_state}</span></td>
<td>
<button class="btn-icon" onclick="openStatsModal(${r.host_id}, '${escapeHtml(r.container_id)}', '${escapeHtml(r.container_name)}')" title="View Stats & Timeline">
📊
</button>
<button class="btn-icon" onclick="viewContainerTimeline(${r.host_id}, '${escapeHtml(r.container_id)}', '${escapeHtml(r.container_name)}')" title="View Lifecycle Timeline">
📜
</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
`;
document.getElementById('topRestartedTable').innerHTML = tableHTML;
}
// Toggle report section visibility
window.toggleReportSection = function(section) {
const sectionElement = document.getElementById(`${section}Section`);
const isVisible = sectionElement.style.display !== 'none';
sectionElement.style.display = isVisible ? 'none' : 'block';
// Toggle collapse icon
const header = sectionElement.previousElementSibling;
const icon = header.querySelector('.collapse-icon');
if (icon) {
icon.textContent = isVisible ? '▶' : '▼';
}
};
// Export report as JSON
function exportReport() {
if (!currentReport) {
alert('No report to export. Please generate a report first.');
return;
}
const dataStr = JSON.stringify(currentReport, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = `container-census-report-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
// Helper: Escape HTML
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Helper: Format date/time
function formatDateTime(timestamp) {
if (!timestamp) return '-';
const date = new Date(timestamp);
return date.toLocaleString();
}

View File

@@ -116,16 +116,21 @@
<span class="nav-badge" id="activityBadge"></span>
<span class="nav-shortcut">7</span>
</button>
<button class="nav-item" data-tab="notifications" data-shortcut="8">
<button class="nav-item" data-tab="reports" data-shortcut="8">
<span class="nav-icon">📈</span>
<span class="nav-label">Reports</span>
<span class="nav-shortcut">8</span>
</button>
<button class="nav-item" data-tab="notifications" data-shortcut="9">
<span class="nav-icon">🔔</span>
<span class="nav-label">Notifications</span>
<span class="nav-badge" id="notificationsSidebarBadge"></span>
<span class="nav-shortcut">8</span>
<span class="nav-shortcut">9</span>
</button>
<button class="nav-item" data-tab="settings" data-shortcut="9">
<button class="nav-item" data-tab="settings" data-shortcut="0">
<span class="nav-icon">⚙️</span>
<span class="nav-label">Settings</span>
<span class="nav-shortcut">9</span>
<span class="nav-shortcut">0</span>
</button>
</nav>
@@ -375,6 +380,127 @@
</div>
</div>
<div id="reportsTab" class="tab-content">
<div class="reports-section">
<h2>📈 Environment Changes Report</h2>
<!-- Report Filters -->
<div class="report-filters">
<div class="filter-group">
<label for="reportStartDate">Start Date:</label>
<input type="datetime-local" id="reportStartDate" class="filter-input">
</div>
<div class="filter-group">
<label for="reportEndDate">End Date:</label>
<input type="datetime-local" id="reportEndDate" class="filter-input">
</div>
<div class="filter-group">
<label for="reportHostFilter">Host:</label>
<select id="reportHostFilter" class="filter-select">
<option value="">All Hosts</option>
</select>
</div>
<div class="filter-group">
<label>&nbsp;</label>
<div style="display: flex; gap: 10px;">
<button id="report7d" class="btn btn-sm btn-secondary">Last 7 Days</button>
<button id="report30d" class="btn btn-sm btn-secondary">Last 30 Days</button>
<button id="report90d" class="btn btn-sm btn-secondary">Last 90 Days</button>
</div>
</div>
<div class="filter-group">
<label>&nbsp;</label>
<button id="generateReportBtn" class="btn btn-primary">Generate Report</button>
</div>
</div>
<!-- Report Loading -->
<div id="reportLoading" class="loading" style="display: none;">Generating report...</div>
<!-- Report Results -->
<div id="reportResults" style="display: none;">
<!-- Summary Cards -->
<div class="stats-grid" id="reportSummaryCards">
<!-- Cards will be injected by JavaScript -->
</div>
<!-- Timeline Chart -->
<div class="card" style="margin-top: 20px;">
<h3>Changes Timeline</h3>
<canvas id="changesTimelineChart" style="max-height: 300px;"></canvas>
</div>
<!-- Changes Details -->
<div class="report-details">
<!-- New Containers -->
<div class="card collapsible" style="margin-top: 20px;">
<div class="card-header" onclick="toggleReportSection('newContainers')">
<h3>🆕 New Containers (<span id="newContainersCount">0</span>)</h3>
<span class="collapse-icon"></span>
</div>
<div id="newContainersSection" class="card-body" style="display: none;">
<div id="newContainersTable"></div>
</div>
</div>
<!-- Removed Containers -->
<div class="card collapsible" style="margin-top: 20px;">
<div class="card-header" onclick="toggleReportSection('removedContainers')">
<h3>❌ Removed Containers (<span id="removedContainersCount">0</span>)</h3>
<span class="collapse-icon"></span>
</div>
<div id="removedContainersSection" class="card-body" style="display: none;">
<div id="removedContainersTable"></div>
</div>
</div>
<!-- Image Updates -->
<div class="card collapsible" style="margin-top: 20px;">
<div class="card-header" onclick="toggleReportSection('imageUpdates')">
<h3>🔄 Image Updates (<span id="imageUpdatesCount">0</span>)</h3>
<span class="collapse-icon"></span>
</div>
<div id="imageUpdatesSection" class="card-body" style="display: none;">
<div id="imageUpdatesTable"></div>
</div>
</div>
<!-- State Changes -->
<div class="card collapsible" style="margin-top: 20px;">
<div class="card-header" onclick="toggleReportSection('stateChanges')">
<h3>🔀 State Changes (<span id="stateChangesCount">0</span>)</h3>
<span class="collapse-icon"></span>
</div>
<div id="stateChangesSection" class="card-body" style="display: none;">
<div id="stateChangesTable"></div>
</div>
</div>
<!-- Top Restarted -->
<div class="card collapsible" style="margin-top: 20px;">
<div class="card-header" onclick="toggleReportSection('topRestarted')">
<h3>🔁 Most Active Containers (<span id="topRestartedCount">0</span>)</h3>
<span class="collapse-icon"></span>
</div>
<div id="topRestartedSection" class="card-body" style="display: none;">
<div id="topRestartedTable"></div>
</div>
</div>
</div>
<!-- Export Button -->
<div style="margin-top: 20px; text-align: right;">
<button id="exportReportBtn" class="btn btn-secondary">📥 Export Report (JSON)</button>
</div>
</div>
<!-- Empty State -->
<div id="reportEmptyState" class="empty-state">
<p>Select a time range and click "Generate Report" to see environment changes.</p>
</div>
</div>
</div>
<div id="notificationsTab" class="tab-content">
<div class="notifications-section">
<h2>📬 Notification Center</h2>
@@ -637,30 +763,33 @@
<button class="stats-range-btn" data-range="all">All Time</button>
</div>
<div id="statsContent" class="stats-content">
<div class="stats-summary">
<div class="stat-box">
<div class="stat-label">Avg CPU</div>
<div class="stat-value" id="avgCpu">-</div>
<div id="statsMessage" class="loading" style="display: none;"></div>
<div id="statsChartArea" style="display: none;">
<div class="stats-summary">
<div class="stat-box">
<div class="stat-label">Avg CPU</div>
<div class="stat-value" id="avgCpu">-</div>
</div>
<div class="stat-box">
<div class="stat-label">Max CPU</div>
<div class="stat-value" id="maxCpu">-</div>
</div>
<div class="stat-box">
<div class="stat-label">Avg Memory</div>
<div class="stat-value" id="avgMemory">-</div>
</div>
<div class="stat-box">
<div class="stat-label">Max Memory</div>
<div class="stat-value" id="maxMemory">-</div>
</div>
</div>
<div class="stat-box">
<div class="stat-label">Max CPU</div>
<div class="stat-value" id="maxCpu">-</div>
</div>
<div class="stat-box">
<div class="stat-label">Avg Memory</div>
<div class="stat-value" id="avgMemory">-</div>
</div>
<div class="stat-box">
<div class="stat-label">Max Memory</div>
<div class="stat-value" id="maxMemory">-</div>
</div>
</div>
<div class="stats-charts">
<div class="chart-container">
<canvas id="cpuChart"></canvas>
</div>
<div class="chart-container">
<canvas id="memoryChart"></canvas>
<div class="stats-charts">
<div class="chart-container">
<canvas id="cpuChart"></canvas>
</div>
<div class="chart-container">
<canvas id="memoryChart"></canvas>
</div>
</div>
</div>
</div>

View File

@@ -3868,3 +3868,353 @@ header {
background-color: #ff9800;
font-weight: bold;
}
/* ==================== REPORTS TAB STYLES ==================== */
.reports-section {
padding: 20px;
}
.report-filters {
background: white;
border-radius: 12px;
padding: 25px;
margin-bottom: 25px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
align-items: end;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.filter-group label {
font-weight: 500;
color: #555;
font-size: 14px;
}
.filter-input,
.filter-select {
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
transition: all 0.2s ease;
background: white;
}
.filter-input:focus,
.filter-select:focus {
outline: none;
border-color: #4CAF50;
box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1);
}
.report-filters .btn {
height: 42px;
margin-top: auto;
}
.report-filters .btn-sm {
height: 38px;
padding: 8px 16px;
font-size: 13px;
}
/* Report results area */
#reportResults {
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
/* Summary cards */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
margin-bottom: 25px;
}
.stat-card {
background: white;
border-radius: 12px;
padding: 20px;
display: flex;
align-items: center;
gap: 15px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transition: all 0.3s ease;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
}
.stat-icon {
font-size: 32px;
line-height: 1;
}
.stat-content {
flex: 1;
}
.stat-value {
font-size: 28px;
font-weight: 700;
color: #333;
line-height: 1.2;
}
.stat-label {
font-size: 13px;
color: #777;
margin-top: 4px;
font-weight: 500;
}
/* Collapsible cards */
.card.collapsible {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
overflow: hidden;
}
.card-header {
padding: 18px 22px;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
background: linear-gradient(to right, #f8f9fa, white);
border-bottom: 1px solid #e9ecef;
transition: all 0.2s ease;
}
.card-header:hover {
background: linear-gradient(to right, #f1f3f5, #f8f9fa);
}
.card-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #333;
}
.collapse-icon {
color: #666;
font-size: 14px;
transition: transform 0.2s ease;
}
.card-body {
padding: 20px;
}
/* Report tables */
.report-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.report-table thead {
background: #f8f9fa;
position: sticky;
top: 0;
}
.report-table th {
padding: 12px 16px;
text-align: left;
font-weight: 600;
color: #555;
border-bottom: 2px solid #dee2e6;
}
.report-table td {
padding: 12px 16px;
border-bottom: 1px solid #e9ecef;
vertical-align: top;
}
.report-table tbody tr {
transition: background-color 0.2s ease;
}
.report-table tbody tr:hover {
background-color: #f8f9fa;
}
.report-table code {
background: #f1f3f5;
padding: 3px 8px;
border-radius: 4px;
font-size: 13px;
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
color: #495057;
}
.report-table small {
color: #868e96;
font-size: 12px;
}
/* Empty message */
.empty-message {
text-align: center;
padding: 40px 20px;
color: #868e96;
font-style: italic;
}
/* Empty state */
.empty-state {
background: white;
border-radius: 12px;
padding: 60px 40px;
text-align: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.empty-state p {
font-size: 16px;
color: #868e96;
margin: 0;
}
/* Loading state */
#reportLoading {
background: white;
border-radius: 12px;
padding: 60px 40px;
text-align: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
font-size: 16px;
color: #666;
}
/* Export button */
#exportReportBtn {
min-width: 200px;
}
/* Timeline chart container */
.card canvas {
padding: 20px 0;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.report-filters {
grid-template-columns: 1fr;
}
.stats-grid {
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
}
.stat-card {
padding: 15px;
gap: 12px;
}
.stat-icon {
font-size: 28px;
}
.stat-value {
font-size: 24px;
}
.report-table {
font-size: 13px;
}
.report-table th,
.report-table td {
padding: 10px 12px;
}
}
/* Improve button groups in filter */
.filter-group > div {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
/* Clickable container links in reports */
.container-link {
cursor: pointer;
transition: all 0.2s ease;
color: #2196F3;
text-decoration: none;
display: inline-block;
}
.container-link:hover {
color: #1976D2;
background: #e3f2fd !important;
transform: translateX(2px);
}
.container-link:active {
transform: translateX(0);
}
/* Icon buttons in reports */
.btn-icon {
background: none;
border: none;
font-size: 18px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: all 0.2s ease;
line-height: 1;
}
.btn-icon:hover {
background: #f5f5f5;
transform: scale(1.1);
}
.btn-icon:active {
transform: scale(0.95);
}
/* Transient container badge */
.transient-badge {
display: inline-block;
margin-left: 8px;
padding: 2px 8px;
background: #FFF3E0;
color: #E65100;
border: 1px solid #FFB74D;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
vertical-align: middle;
cursor: help;
transition: all 0.2s ease;
}
.transient-badge:hover {
background: #FFE0B2;
border-color: #FF9800;
transform: scale(1.05);
}