- Add certbot to getDaemonStatus: checks binary availability and version - Dashboard: add Certificates card to services grid - Sidebar: show green/red status dot for certbot on Certificates nav item
250 lines
11 KiB
TypeScript
250 lines
11 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { NavLink } from 'react-router';
|
|
import { Sun, Moon, Monitor, Shield, Lock, ShieldAlert, ShieldBan, ShieldCheck, Wifi, LayoutDashboard, Globe, Network, ChevronRight, KeyRound } from 'lucide-react';
|
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
|
import {
|
|
Sidebar,
|
|
SidebarContent,
|
|
SidebarFooter,
|
|
SidebarGroup,
|
|
SidebarGroupContent,
|
|
SidebarGroupLabel,
|
|
SidebarHeader,
|
|
SidebarMenu,
|
|
SidebarMenuButton,
|
|
SidebarMenuItem,
|
|
SidebarRail,
|
|
useSidebar,
|
|
} from './ui/sidebar';
|
|
import { useTheme } from '../contexts/ThemeContext';
|
|
import { useCentralStatus } from '../hooks/useCentralStatus';
|
|
|
|
function WildCentralLogo({ className }: { className?: string }) {
|
|
return (
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" className={className} fill="none">
|
|
<circle cx="12" cy="12" r="3" fill="currentColor"/>
|
|
<line x1="12" y1="4" x2="12" y2="8.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
|
<line x1="12" y1="15.5" x2="12" y2="20" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
|
<line x1="4" y1="12" x2="8.5" y2="12" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
|
<line x1="15.5" y1="12" x2="20" y2="12" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
|
<line x1="6.3" y1="6.3" x2="9.4" y2="9.4" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
|
<line x1="14.6" y1="14.6" x2="17.7" y2="17.7" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
|
<line x1="17.7" y1="6.3" x2="14.6" y2="9.4" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
|
<line x1="9.4" y1="14.6" x2="6.3" y2="17.7" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
|
<circle cx="12" cy="3.5" r="1.2" fill="currentColor" opacity="0.6"/>
|
|
<circle cx="12" cy="20.5" r="1.2" fill="currentColor" opacity="0.6"/>
|
|
<circle cx="3.5" cy="12" r="1.2" fill="currentColor" opacity="0.6"/>
|
|
<circle cx="20.5" cy="12" r="1.2" fill="currentColor" opacity="0.6"/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
const ADVANCED_OPEN_KEY = 'wild-central:advanced-open';
|
|
|
|
export function AppSidebar() {
|
|
const { theme, setTheme } = useTheme();
|
|
const { state } = useSidebar();
|
|
const { data: centralStatus } = useCentralStatus();
|
|
const [advancedOpen, setAdvancedOpen] = useState(() => localStorage.getItem(ADVANCED_OPEN_KEY) === 'true');
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem(ADVANCED_OPEN_KEY, String(advancedOpen));
|
|
}, [advancedOpen]);
|
|
|
|
const cycleTheme = () => {
|
|
if (theme === 'light') {
|
|
setTheme('dark');
|
|
} else if (theme === 'dark') {
|
|
setTheme('system');
|
|
} else {
|
|
setTheme('light');
|
|
}
|
|
};
|
|
|
|
const getThemeIcon = () => {
|
|
switch (theme) {
|
|
case 'light':
|
|
return <Sun className="h-4 w-4" />;
|
|
case 'dark':
|
|
return <Moon className="h-4 w-4" />;
|
|
default:
|
|
return <Monitor className="h-4 w-4" />;
|
|
}
|
|
};
|
|
|
|
const getThemeLabel = () => {
|
|
switch (theme) {
|
|
case 'light':
|
|
return 'Light mode';
|
|
case 'dark':
|
|
return 'Dark mode';
|
|
default:
|
|
return 'System theme';
|
|
}
|
|
};
|
|
|
|
const domainsItems = [
|
|
{ to: '/', icon: LayoutDashboard, label: 'Dashboard', end: true },
|
|
{ to: '/domains', icon: Globe, label: 'Domains' },
|
|
];
|
|
|
|
const centralItems = [
|
|
{ to: '/auth', icon: KeyRound, label: 'Authentication', daemon: 'authelia' as const },
|
|
{ to: '/vpn', icon: Lock, label: 'VPN', daemon: 'wireguard' as const },
|
|
{ to: '/firewall', icon: Shield, label: 'Firewall', daemon: 'nftables' as const },
|
|
{ to: '/crowdsec', icon: ShieldAlert, label: 'CrowdSec', daemon: 'crowdsec' as const },
|
|
{ to: '/dhcp', icon: Wifi, label: 'DHCP', daemon: 'dnsmasq' as const },
|
|
{ to: '/dns-filter', icon: ShieldBan, label: 'DNS Filter', daemon: 'dnsmasq' as const },
|
|
];
|
|
|
|
const advancedItems = [
|
|
{ to: '/advanced/haproxy', icon: Network, label: 'HAProxy', daemon: 'haproxy' as const },
|
|
{ to: '/advanced/dnsmasq', icon: Wifi, label: 'dnsmasq', daemon: 'dnsmasq' as const },
|
|
{ to: '/advanced/nftables', icon: Shield, label: 'nftables', daemon: 'nftables' as const },
|
|
{ to: '/advanced/wireguard', icon: Lock, label: 'WireGuard', daemon: 'wireguard' as const },
|
|
{ to: '/advanced/crowdsec', icon: ShieldAlert, label: 'CrowdSec', daemon: 'crowdsec' as const },
|
|
{ to: '/advanced/authelia', icon: KeyRound, label: 'Authelia', daemon: 'authelia' as const },
|
|
{ to: '/advanced/ddns', icon: Globe, label: 'DDNS', daemon: undefined },
|
|
{ to: '/advanced/certificates', icon: ShieldCheck, label: 'Certificates', daemon: 'certbot' as const },
|
|
];
|
|
|
|
return (
|
|
<Sidebar variant="sidebar" collapsible="icon">
|
|
<SidebarHeader>
|
|
<div className="flex items-center justify-center pb-2">
|
|
<div className="p-1 bg-primary/10 rounded-lg">
|
|
<WildCentralLogo className="h-6 w-6" />
|
|
</div>
|
|
<div className="overflow-hidden transition-all duration-200 ease-linear ml-2 max-w-[200px] opacity-100 group-data-[collapsible=icon]:ml-0 group-data-[collapsible=icon]:max-w-0 group-data-[collapsible=icon]:opacity-0">
|
|
<h2 className="text-lg font-bold text-foreground whitespace-nowrap">Wild Central</h2>
|
|
</div>
|
|
</div>
|
|
</SidebarHeader>
|
|
|
|
<SidebarContent>
|
|
{state === 'collapsed' ? (
|
|
<SidebarMenu>
|
|
{[...domainsItems, ...centralItems, ...advancedItems].map((item) => {
|
|
const daemon = 'daemon' in item ? (item as any).daemon : undefined;
|
|
const active = daemon ? centralStatus?.daemons?.[daemon]?.active : undefined;
|
|
return (
|
|
<SidebarMenuItem key={item.to}>
|
|
<NavLink to={item.to} end={'end' in item ? (item as any).end : undefined}>
|
|
{({ isActive }) => (
|
|
<SidebarMenuButton isActive={isActive} tooltip={item.label}>
|
|
<item.icon className="h-4 w-4" />
|
|
<span>{item.label}</span>
|
|
{active !== undefined && (
|
|
<span className={`ml-auto h-1.5 w-1.5 rounded-full ${active ? 'bg-green-500' : 'bg-red-500'}`} />
|
|
)}
|
|
</SidebarMenuButton>
|
|
)}
|
|
</NavLink>
|
|
</SidebarMenuItem>
|
|
);
|
|
})}
|
|
</SidebarMenu>
|
|
) : (
|
|
<>
|
|
<SidebarGroup>
|
|
<SidebarGroupLabel>Domains</SidebarGroupLabel>
|
|
<SidebarGroupContent>
|
|
<SidebarMenu>
|
|
{domainsItems.map(({ to, icon: Icon, label, end }) => (
|
|
<SidebarMenuItem key={to}>
|
|
<NavLink to={to} end={end}>
|
|
{({ isActive }) => (
|
|
<SidebarMenuButton isActive={isActive}>
|
|
<Icon className="h-4 w-4" />
|
|
<span className="truncate">{label}</span>
|
|
</SidebarMenuButton>
|
|
)}
|
|
</NavLink>
|
|
</SidebarMenuItem>
|
|
))}
|
|
</SidebarMenu>
|
|
</SidebarGroupContent>
|
|
</SidebarGroup>
|
|
|
|
<SidebarGroup>
|
|
<SidebarGroupLabel>Central</SidebarGroupLabel>
|
|
<SidebarGroupContent>
|
|
<SidebarMenu>
|
|
{centralItems.map(({ to, icon: Icon, label, daemon }) => {
|
|
const active = centralStatus?.daemons?.[daemon]?.active;
|
|
return (
|
|
<SidebarMenuItem key={to}>
|
|
<NavLink to={to}>
|
|
{({ isActive }) => (
|
|
<SidebarMenuButton isActive={isActive}>
|
|
<Icon className="h-4 w-4" />
|
|
<span className="truncate">{label}</span>
|
|
{active !== undefined && (
|
|
<span className={`ml-auto h-1.5 w-1.5 rounded-full ${active ? 'bg-green-500' : 'bg-red-500'}`} />
|
|
)}
|
|
</SidebarMenuButton>
|
|
)}
|
|
</NavLink>
|
|
</SidebarMenuItem>
|
|
);
|
|
})}
|
|
</SidebarMenu>
|
|
</SidebarGroupContent>
|
|
</SidebarGroup>
|
|
|
|
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
|
<SidebarGroup>
|
|
<CollapsibleTrigger asChild>
|
|
<SidebarGroupLabel className="cursor-pointer select-none">
|
|
Advanced
|
|
<ChevronRight className={`ml-auto h-3.5 w-3.5 transition-transform duration-200 ${advancedOpen ? 'rotate-90' : ''}`} />
|
|
</SidebarGroupLabel>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<SidebarGroupContent>
|
|
<SidebarMenu>
|
|
{advancedItems.map(({ to, icon: Icon, label, daemon }) => {
|
|
const active = daemon ? centralStatus?.daemons?.[daemon]?.active : undefined;
|
|
return (
|
|
<SidebarMenuItem key={to}>
|
|
<NavLink to={to}>
|
|
{({ isActive }) => (
|
|
<SidebarMenuButton isActive={isActive}>
|
|
<Icon className="h-4 w-4" />
|
|
<span className="truncate">{label}</span>
|
|
{active !== undefined && (
|
|
<span className={`ml-auto h-1.5 w-1.5 rounded-full ${active ? 'bg-green-500' : 'bg-red-500'}`} />
|
|
)}
|
|
</SidebarMenuButton>
|
|
)}
|
|
</NavLink>
|
|
</SidebarMenuItem>
|
|
);
|
|
})}
|
|
</SidebarMenu>
|
|
</SidebarGroupContent>
|
|
</CollapsibleContent>
|
|
</SidebarGroup>
|
|
</Collapsible>
|
|
</>
|
|
)}
|
|
</SidebarContent>
|
|
|
|
<SidebarFooter>
|
|
<SidebarMenu>
|
|
<SidebarMenuItem>
|
|
<SidebarMenuButton
|
|
onClick={cycleTheme}
|
|
tooltip={`Current: ${getThemeLabel()}. Click to cycle themes.`}
|
|
>
|
|
{getThemeIcon()}
|
|
<span>{getThemeLabel()}</span>
|
|
</SidebarMenuButton>
|
|
</SidebarMenuItem>
|
|
</SidebarMenu>
|
|
</SidebarFooter>
|
|
<SidebarRail/>
|
|
</Sidebar>
|
|
);
|
|
}
|