mirror of
https://github.com/selfhosters-cc/container-census.git
synced 2026-04-24 22:19:31 -05:00
Switched to a Next JS interface and simplified the UI
This commit is contained in:
@@ -301,7 +301,11 @@ func (s *Server) setupRoutes() {
|
||||
// Serve static files with selective authentication
|
||||
// Login pages are public, everything else requires auth
|
||||
// Add cache control headers for JS files to ensure updates are seen
|
||||
staticFileServer := http.FileServer(http.Dir("./web"))
|
||||
webDir := os.Getenv("WEB_DIR")
|
||||
if webDir == "" {
|
||||
webDir = "./web"
|
||||
}
|
||||
staticFileServer := http.FileServer(http.Dir(webDir))
|
||||
noCacheFileServer := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// For JS files, set cache headers to force revalidation
|
||||
if strings.HasSuffix(r.URL.Path, ".js") {
|
||||
|
||||
+38
-1
@@ -1,5 +1,13 @@
|
||||
#!/bin/bash
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
# Colors for output
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Create Trivy cache directory in /tmp for local development
|
||||
mkdir -p /tmp/trivy-cache
|
||||
|
||||
@@ -43,7 +51,35 @@ else
|
||||
SESSION_SECRET=""
|
||||
fi
|
||||
|
||||
# Prompt for frontend choice
|
||||
echo ""
|
||||
echo "Frontend options:"
|
||||
echo " 1) Classic (vanilla JS) - web/"
|
||||
echo " 2) Next.js (React) - web-next/out/"
|
||||
read -p "Choose frontend [1]: " -n 1 -r
|
||||
echo
|
||||
|
||||
WEB_DIR="$PROJECT_ROOT/web"
|
||||
if [[ $REPLY == "2" ]]; then
|
||||
# Check if Next.js build exists
|
||||
if [ -d "$PROJECT_ROOT/web-next/out" ]; then
|
||||
WEB_DIR="$PROJECT_ROOT/web-next/out"
|
||||
echo -e "${GREEN}Using Next.js frontend${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}Next.js build not found. Building now...${NC}"
|
||||
(cd "$PROJECT_ROOT/web-next" && npm run build)
|
||||
WEB_DIR="$PROJECT_ROOT/web-next/out"
|
||||
echo -e "${GREEN}Using Next.js frontend${NC}"
|
||||
fi
|
||||
else
|
||||
echo -e "${GREEN}Using classic frontend${NC}"
|
||||
fi
|
||||
|
||||
# Run the server with local development settings
|
||||
echo ""
|
||||
echo -e "${YELLOW}Starting server on http://localhost:3333${NC}"
|
||||
echo ""
|
||||
|
||||
DOCKER_HOST="${DOCKER_HOST:-unix:///var/run/docker.sock}" \
|
||||
SERVER_PORT=3333 \
|
||||
CONFIG_PATH=/opt/docker-compose/census-server/census/config/${CONFIG_FILE} \
|
||||
@@ -53,4 +89,5 @@ AUTH_PASSWORD=${AUTH_PASSWORD} \
|
||||
SESSION_SECRET=${SESSION_SECRET} \
|
||||
DATABASE_PATH=/opt/docker-compose/census-server/census/server/${DB_FILE} \
|
||||
TRIVY_CACHE_DIR=/tmp/trivy-cache \
|
||||
/tmp/census-server
|
||||
WEB_DIR=${WEB_DIR} \
|
||||
/tmp/census-server
|
||||
|
||||
+45
-1
@@ -1,2 +1,46 @@
|
||||
#!/bin/bash
|
||||
CGO_ENABLED=1 go build -o /tmp/census-server ./cmd/server && ls -lh /tmp/census-server
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
# Colors for output
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Check if we should build the Next.js frontend
|
||||
BUILD_FRONTEND=false
|
||||
if [[ "$1" == "--with-frontend" || "$1" == "-f" ]]; then
|
||||
BUILD_FRONTEND=true
|
||||
fi
|
||||
|
||||
# Build Next.js frontend if requested
|
||||
if [[ "$BUILD_FRONTEND" == "true" ]]; then
|
||||
echo -e "${YELLOW}Building Next.js frontend...${NC}"
|
||||
|
||||
if [ ! -d "$PROJECT_ROOT/web-next/node_modules" ]; then
|
||||
echo "Installing npm dependencies..."
|
||||
(cd "$PROJECT_ROOT/web-next" && npm install)
|
||||
fi
|
||||
|
||||
(cd "$PROJECT_ROOT/web-next" && npm run build)
|
||||
|
||||
# Copy the static export to a temporary location for serving
|
||||
echo -e "${GREEN}Frontend built successfully!${NC}"
|
||||
echo "Static files available in: $PROJECT_ROOT/web-next/out/"
|
||||
fi
|
||||
|
||||
# Build Go server
|
||||
echo -e "${YELLOW}Building Go server...${NC}"
|
||||
cd "$PROJECT_ROOT"
|
||||
CGO_ENABLED=1 go build -o /tmp/census-server ./cmd/server
|
||||
|
||||
echo -e "${GREEN}Server built successfully!${NC}"
|
||||
ls -lh /tmp/census-server
|
||||
|
||||
if [[ "$BUILD_FRONTEND" == "true" ]]; then
|
||||
echo ""
|
||||
echo -e "${YELLOW}To use the Next.js frontend, set:${NC}"
|
||||
echo " WEB_DIR=$PROJECT_ROOT/web-next/out"
|
||||
fi
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,8 +6,8 @@
|
||||
--bg-secondary: #1e293b;
|
||||
--bg-tertiary: #334155;
|
||||
--text-primary: #f8fafc;
|
||||
--text-secondary: #94a3b8;
|
||||
--text-tertiary: #64748b;
|
||||
--text-secondary: #cbd5e1;
|
||||
--text-tertiary: #94a3b8;
|
||||
--border: #334155;
|
||||
--accent: #3b82f6;
|
||||
--accent-hover: #2563eb;
|
||||
|
||||
@@ -315,7 +315,7 @@ export default function HostsPage() {
|
||||
<th className="text-left px-4 py-3 text-sm font-medium">Address</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium">Status</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium">Stats</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium">Containers</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium">Running</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium">Last Seen</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium">Actions</th>
|
||||
</tr>
|
||||
@@ -364,7 +364,7 @@ export default function HostsPage() {
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-sm">
|
||||
{host.running_count ?? 0}/{host.container_count ?? 0}
|
||||
{host.running_count ?? 0}/{host.container_count ?? 0} running
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-[var(--text-tertiary)]">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import Sidebar from "@/components/layout/Sidebar";
|
||||
import AppLayout from "@/components/layout/AppLayout";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -28,12 +28,9 @@ export default function RootLayout({
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<div className="flex h-screen">
|
||||
<Sidebar />
|
||||
<main className="flex-1 overflow-auto p-6">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
<AppLayout>
|
||||
{children}
|
||||
</AppLayout>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -30,7 +30,10 @@ import type {
|
||||
type Tab = 'log' | 'rules' | 'channels' | 'silences';
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleString();
|
||||
if (!dateStr) return 'Unknown';
|
||||
const date = new Date(dateStr);
|
||||
if (isNaN(date.getTime())) return 'Unknown';
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
function EventTypeBadge({ type }: { type: string }) {
|
||||
@@ -77,7 +80,7 @@ function NotificationLogList({ logs, onMarkRead }: NotificationLogListProps) {
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<EventTypeBadge type={log.event_type} />
|
||||
<span className="text-xs text-[var(--text-tertiary)]">{formatDate(log.created_at)}</span>
|
||||
<span className="text-xs text-[var(--text-tertiary)]">{formatDate(log.sent_at)}</span>
|
||||
</div>
|
||||
<div className="font-medium">{log.container_name || 'System'}</div>
|
||||
<div className="text-sm text-[var(--text-tertiary)]">{log.message}</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getContainers, getHosts, getVulnerabilitySummary, getNotificationStatus } from '@/lib/api';
|
||||
import type { Container, Host, VulnerabilitySummary } from '@/types';
|
||||
import { getContainers, getHosts, getNotificationStatus } from '@/lib/api';
|
||||
import type { Container, Host } from '@/types';
|
||||
|
||||
function StatCard({ label, value, icon, color = 'text-[var(--text-primary)]' }: {
|
||||
label: string;
|
||||
@@ -158,22 +158,19 @@ function RecentContainers({ containers }: { containers: Container[] }) {
|
||||
export default function DashboardPage() {
|
||||
const [containers, setContainers] = useState<Container[]>([]);
|
||||
const [hosts, setHosts] = useState<Host[]>([]);
|
||||
const [vulnSummary, setVulnSummary] = useState<VulnerabilitySummary | null>(null);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadData() {
|
||||
try {
|
||||
const [containersData, hostsData, vulnData, notifStatus] = await Promise.all([
|
||||
const [containersData, hostsData, notifStatus] = await Promise.all([
|
||||
getContainers(),
|
||||
getHosts(),
|
||||
getVulnerabilitySummary().catch(() => null),
|
||||
getNotificationStatus().catch(() => ({ unread_count: 0 })),
|
||||
]);
|
||||
setContainers(containersData);
|
||||
setHosts(hostsData);
|
||||
setVulnSummary(vulnData);
|
||||
setUnreadCount(notifStatus.unread_count);
|
||||
} catch (error) {
|
||||
console.error('Failed to load dashboard data:', error);
|
||||
@@ -222,35 +219,6 @@ export default function DashboardPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Vulnerability Stats */}
|
||||
{vulnSummary && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label="Critical Vulnerabilities"
|
||||
value={vulnSummary.critical_count ?? 0}
|
||||
icon="🚨"
|
||||
color={(vulnSummary.critical_count ?? 0) > 0 ? 'text-[var(--danger)]' : 'text-[var(--text-primary)]'}
|
||||
/>
|
||||
<StatCard
|
||||
label="High Vulnerabilities"
|
||||
value={vulnSummary.high_count ?? 0}
|
||||
icon="⚠️"
|
||||
color={(vulnSummary.high_count ?? 0) > 0 ? 'text-[var(--warning)]' : 'text-[var(--text-primary)]'}
|
||||
/>
|
||||
<StatCard
|
||||
label="Scanned Images"
|
||||
value={`${vulnSummary.scanned_images ?? 0}/${vulnSummary.total_images ?? 0}`}
|
||||
icon="🔍"
|
||||
/>
|
||||
<StatCard
|
||||
label="Unread Notifications"
|
||||
value={unreadCount}
|
||||
icon="🔔"
|
||||
color={unreadCount > 0 ? 'text-[var(--accent)]' : 'text-[var(--text-primary)]'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Charts and Lists */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<ContainersByState containers={containers} />
|
||||
|
||||
+328
-117
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { useEffect, useState, useMemo, useRef } from 'react';
|
||||
import {
|
||||
getVulnerabilitySummary,
|
||||
getVulnerabilityScans,
|
||||
@@ -11,6 +11,14 @@ import {
|
||||
} from '@/lib/api';
|
||||
import type { VulnerabilitySummary, VulnerabilityScan, Vulnerability } from '@/types';
|
||||
|
||||
// Chart.js type declaration
|
||||
declare const Chart: {
|
||||
new (ctx: CanvasRenderingContext2D, config: unknown): {
|
||||
destroy: () => void;
|
||||
update: () => void;
|
||||
};
|
||||
};
|
||||
|
||||
function SeverityBadge({ severity, count }: { severity: string; count: number }) {
|
||||
const colors: Record<string, string> = {
|
||||
critical: 'bg-[#ff1744] text-white',
|
||||
@@ -20,17 +28,20 @@ function SeverityBadge({ severity, count }: { severity: string; count: number })
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`px-2 py-0.5 text-xs rounded ${colors[severity] || 'bg-gray-500 text-white'}`}>
|
||||
{count} {severity.toUpperCase()}
|
||||
<span className={`px-2 py-0.5 text-xs rounded font-medium ${colors[severity] || 'bg-gray-500 text-white'}`}>
|
||||
{count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value, color = 'text-[var(--text-primary)]' }: { label: string; value: number | string; color?: string }) {
|
||||
function StatCard({ label, value, icon, color = 'text-[var(--text-primary)]' }: { label: string; value: number | string; icon: string; color?: string }) {
|
||||
return (
|
||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-4">
|
||||
<div className="text-sm text-[var(--text-tertiary)]">{label}</div>
|
||||
<div className={`text-2xl font-bold ${color}`}>{value}</div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span>{icon}</span>
|
||||
<span className="text-sm text-[var(--text-tertiary)]">{label}</span>
|
||||
</div>
|
||||
<div className={`text-3xl font-bold ${color}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -73,6 +84,20 @@ function VulnerabilityDetailsModal({ isOpen, onClose, imageId, imageName }: Vuln
|
||||
});
|
||||
}, [vulnerabilities, filter, severityFilter]);
|
||||
|
||||
// Compute counts from vulnerabilities array (most accurate)
|
||||
const counts = useMemo(() => {
|
||||
const c = { critical: 0, high: 0, medium: 0, low: 0, total: 0 };
|
||||
vulnerabilities.forEach(v => {
|
||||
c.total++;
|
||||
const sev = v.severity.toLowerCase();
|
||||
if (sev === 'critical') c.critical++;
|
||||
else if (sev === 'high') c.high++;
|
||||
else if (sev === 'medium') c.medium++;
|
||||
else if (sev === 'low') c.low++;
|
||||
});
|
||||
return c;
|
||||
}, [vulnerabilities]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
@@ -92,18 +117,28 @@ function VulnerabilityDetailsModal({ isOpen, onClose, imageId, imageName }: Vuln
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Summary */}
|
||||
{scan && (
|
||||
<div className="p-4 border-b border-[var(--border)] flex flex-wrap gap-2">
|
||||
<SeverityBadge severity="critical" count={scan.critical_count} />
|
||||
<SeverityBadge severity="high" count={scan.high_count} />
|
||||
<SeverityBadge severity="medium" count={scan.medium_count} />
|
||||
<SeverityBadge severity="low" count={scan.low_count} />
|
||||
<span className="text-sm text-[var(--text-tertiary)] ml-4">
|
||||
Total: {scan.total_vulnerabilities}
|
||||
</span>
|
||||
{/* Summary with severity badges */}
|
||||
<div className="p-4 border-b border-[var(--border)] flex flex-wrap gap-3 items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-[var(--text-tertiary)]">Critical:</span>
|
||||
<SeverityBadge severity="critical" count={counts.critical} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-[var(--text-tertiary)]">High:</span>
|
||||
<SeverityBadge severity="high" count={counts.high} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-[var(--text-tertiary)]">Medium:</span>
|
||||
<SeverityBadge severity="medium" count={counts.medium} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-[var(--text-tertiary)]">Low:</span>
|
||||
<SeverityBadge severity="low" count={counts.low} />
|
||||
</div>
|
||||
<span className="text-sm text-[var(--text-tertiary)] ml-4">
|
||||
Total: <strong>{counts.total}</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="p-4 border-b border-[var(--border)] flex gap-4">
|
||||
@@ -168,9 +203,14 @@ export default function SecurityPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [severityFilter, setSeverityFilter] = useState('');
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [detailsModal, setDetailsModal] = useState<{ imageId: string; imageName: string } | null>(null);
|
||||
|
||||
const severityChartRef = useRef<HTMLCanvasElement>(null);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const severityChartInstance = useRef<any>(null);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [summaryData, scansData] = await Promise.all([
|
||||
@@ -188,10 +228,93 @@ export default function SecurityPage() {
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
|
||||
// Load Chart.js from CDN
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/chart.js@4.4.0';
|
||||
script.async = true;
|
||||
document.body.appendChild(script);
|
||||
|
||||
const interval = setInterval(loadData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
if (script.parentNode) script.parentNode.removeChild(script);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Helper to get severity count from scan (handles both nested and flat formats)
|
||||
const getSeverityCount = (scan: VulnerabilityScan, severity: 'critical' | 'high' | 'medium' | 'low'): number => {
|
||||
// Try nested severity_counts first (current API format)
|
||||
if (scan.severity_counts && typeof scan.severity_counts === 'object') {
|
||||
return scan.severity_counts[severity] || 0;
|
||||
}
|
||||
// Fallback to flat fields (legacy format)
|
||||
const legacyField = `${severity}_count` as keyof VulnerabilityScan;
|
||||
return (scan[legacyField] as number) || 0;
|
||||
};
|
||||
|
||||
// Compute stats from scans (most reliable)
|
||||
const stats = useMemo(() => {
|
||||
let critical = 0, high = 0, medium = 0, low = 0, atRisk = 0;
|
||||
scans.forEach(scan => {
|
||||
if (scan.success) {
|
||||
critical += getSeverityCount(scan, 'critical');
|
||||
high += getSeverityCount(scan, 'high');
|
||||
medium += getSeverityCount(scan, 'medium');
|
||||
low += getSeverityCount(scan, 'low');
|
||||
if (scan.total_vulnerabilities > 0) atRisk++;
|
||||
}
|
||||
});
|
||||
return {
|
||||
total: scans.filter(s => s.success).length,
|
||||
critical,
|
||||
high,
|
||||
medium,
|
||||
low,
|
||||
atRisk,
|
||||
};
|
||||
}, [scans]);
|
||||
|
||||
// Render severity distribution chart
|
||||
useEffect(() => {
|
||||
if (loading || !severityChartRef.current || typeof Chart === 'undefined') return;
|
||||
|
||||
if (severityChartInstance.current) {
|
||||
severityChartInstance.current.destroy();
|
||||
}
|
||||
|
||||
const ctx = severityChartRef.current.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
severityChartInstance.current = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Critical', 'High', 'Medium', 'Low'],
|
||||
datasets: [{
|
||||
data: [stats.critical, stats.high, stats.medium, stats.low],
|
||||
backgroundColor: ['#ff1744', '#ff9800', '#ffc107', '#4caf50'],
|
||||
borderWidth: 0,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: { color: '#94a3b8', padding: 15 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (severityChartInstance.current) {
|
||||
severityChartInstance.current.destroy();
|
||||
}
|
||||
};
|
||||
}, [loading, stats]);
|
||||
|
||||
const filteredScans = useMemo(() => {
|
||||
return scans.filter(scan => {
|
||||
const matchesSearch = searchTerm === '' ||
|
||||
@@ -199,33 +322,30 @@ export default function SecurityPage() {
|
||||
scan.image_id.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
let matchesStatus = true;
|
||||
if (statusFilter === 'critical') {
|
||||
matchesStatus = scan.critical_count > 0;
|
||||
} else if (statusFilter === 'high') {
|
||||
matchesStatus = scan.high_count > 0;
|
||||
} else if (statusFilter === 'clean') {
|
||||
matchesStatus = scan.total_vulnerabilities === 0;
|
||||
if (statusFilter === 'scanned') {
|
||||
matchesStatus = scan.success === true;
|
||||
} else if (statusFilter === 'remote') {
|
||||
matchesStatus = scan.success !== true && Boolean(scan.error?.includes('not available') || scan.error?.includes('remote'));
|
||||
} else if (statusFilter === 'failed') {
|
||||
matchesStatus = !scan.success;
|
||||
matchesStatus = scan.success !== true && !scan.error?.includes('not available');
|
||||
}
|
||||
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
}, [scans, searchTerm, statusFilter]);
|
||||
let matchesSeverity = true;
|
||||
if (severityFilter === 'critical') {
|
||||
matchesSeverity = getSeverityCount(scan, 'critical') > 0;
|
||||
} else if (severityFilter === 'high') {
|
||||
matchesSeverity = getSeverityCount(scan, 'high') > 0;
|
||||
} else if (severityFilter === 'medium') {
|
||||
matchesSeverity = getSeverityCount(scan, 'medium') > 0;
|
||||
} else if (severityFilter === 'low') {
|
||||
matchesSeverity = getSeverityCount(scan, 'low') > 0;
|
||||
} else if (severityFilter === 'clean') {
|
||||
matchesSeverity = scan.total_vulnerabilities === 0 && scan.success === true;
|
||||
}
|
||||
|
||||
const stats = useMemo(() => {
|
||||
// Handle both nested (summary.summary) and direct formats
|
||||
const s = summary?.summary || summary;
|
||||
const severityCounts = (s as { severity_counts?: Record<string, number> })?.severity_counts || {};
|
||||
const totalScanned = (s as { total_images_scanned?: number })?.total_images_scanned || scans.length;
|
||||
const atRiskCount = (s as { images_with_vulnerabilities?: number })?.images_with_vulnerabilities || scans.filter(sc => sc.total_vulnerabilities > 0).length;
|
||||
return {
|
||||
total: totalScanned,
|
||||
critical: severityCounts.critical || 0,
|
||||
high: severityCounts.high || 0,
|
||||
atRisk: atRiskCount,
|
||||
};
|
||||
}, [summary, scans]);
|
||||
return matchesSearch && matchesStatus && matchesSeverity;
|
||||
});
|
||||
}, [scans, searchTerm, statusFilter, severityFilter]);
|
||||
|
||||
const handleScanAll = async () => {
|
||||
setActionLoading(true);
|
||||
@@ -260,6 +380,27 @@ export default function SecurityPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Determine status badge for a scan
|
||||
const getStatusBadge = (scan: VulnerabilityScan) => {
|
||||
if (!scan.success) {
|
||||
const isRemote = scan.error?.includes('not available') || scan.error?.includes('remote');
|
||||
if (isRemote) {
|
||||
return <span className="px-2 py-1 text-xs rounded bg-[var(--info)] text-white">🌐 Remote</span>;
|
||||
}
|
||||
return <span className="px-2 py-1 text-xs rounded bg-[var(--danger)] text-white" title={scan.error}>⚠️ Failed</span>;
|
||||
}
|
||||
if (scan.total_vulnerabilities === 0) {
|
||||
return <span className="px-2 py-1 text-xs rounded bg-[var(--success)] text-white">✓ Clean</span>;
|
||||
}
|
||||
if (getSeverityCount(scan, 'critical') > 0) {
|
||||
return <span className="px-2 py-1 text-xs rounded bg-[#ff1744] text-white">🚨 Critical</span>;
|
||||
}
|
||||
if (getSeverityCount(scan, 'high') > 0) {
|
||||
return <span className="px-2 py-1 text-xs rounded bg-[#ff9800] text-white">⚠️ High</span>;
|
||||
}
|
||||
return <span className="px-2 py-1 text-xs rounded bg-[#ffc107] text-black">⚡ Vuln</span>;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
@@ -271,7 +412,10 @@ export default function SecurityPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Security</h1>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">🛡️ Security</h1>
|
||||
<p className="text-sm text-[var(--text-tertiary)]">Monitor and track security vulnerabilities across all container images</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleScanAll}
|
||||
@@ -291,18 +435,76 @@ export default function SecurityPage() {
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard label="Scanned Images" value={stats.total} />
|
||||
<StatCard label="Critical" value={stats.critical} color={stats.critical > 0 ? 'text-[#ff1744]' : ''} />
|
||||
<StatCard label="High" value={stats.high} color={stats.high > 0 ? 'text-[#ff9800]' : ''} />
|
||||
<StatCard label="At Risk Images" value={stats.atRisk} color={stats.atRisk > 0 ? 'text-[var(--warning)]' : ''} />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatCard icon="📊" label="Scanned Images" value={stats.total} />
|
||||
<StatCard icon="🚨" label="Critical" value={stats.critical} color={stats.critical > 0 ? 'text-[#ff1744]' : ''} />
|
||||
<StatCard icon="⚠️" label="High" value={stats.high} color={stats.high > 0 ? 'text-[#ff9800]' : ''} />
|
||||
<StatCard icon="🛡️" label="At Risk Images" value={stats.atRisk} color={stats.atRisk > 0 ? 'text-[var(--warning)]' : ''} />
|
||||
</div>
|
||||
|
||||
{/* Charts Row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Severity Distribution Chart */}
|
||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-4">
|
||||
<h3 className="text-lg font-medium mb-2">Severity Distribution</h3>
|
||||
<p className="text-xs text-[var(--text-tertiary)] mb-4">Current vulnerability breakdown</p>
|
||||
<div className="h-64">
|
||||
<canvas ref={severityChartRef}></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Severity Counts */}
|
||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-4">
|
||||
<h3 className="text-lg font-medium mb-2">Vulnerability Counts</h3>
|
||||
<p className="text-xs text-[var(--text-tertiary)] mb-4">Total vulnerabilities by severity</p>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-3 bg-[var(--bg-tertiary)] rounded">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full bg-[#ff1744]"></span>
|
||||
<span>Critical</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-[#ff1744]">{stats.critical}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-[var(--bg-tertiary)] rounded">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full bg-[#ff9800]"></span>
|
||||
<span>High</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-[#ff9800]">{stats.high}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-[var(--bg-tertiary)] rounded">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full bg-[#ffc107]"></span>
|
||||
<span>Medium</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-[#ffc107]">{stats.medium}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-[var(--bg-tertiary)] rounded">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full bg-[#4caf50]"></span>
|
||||
<span>Low</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-[#4caf50]">{stats.low}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Queue Status */}
|
||||
{summary?.queue_status && (summary.queue_status.in_progress > 0 || summary.queue_status.pending > 0) && (
|
||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-4 flex items-center gap-4">
|
||||
<span className="text-xl">⏳</span>
|
||||
<span className="text-sm">
|
||||
<strong>Scanning in progress:</strong> {summary.queue_status.in_progress} scanning, {summary.queue_status.pending} queued
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search images..."
|
||||
placeholder="🔍 Search images..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="flex-1 min-w-[200px] bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg px-4 py-2 text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent)]"
|
||||
@@ -313,10 +515,21 @@ export default function SecurityPage() {
|
||||
className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg px-4 py-2 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="critical">Critical Issues</option>
|
||||
<option value="high">High Issues</option>
|
||||
<option value="scanned">Scanned Only</option>
|
||||
<option value="remote">Remote Only</option>
|
||||
<option value="failed">Failed Only</option>
|
||||
</select>
|
||||
<select
|
||||
value={severityFilter}
|
||||
onChange={(e) => setSeverityFilter(e.target.value)}
|
||||
className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg px-4 py-2 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
||||
>
|
||||
<option value="">All Severities</option>
|
||||
<option value="critical">Critical</option>
|
||||
<option value="high">High</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="clean">Clean</option>
|
||||
<option value="failed">Failed Scans</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -327,73 +540,71 @@ export default function SecurityPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-[var(--bg-tertiary)]">
|
||||
<th className="text-left px-4 py-3 text-sm font-medium">Image</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium">Status</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium">Critical</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium">High</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium">Medium</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium">Low</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium">Total</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium">Last Scan</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredScans.map(scan => (
|
||||
<tr
|
||||
key={scan.image_id}
|
||||
className="border-t border-[var(--border)] hover:bg-[var(--bg-tertiary)] cursor-pointer"
|
||||
onClick={() => setDetailsModal({ imageId: scan.image_id, imageName: scan.image_name })}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<code className="text-sm">{scan.image_name}</code>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{scan.success ? (
|
||||
<span className="px-2 py-1 text-xs rounded bg-[var(--success)] text-white">✓</span>
|
||||
) : (
|
||||
<span className="px-2 py-1 text-xs rounded bg-[var(--danger)] text-white" title={scan.error}>✗</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={scan.critical_count > 0 ? 'text-[#ff1744] font-bold' : ''}>
|
||||
{scan.critical_count}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={scan.high_count > 0 ? 'text-[#ff9800] font-bold' : ''}>
|
||||
{scan.high_count}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={scan.medium_count > 0 ? 'text-[#ffc107]' : ''}>
|
||||
{scan.medium_count}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">{scan.low_count}</td>
|
||||
<td className="px-4 py-3">{scan.total_vulnerabilities}</td>
|
||||
<td className="px-4 py-3 text-sm text-[var(--text-tertiary)]">
|
||||
{new Date(scan.scanned_at).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleScanImage(scan.image_id);
|
||||
}}
|
||||
className="p-1.5 rounded hover:bg-[var(--bg-secondary)] transition-colors"
|
||||
title="Rescan"
|
||||
>
|
||||
🔄
|
||||
</button>
|
||||
</td>
|
||||
<div className="px-4 py-3 border-b border-[var(--border)] flex items-center justify-between">
|
||||
<h3 className="font-medium">Vulnerability Scans</h3>
|
||||
<span className="text-sm text-[var(--text-tertiary)]">{filteredScans.length} scans</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-[var(--bg-tertiary)]">
|
||||
<th className="text-left px-4 py-3 text-sm font-medium">Image</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium">Status</th>
|
||||
<th className="text-center px-4 py-3 text-sm font-medium">Critical</th>
|
||||
<th className="text-center px-4 py-3 text-sm font-medium">High</th>
|
||||
<th className="text-center px-4 py-3 text-sm font-medium">Medium</th>
|
||||
<th className="text-center px-4 py-3 text-sm font-medium">Low</th>
|
||||
<th className="text-center px-4 py-3 text-sm font-medium">Total</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium">Last Scan</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium">Actions</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredScans.map(scan => (
|
||||
<tr
|
||||
key={scan.image_id}
|
||||
className="border-t border-[var(--border)] hover:bg-[var(--bg-tertiary)] cursor-pointer"
|
||||
onClick={() => setDetailsModal({ imageId: scan.image_id, imageName: scan.image_name })}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<code className="text-sm">{scan.image_name}</code>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{getStatusBadge(scan)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<SeverityBadge severity="critical" count={getSeverityCount(scan, 'critical')} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<SeverityBadge severity="high" count={getSeverityCount(scan, 'high')} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<SeverityBadge severity="medium" count={getSeverityCount(scan, 'medium')} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<SeverityBadge severity="low" count={getSeverityCount(scan, 'low')} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center font-medium">{scan.total_vulnerabilities}</td>
|
||||
<td className="px-4 py-3 text-sm text-[var(--text-tertiary)]">
|
||||
{new Date(scan.scanned_at).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleScanImage(scan.image_id);
|
||||
}}
|
||||
className="px-2 py-1 text-sm rounded hover:bg-[var(--bg-secondary)] transition-colors"
|
||||
title="Rescan"
|
||||
>
|
||||
🔄
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -18,9 +18,6 @@ interface Settings {
|
||||
threshold_duration: number;
|
||||
cooldown_period: number;
|
||||
};
|
||||
ui: {
|
||||
card_design: string;
|
||||
};
|
||||
}
|
||||
|
||||
async function getSettings(): Promise<Settings> {
|
||||
@@ -56,6 +53,8 @@ export default function SettingsPage() {
|
||||
const [health, setHealth] = useState<HealthStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState<string | null>(null);
|
||||
const [showUpdateModal, setShowUpdateModal] = useState(false);
|
||||
const [checkingUpdates, setCheckingUpdates] = useState(false);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
@@ -76,6 +75,19 @@ export default function SettingsPage() {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const handleCheckUpdates = async () => {
|
||||
setCheckingUpdates(true);
|
||||
try {
|
||||
const healthData = await getHealth();
|
||||
setHealth(healthData);
|
||||
setShowUpdateModal(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to check for updates:', error);
|
||||
} finally {
|
||||
setCheckingUpdates(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (key: string, updatedSettings: Settings) => {
|
||||
setSaving(key);
|
||||
try {
|
||||
@@ -106,15 +118,6 @@ export default function SettingsPage() {
|
||||
handleSave('telemetry_interval', updated);
|
||||
};
|
||||
|
||||
const handleCardDesignChange = (value: string) => {
|
||||
if (!settings) return;
|
||||
const updated = {
|
||||
...settings,
|
||||
ui: { ...settings.ui, card_design: value },
|
||||
};
|
||||
handleSave('card_design', updated);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
@@ -131,26 +134,37 @@ export default function SettingsPage() {
|
||||
{health && (
|
||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-4">
|
||||
<h2 className="text-lg font-semibold mb-4">System Information</h2>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-[var(--text-tertiary)]">Version:</span>{' '}
|
||||
<span className="font-medium">v{health.version}</span>
|
||||
{health.update_available && (
|
||||
<a
|
||||
href={health.release_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-2 text-[var(--accent)]"
|
||||
>
|
||||
⬆️ v{health.latest_version} available
|
||||
</a>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-[var(--text-secondary)]">Version:</span>{' '}
|
||||
<span className="font-medium">v{health.version}</span>
|
||||
{health.update_available && (
|
||||
<a
|
||||
href={health.release_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-2 text-[var(--accent)]"
|
||||
>
|
||||
⬆️ v{health.latest_version} available
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--text-secondary)]">Status:</span>{' '}
|
||||
<span className={`font-medium ${health.status === 'healthy' ? 'text-[var(--success)]' : 'text-[var(--danger)]'}`}>
|
||||
{health.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--text-tertiary)]">Status:</span>{' '}
|
||||
<span className={`font-medium ${health.status === 'healthy' ? 'text-[var(--success)]' : 'text-[var(--danger)]'}`}>
|
||||
{health.status}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleCheckUpdates}
|
||||
disabled={checkingUpdates}
|
||||
className="px-4 py-2 text-sm bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
{checkingUpdates ? 'Checking...' : 'Check for Updates'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -202,60 +216,37 @@ export default function SettingsPage() {
|
||||
<option value="168">Weekly</option>
|
||||
<option value="720">Monthly</option>
|
||||
</select>
|
||||
{saving === 'telemetry_interval' && <span className="text-sm text-[var(--text-tertiary)]">Saving...</span>}
|
||||
{saving === 'telemetry_interval' && <span className="text-sm text-[var(--text-secondary)]">Saving...</span>}
|
||||
</div>
|
||||
</SettingRow>
|
||||
<div className="text-xs text-[var(--text-tertiary)] mt-2">
|
||||
<div className="text-xs text-[var(--text-secondary)] mt-2">
|
||||
Telemetry helps improve Container Census by sharing anonymous usage statistics.
|
||||
No container names, images, or sensitive data is collected.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* UI Settings */}
|
||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-4">
|
||||
<h2 className="text-lg font-semibold mb-4">UI Settings</h2>
|
||||
<SettingRow
|
||||
label="Container Card Design"
|
||||
description="Visual style for container cards in the Containers tab"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={settings?.ui.card_design || 'material'}
|
||||
onChange={(e) => handleCardDesignChange(e.target.value)}
|
||||
disabled={saving === 'card_design'}
|
||||
className="bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="material">Material</option>
|
||||
<option value="compact">Compact</option>
|
||||
<option value="dashboard">Dashboard</option>
|
||||
</select>
|
||||
{saving === 'card_design' && <span className="text-sm text-[var(--text-tertiary)]">Saving...</span>}
|
||||
</div>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
{/* Notification Rate Limits */}
|
||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-4">
|
||||
<h2 className="text-lg font-semibold mb-4">Notification Settings</h2>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-[var(--text-tertiary)]">Rate Limit:</span>{' '}
|
||||
<span className="text-[var(--text-secondary)]">Rate Limit:</span>{' '}
|
||||
<span className="font-medium">{settings?.notification.rate_limit_max || 100}/hour</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--text-tertiary)]">Batch Interval:</span>{' '}
|
||||
<span className="text-[var(--text-secondary)]">Batch Interval:</span>{' '}
|
||||
<span className="font-medium">{settings?.notification.rate_limit_batch_interval || 600}s</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--text-tertiary)]">Threshold Duration:</span>{' '}
|
||||
<span className="text-[var(--text-secondary)]">Threshold Duration:</span>{' '}
|
||||
<span className="font-medium">{settings?.notification.threshold_duration || 120}s</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--text-tertiary)]">Cooldown Period:</span>{' '}
|
||||
<span className="text-[var(--text-secondary)]">Cooldown Period:</span>{' '}
|
||||
<span className="font-medium">{settings?.notification.cooldown_period || 300}s</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-tertiary)] mt-2">
|
||||
<div className="text-xs text-[var(--text-secondary)] mt-2">
|
||||
Notification rate limits are configured via environment variables.
|
||||
</div>
|
||||
</div>
|
||||
@@ -266,7 +257,7 @@ export default function SettingsPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium">Reset All Settings</div>
|
||||
<div className="text-sm text-[var(--text-tertiary)]">Reset all settings to their default values</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">Reset all settings to their default values</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -280,6 +271,60 @@ export default function SettingsPage() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Update Modal */}
|
||||
{showUpdateModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg w-full max-w-md p-6">
|
||||
<h2 className="text-xl font-bold mb-4">Software Update</h2>
|
||||
{health?.update_available ? (
|
||||
<>
|
||||
<p className="text-[var(--text-secondary)] mb-4">
|
||||
A new version of Container Census is available!
|
||||
</p>
|
||||
<div className="space-y-2 mb-6">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-[var(--text-secondary)]">Current Version:</span>
|
||||
<span className="font-medium">v{health.version}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-[var(--text-secondary)]">Latest Version:</span>
|
||||
<span className="font-medium text-[var(--success)]">v{health.latest_version}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<a
|
||||
href={health.release_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1 px-4 py-2 text-sm text-center bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] transition-colors"
|
||||
>
|
||||
View Release Notes
|
||||
</a>
|
||||
<button
|
||||
onClick={() => setShowUpdateModal(false)}
|
||||
className="px-4 py-2 text-sm border border-[var(--border)] rounded hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-[var(--text-secondary)] mb-6">
|
||||
You are running the latest version of Container Census (v{health?.version}).
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowUpdateModal(false)}
|
||||
className="w-full px-4 py-2 text-sm bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode, useState } from 'react';
|
||||
import Sidebar from './Sidebar';
|
||||
import Header from './Header';
|
||||
import { triggerScan, submitTelemetry } from '@/lib/api';
|
||||
|
||||
interface AppLayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function AppLayout({ children }: AppLayoutProps) {
|
||||
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
|
||||
|
||||
const showToast = (message: string, type: 'success' | 'error' = 'success') => {
|
||||
setToast({ message, type });
|
||||
setTimeout(() => setToast(null), 3000);
|
||||
};
|
||||
|
||||
const handleScan = async () => {
|
||||
try {
|
||||
await triggerScan();
|
||||
showToast('Scan triggered successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to trigger scan:', error);
|
||||
showToast('Failed to trigger scan', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTelemetry = async () => {
|
||||
try {
|
||||
await submitTelemetry();
|
||||
showToast('Telemetry submitted successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to submit telemetry:', error);
|
||||
showToast('Failed to submit telemetry', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<Sidebar />
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<Header onScan={handleScan} onTelemetry={handleTelemetry} />
|
||||
<main className="flex-1 overflow-auto p-6">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Toast notification */}
|
||||
{toast && (
|
||||
<div
|
||||
className={`fixed bottom-4 right-4 px-4 py-2 rounded-lg text-white shadow-lg z-50 transition-opacity ${
|
||||
toast.type === 'success' ? 'bg-[var(--success)]' : 'bg-[var(--danger)]'
|
||||
}`}
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { getHealth, getNotificationStatus, getNotificationLog, markAllNotificationsRead } from '@/lib/api';
|
||||
import type { HealthStatus, NotificationLog } from '@/types';
|
||||
|
||||
interface HeaderProps {
|
||||
onScan: () => void;
|
||||
onTelemetry: () => void;
|
||||
}
|
||||
|
||||
export default function Header({ onScan, onTelemetry }: HeaderProps) {
|
||||
const [health, setHealth] = useState<HealthStatus | null>(null);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [notifications, setNotifications] = useState<NotificationLog[]>([]);
|
||||
const [showNotifications, setShowNotifications] = useState(false);
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const notificationRef = useRef<HTMLDivElement>(null);
|
||||
const helpRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Load health and notification status
|
||||
getHealth().then(setHealth).catch(console.error);
|
||||
getNotificationStatus()
|
||||
.then(status => setUnreadCount(status.unread_count))
|
||||
.catch(console.error);
|
||||
getNotificationLog(10)
|
||||
.then(setNotifications)
|
||||
.catch(console.error);
|
||||
|
||||
// Refresh notification count periodically
|
||||
const interval = setInterval(() => {
|
||||
getNotificationStatus()
|
||||
.then(status => setUnreadCount(status.unread_count))
|
||||
.catch(console.error);
|
||||
}, 30000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Close dropdowns when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (notificationRef.current && !notificationRef.current.contains(event.target as Node)) {
|
||||
setShowNotifications(false);
|
||||
}
|
||||
if (helpRef.current && !helpRef.current.contains(event.target as Node)) {
|
||||
setShowHelp(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleScan = async () => {
|
||||
setScanning(true);
|
||||
try {
|
||||
await onScan();
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkAllRead = async () => {
|
||||
try {
|
||||
await markAllNotificationsRead();
|
||||
setUnreadCount(0);
|
||||
setNotifications(prev => prev.map(n => ({ ...n, read: true })));
|
||||
} catch (error) {
|
||||
console.error('Failed to mark notifications as read:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="h-14 bg-[var(--bg-secondary)] border-b border-[var(--border)] flex items-center justify-between px-4">
|
||||
{/* Left side - Logo */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/" className="flex items-center gap-2 text-lg font-bold hover:opacity-80">
|
||||
<span>Census</span>
|
||||
</Link>
|
||||
{health && (
|
||||
<span className="text-xs px-2 py-1 rounded bg-[var(--bg-tertiary)] text-[var(--text-tertiary)]">
|
||||
{health.update_available ? (
|
||||
<a
|
||||
href={health.release_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[var(--accent)] hover:underline"
|
||||
>
|
||||
v{health.version} → v{health.latest_version} ⬆️
|
||||
</a>
|
||||
) : (
|
||||
`v${health.version}`
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right side - Action buttons */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Scan button */}
|
||||
<button
|
||||
onClick={handleScan}
|
||||
disabled={scanning}
|
||||
className="p-2 rounded hover:bg-[var(--bg-tertiary)] transition-colors disabled:opacity-50"
|
||||
title="Trigger Scan"
|
||||
>
|
||||
<span className={scanning ? 'animate-spin inline-block' : ''}>{scanning ? '⏳' : '🔄'}</span>
|
||||
</button>
|
||||
|
||||
{/* Telemetry button */}
|
||||
<button
|
||||
onClick={onTelemetry}
|
||||
className="p-2 rounded hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
title="Submit Telemetry"
|
||||
>
|
||||
<span>📡</span>
|
||||
</button>
|
||||
|
||||
{/* Help dropdown */}
|
||||
<div ref={helpRef} className="relative">
|
||||
<button
|
||||
onClick={() => setShowHelp(!showHelp)}
|
||||
className="p-2 rounded hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
title="Help"
|
||||
>
|
||||
<span>❓</span>
|
||||
</button>
|
||||
{showHelp && (
|
||||
<div className="absolute right-0 top-full mt-2 w-48 bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg shadow-lg z-50 overflow-hidden">
|
||||
<a
|
||||
href="https://github.com/selfhosters-cc/container-census"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-4 py-2 hover:bg-[var(--bg-tertiary)] text-sm"
|
||||
>
|
||||
<span>📚</span> Documentation
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/selfhosters-cc/container-census/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-4 py-2 hover:bg-[var(--bg-tertiary)] text-sm"
|
||||
>
|
||||
<span>💬</span> Give Feedback
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Settings link */}
|
||||
<Link
|
||||
href="/settings"
|
||||
className="p-2 rounded hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
title="Settings"
|
||||
>
|
||||
<span>⚙️</span>
|
||||
</Link>
|
||||
|
||||
{/* Notifications dropdown */}
|
||||
<div ref={notificationRef} className="relative">
|
||||
<button
|
||||
onClick={() => setShowNotifications(!showNotifications)}
|
||||
className="p-2 rounded hover:bg-[var(--bg-tertiary)] transition-colors relative"
|
||||
title="Notifications"
|
||||
>
|
||||
<span>🔔</span>
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-[var(--danger)] text-white text-xs px-1.5 py-0.5 rounded-full min-w-[18px] text-center">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{showNotifications && (
|
||||
<div className="absolute right-0 top-full mt-2 w-80 bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg shadow-lg z-50 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-[var(--border)]">
|
||||
<h4 className="font-medium">Notifications</h4>
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={handleMarkAllRead}
|
||||
className="text-xs text-[var(--accent)] hover:underline"
|
||||
>
|
||||
Mark all read
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-[var(--text-tertiary)] text-sm">
|
||||
No notifications
|
||||
</div>
|
||||
) : (
|
||||
notifications.map(n => (
|
||||
<div
|
||||
key={n.id}
|
||||
className={`px-4 py-2 border-b border-[var(--border)] last:border-0 ${!n.read ? 'bg-[var(--bg-tertiary)]/50' : ''}`}
|
||||
>
|
||||
<div className="text-sm">{n.message}</div>
|
||||
<div className="text-xs text-[var(--text-tertiary)] mt-1">
|
||||
{new Date(n.sent_at).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="px-4 py-2 border-t border-[var(--border)]">
|
||||
<Link
|
||||
href="/notifications"
|
||||
className="text-xs text-[var(--accent)] hover:underline"
|
||||
onClick={() => setShowNotifications(false)}
|
||||
>
|
||||
View all notifications
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,6 @@ const mainNavItems: NavItem[] = [
|
||||
{ href: '/containers', label: 'Containers', icon: '📦' },
|
||||
{ href: '/hosts', label: 'Hosts', icon: '🖥️' },
|
||||
{ href: '/security', label: 'Security', icon: '🛡️' },
|
||||
{ href: '/monitoring', label: 'Monitoring', icon: '📈' },
|
||||
];
|
||||
|
||||
const bottomNavItems: NavItem[] = [
|
||||
|
||||
+41
-4
@@ -116,14 +116,14 @@ export const getNotificationLog = (limit?: number, unread?: boolean) => {
|
||||
const params = new URLSearchParams();
|
||||
if (limit) params.set('limit', String(limit));
|
||||
if (unread) params.set('unread', 'true');
|
||||
return fetchApi<import('@/types').NotificationLog[]>(`/notifications/log?${params}`);
|
||||
return fetchApi<import('@/types').NotificationLog[]>(`/notifications/logs?${params}`);
|
||||
};
|
||||
export const markNotificationRead = (id: number) =>
|
||||
fetchApi<void>(`/notifications/log/${id}/read`, { method: 'PUT' });
|
||||
fetchApi<void>(`/notifications/logs/${id}/read`, { method: 'PUT' });
|
||||
export const markAllNotificationsRead = () =>
|
||||
fetchApi<void>('/notifications/log/read-all', { method: 'POST' });
|
||||
fetchApi<void>('/notifications/logs/read-all', { method: 'PUT' });
|
||||
export const clearOldNotifications = () =>
|
||||
fetchApi<void>('/notifications/log/clear', { method: 'DELETE' });
|
||||
fetchApi<void>('/notifications/logs/clear', { method: 'DELETE' });
|
||||
|
||||
export const getNotificationSilences = () =>
|
||||
fetchApi<import('@/types').NotificationSilence[]>('/notifications/silences');
|
||||
@@ -160,3 +160,40 @@ export const getMetrics = async () => {
|
||||
const response = await fetch('/metrics');
|
||||
return response.text();
|
||||
};
|
||||
|
||||
// Scan
|
||||
export const triggerScan = () =>
|
||||
fetchApi<void>('/scan', { method: 'POST' });
|
||||
|
||||
// Telemetry
|
||||
export const submitTelemetry = () =>
|
||||
fetchApi<void>('/telemetry/submit', { method: 'POST' });
|
||||
|
||||
// Container logs
|
||||
export const getContainerLogs = (hostId: number, containerId: string, tail?: number) =>
|
||||
fetchApi<{ logs: string }>(`/containers/${hostId}/${containerId}/logs${tail ? `?tail=${tail}` : ''}`);
|
||||
|
||||
// Container updates (uses container name, not ID)
|
||||
export const checkContainerUpdate = (hostId: number, containerName: string) =>
|
||||
fetchApi<{ available: boolean; message?: string }>(`/containers/${hostId}/${encodeURIComponent(containerName)}/check-update`, { method: 'POST' });
|
||||
export const updateContainer = (hostId: number, containerName: string) =>
|
||||
fetchApi<{ success: boolean; message?: string; new_container_id?: string }>(`/containers/${hostId}/${encodeURIComponent(containerName)}/update`, { method: 'POST' });
|
||||
|
||||
// Bulk update operations
|
||||
export const bulkCheckUpdates = (containers: Array<{ host_id: number; container_id: string }>) =>
|
||||
fetchApi<Record<string, { available: boolean; message?: string }>>('/containers/bulk-check-updates', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ containers }),
|
||||
});
|
||||
|
||||
export const bulkUpdate = (containers: Array<{ host_id: number; container_id: string }>) =>
|
||||
fetchApi<Record<string, { success: boolean; error?: string; new_container_id?: string }>>('/containers/bulk-update', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ containers }),
|
||||
});
|
||||
|
||||
// Vulnerability trends
|
||||
export const getVulnerabilityTrends = () =>
|
||||
fetchApi<{ date: string; critical: number; high: number; medium: number; low: number }[]>('/vulnerabilities/trends');
|
||||
|
||||
@@ -67,17 +67,30 @@ export interface Image {
|
||||
}
|
||||
|
||||
// Vulnerability types
|
||||
export interface SeverityCounts {
|
||||
critical: number;
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
unknown?: number;
|
||||
}
|
||||
|
||||
export interface VulnerabilityScan {
|
||||
id?: number;
|
||||
image_id: string;
|
||||
image_name: string;
|
||||
scanned_at: string;
|
||||
scan_duration_ms?: number;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
trivy_db_version?: string;
|
||||
total_vulnerabilities: number;
|
||||
critical_count: number;
|
||||
high_count: number;
|
||||
medium_count: number;
|
||||
low_count: number;
|
||||
severity_counts: SeverityCounts;
|
||||
// Legacy flat fields for backward compatibility
|
||||
critical_count?: number;
|
||||
high_count?: number;
|
||||
medium_count?: number;
|
||||
low_count?: number;
|
||||
}
|
||||
|
||||
export interface Vulnerability {
|
||||
@@ -146,10 +159,10 @@ export interface NotificationRule {
|
||||
|
||||
export interface NotificationLog {
|
||||
id: number;
|
||||
channel_id: number;
|
||||
channel_name: string;
|
||||
rule_id: number;
|
||||
rule_name: string;
|
||||
channel_id?: number;
|
||||
channel_name?: string;
|
||||
rule_id?: number;
|
||||
rule_name?: string;
|
||||
event_type: string;
|
||||
container_id?: string;
|
||||
container_name?: string;
|
||||
@@ -157,7 +170,9 @@ export interface NotificationLog {
|
||||
host_name?: string;
|
||||
message: string;
|
||||
read: boolean;
|
||||
created_at: string;
|
||||
sent_at: string;
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface NotificationSilence {
|
||||
|
||||
Reference in New Issue
Block a user