Switched to a Next JS interface and simplified the UI

This commit is contained in:
Self Hosters
2025-12-02 21:22:02 -05:00
parent fe2081d97e
commit e7ed72dbb8
16 changed files with 1830 additions and 292 deletions
+5 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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;
+2 -2
View File
@@ -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)]">
+4 -7
View File
@@ -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>
);
+5 -2
View File
@@ -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>
+3 -35
View File
@@ -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
View File
@@ -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>
)}
+106 -61
View File
@@ -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>
);
}
+223
View File
@@ -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
View File
@@ -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');
+24 -9
View File
@@ -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 {