- Cross-platform support (Windows/Linux) with Windows Updates and Winget - Added dependency confirmation workflow and refresh token authentication - New screenshots: History, Live Operations, Windows Agent Details - Local CLI features with terminal output and cache system - Updated known limitations - Proxmox integration is broken - Organized docs to docs/ folder and updated .gitignore - Probably introduced a dozen bugs with Windows agents - stay tuned
340 lines
12 KiB
TypeScript
340 lines
12 KiB
TypeScript
import React, { useState } from 'react';
|
||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||
import {
|
||
LayoutDashboard,
|
||
Computer,
|
||
Package,
|
||
Activity,
|
||
History,
|
||
Settings,
|
||
Menu,
|
||
X,
|
||
LogOut,
|
||
Search,
|
||
RefreshCw,
|
||
Container,
|
||
Bell,
|
||
} from 'lucide-react';
|
||
import { useUIStore, useAuthStore, useRealtimeStore } from '@/lib/store';
|
||
import { cn, formatRelativeTime } from '@/lib/utils';
|
||
|
||
interface LayoutProps {
|
||
children: React.ReactNode;
|
||
}
|
||
|
||
const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||
const location = useLocation();
|
||
const navigate = useNavigate();
|
||
const { sidebarOpen, setSidebarOpen, setActiveTab } = useUIStore();
|
||
const { logout } = useAuthStore();
|
||
const { notifications, markNotificationRead, clearNotifications } = useRealtimeStore();
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [isNotificationDropdownOpen, setIsNotificationDropdownOpen] = useState(false);
|
||
|
||
const unreadCount = notifications.filter(n => !n.read).length;
|
||
|
||
const navigation = [
|
||
{
|
||
name: 'Dashboard',
|
||
href: '/dashboard',
|
||
icon: LayoutDashboard,
|
||
current: location.pathname === '/' || location.pathname === '/dashboard',
|
||
},
|
||
{
|
||
name: 'Agents',
|
||
href: '/agents',
|
||
icon: Computer,
|
||
current: location.pathname.startsWith('/agents'),
|
||
},
|
||
{
|
||
name: 'Updates',
|
||
href: '/updates',
|
||
icon: Package,
|
||
current: location.pathname.startsWith('/updates'),
|
||
},
|
||
{
|
||
name: 'Docker',
|
||
href: '/docker',
|
||
icon: Container,
|
||
current: location.pathname.startsWith('/docker'),
|
||
},
|
||
{
|
||
name: 'Live Operations',
|
||
href: '/live',
|
||
icon: Activity,
|
||
current: location.pathname === '/live',
|
||
},
|
||
{
|
||
name: 'History',
|
||
href: '/history',
|
||
icon: History,
|
||
current: location.pathname === '/history',
|
||
},
|
||
{
|
||
name: 'Settings',
|
||
href: '/settings',
|
||
icon: Settings,
|
||
current: location.pathname === '/settings',
|
||
},
|
||
];
|
||
|
||
const handleLogout = () => {
|
||
logout();
|
||
localStorage.removeItem('auth_token');
|
||
navigate('/login');
|
||
};
|
||
|
||
const handleSearch = (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (searchQuery.trim()) {
|
||
// Navigate to updates page with search query
|
||
navigate(`/updates?search=${encodeURIComponent(searchQuery.trim())}`);
|
||
setSearchQuery('');
|
||
}
|
||
};
|
||
|
||
// Notification helper functions
|
||
const getNotificationIcon = (type: string) => {
|
||
switch (type) {
|
||
case 'success':
|
||
return '✅';
|
||
case 'error':
|
||
return '❌';
|
||
case 'warning':
|
||
return '⚠️';
|
||
default:
|
||
return 'ℹ️';
|
||
}
|
||
};
|
||
|
||
const getNotificationColor = (type: string) => {
|
||
switch (type) {
|
||
case 'success':
|
||
return 'border-green-200 bg-green-50';
|
||
case 'error':
|
||
return 'border-red-200 bg-red-50';
|
||
case 'warning':
|
||
return 'border-yellow-200 bg-yellow-50';
|
||
default:
|
||
return 'border-blue-200 bg-blue-50';
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gray-50 flex">
|
||
{/* Sidebar */}
|
||
<div
|
||
className={cn(
|
||
'fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-lg transform transition-transform duration-200 ease-in-out lg:translate-x-0 lg:static lg:inset-0',
|
||
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||
)}
|
||
>
|
||
<div className="flex items-center justify-between h-16 px-6 border-b border-gray-200">
|
||
<div className="flex items-center space-x-3">
|
||
<div className="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
|
||
<span className="text-white font-bold text-lg">🚩</span>
|
||
</div>
|
||
<h1 className="text-xl font-bold text-gray-900">RedFlag</h1>
|
||
</div>
|
||
<button
|
||
onClick={() => setSidebarOpen(false)}
|
||
className="lg:hidden text-gray-500 hover:text-gray-700"
|
||
>
|
||
<X className="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
|
||
<nav className="mt-6 px-3">
|
||
<div className="space-y-1">
|
||
{navigation.map((item) => {
|
||
const Icon = item.icon;
|
||
return (
|
||
<Link
|
||
key={item.name}
|
||
to={item.href}
|
||
onClick={() => setActiveTab(item.name)}
|
||
className={cn(
|
||
'group flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors',
|
||
item.current
|
||
? 'bg-primary-50 text-primary-700 border-r-2 border-primary-700'
|
||
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
|
||
)}
|
||
>
|
||
<Icon
|
||
className={cn(
|
||
'mr-3 h-5 w-5 flex-shrink-0',
|
||
item.current ? 'text-primary-700' : 'text-gray-400 group-hover:text-gray-500'
|
||
)}
|
||
/>
|
||
{item.name}
|
||
</Link>
|
||
);
|
||
})}
|
||
</div>
|
||
</nav>
|
||
|
||
{/* User section */}
|
||
<div className="absolute bottom-0 left-0 right-0 p-4 border-t border-gray-200">
|
||
<button
|
||
onClick={handleLogout}
|
||
className="flex items-center w-full px-3 py-2 text-sm font-medium text-gray-700 rounded-md hover:bg-gray-50 hover:text-gray-900 transition-colors"
|
||
>
|
||
<LogOut className="mr-3 h-5 w-5 text-gray-400" />
|
||
Logout
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Main content */}
|
||
<div className="flex-1 flex flex-col lg:pl-0">
|
||
{/* Top header */}
|
||
<header className="bg-white shadow-sm border-b border-gray-200">
|
||
<div className="flex items-center justify-between h-16 px-4 sm:px-6 lg:px-8">
|
||
<div className="flex items-center space-x-4 flex-1">
|
||
<button
|
||
onClick={() => setSidebarOpen(true)}
|
||
className="lg:hidden text-gray-500 hover:text-gray-700"
|
||
>
|
||
<Menu className="w-5 h-5" />
|
||
</button>
|
||
|
||
{/* Search */}
|
||
<form onSubmit={handleSearch} className="hidden md:block flex-1 max-w-md">
|
||
<div className="relative">
|
||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||
<input
|
||
type="text"
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
placeholder="Search updates..."
|
||
className="pl-10 pr-4 py-2 w-full border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||
/>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
{/* Header actions - right to left order */}
|
||
<div className="flex items-center space-x-2 ml-4">
|
||
{/* Refresh button */}
|
||
<button
|
||
onClick={() => window.location.reload()}
|
||
className="text-gray-500 hover:text-gray-700 p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||
title="Refresh page"
|
||
>
|
||
<RefreshCw className="w-4 h-4" />
|
||
</button>
|
||
|
||
{/* Notifications */}
|
||
<div className="relative">
|
||
<button
|
||
onClick={() => setIsNotificationDropdownOpen(!isNotificationDropdownOpen)}
|
||
className="text-gray-500 hover:text-gray-700 p-2 rounded-lg hover:bg-gray-100 transition-colors relative"
|
||
title="Notifications"
|
||
>
|
||
<Bell className="w-4 h-4" />
|
||
{unreadCount > 0 && (
|
||
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-600 text-white text-xs rounded-full flex items-center justify-center font-medium">
|
||
{unreadCount > 99 ? '99+' : unreadCount}
|
||
</span>
|
||
)}
|
||
</button>
|
||
|
||
{/* Notifications dropdown */}
|
||
{isNotificationDropdownOpen && (
|
||
<div className="absolute top-12 right-0 w-96 bg-white rounded-lg shadow-lg border border-gray-200 max-h-96 overflow-hidden z-50">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between p-4 border-b border-gray-200">
|
||
<h3 className="font-semibold text-gray-900">Notifications</h3>
|
||
<div className="flex items-center space-x-2">
|
||
{notifications.length > 0 && (
|
||
<button
|
||
onClick={clearNotifications}
|
||
className="text-sm text-gray-500 hover:text-gray-700 transition-colors"
|
||
>
|
||
Clear All
|
||
</button>
|
||
)}
|
||
<button
|
||
onClick={() => setIsNotificationDropdownOpen(false)}
|
||
className="text-gray-500 hover:text-gray-700 transition-colors"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Notifications list */}
|
||
<div className="overflow-y-auto max-h-80">
|
||
{notifications.length === 0 ? (
|
||
<div className="p-8 text-center text-gray-500">
|
||
<Bell className="w-8 h-8 mx-auto mb-2 text-gray-300" />
|
||
<p>No notifications</p>
|
||
</div>
|
||
) : (
|
||
notifications.map((notification) => (
|
||
<div
|
||
key={notification.id}
|
||
className={cn(
|
||
'p-4 border-b border-gray-100 cursor-pointer hover:bg-gray-50 transition-colors',
|
||
!notification.read && 'bg-blue-50 border-l-4 border-l-blue-500',
|
||
getNotificationColor(notification.type)
|
||
)}
|
||
onClick={() => {
|
||
markNotificationRead(notification.id);
|
||
setIsNotificationDropdownOpen(false);
|
||
}}
|
||
>
|
||
<div className="flex items-start space-x-3">
|
||
<div className="flex-shrink-0 mt-0.5 text-lg">
|
||
{getNotificationIcon(notification.type)}
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-sm font-medium text-gray-900">
|
||
{notification.title}
|
||
</p>
|
||
{!notification.read && (
|
||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">
|
||
New
|
||
</span>
|
||
)}
|
||
</div>
|
||
<p className="text-sm text-gray-600 mt-1">
|
||
{notification.message}
|
||
</p>
|
||
<p className="text-xs text-gray-400 mt-2">
|
||
{formatRelativeTime(notification.timestamp)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
{/* Page content */}
|
||
<main className="flex-1 overflow-y-auto">
|
||
<div className="py-6">
|
||
{children}
|
||
</div>
|
||
</main>
|
||
</div>
|
||
|
||
{/* Mobile sidebar overlay */}
|
||
{sidebarOpen && (
|
||
<div
|
||
className="fixed inset-0 z-40 bg-gray-600 bg-opacity-75 lg:hidden"
|
||
onClick={() => setSidebarOpen(false)}
|
||
></div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default Layout; |