Files
EventHubFrontAdmin/src/layouts/ControlCenterLayout.tsx
T
aleksey 2a32465050
CI / test (push) Successful in 6m45s
CI / push-image (push) Failing after 4m38s
CI / ci-done (push) Has been cancelled
feat(admin): VERSION+sha в UI, bake CI и auto stage по sha-*
2026-07-14 21:25:35 +03:00

381 lines
16 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
LayoutDashboard,
Inbox,
Users,
Calendar,
CalendarDays,
AlertTriangle,
Star,
Ban,
Ticket,
CreditCard,
Shield,
FileSearch,
Activity,
ChevronLeft,
ChevronRight,
LogOut,
User,
Search,
Rows3,
Check,
Languages,
} from 'lucide-react';
import { useAuthStore } from '@/store/authStore';
import { useAdminWebSocket } from '@/hooks/useAdminWebSocket';
import { useUpdateProfile } from '@/hooks/useProfile';
import {
filterNavGroupsByRole,
getMenuSelectedKey,
canReceiveWsNotification,
NAV_GROUPS,
type NavItem,
} from '@/config/adminAccess';
import { notify } from '@/lib/notify';
import { cn } from '@/lib/utils';
import { getTableDensity, toggleTableDensity, type TableDensity } from '@/lib/density';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ErrorBoundary } from '@/components/ErrorBoundary';
import { CommandPalette } from '@/components/CommandPalette';
import { useCommandPaletteHotkey } from '@/hooks/useCommandPaletteHotkey';
import { HeaderNodeMetrics } from '@/components/HeaderNodeMetrics';
import { healthApi } from '@/api/healthApi';
function formatBuildLabel(version: string, sha: string): string {
const shortSha = sha && sha !== 'unknown' && sha !== 'dev' ? sha.slice(0, 12) : sha;
return `${version} (${shortSha})`;
}
const navIconByKey: Record<string, React.ComponentType<{ className?: string }>> = {
'/dashboard': LayoutDashboard,
'/inbox/reports': Inbox,
'/inbox/tickets': Ticket,
'/users': Users,
'/calendars': Calendar,
'/events': CalendarDays,
'/reports': AlertTriangle,
'/reviews': Star,
'/banned-words': Ban,
'/tickets': Ticket,
'/subscriptions': CreditCard,
'/admins': Shield,
'/audit': FileSearch,
'/monitoring': Activity,
};
function NavLinkItem({ item, active, collapsed }: { item: NavItem; active: boolean; collapsed: boolean }) {
const { t } = useTranslation();
const Icon = navIconByKey[item.key] ?? LayoutDashboard;
const label = t(item.label);
return (
<Link
to={item.key}
title={collapsed ? label : undefined}
className={cn(
'flex items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors',
collapsed && 'justify-center px-2',
active
? 'bg-sidebar-accent text-white font-medium'
: 'text-sidebar-foreground/80 hover:bg-sidebar-accent/60 hover:text-white'
)}
>
<Icon className="h-4 w-4 shrink-0" />
{!collapsed && <span className="truncate">{label}</span>}
</Link>
);
}
const ControlCenterLayout: React.FC = () => {
useAdminWebSocket();
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const { user, logout } = useAuthStore();
const updateProfile = useUpdateProfile();
const [collapsed, setCollapsed] = useState(false);
const [paletteOpen, setPaletteOpen] = useState(false);
const [density, setDensity] = useState<TableDensity>(() => getTableDensity());
const [apiVersionLabel, setApiVersionLabel] = useState<string | null>(null);
const openPalette = useCallback(() => setPaletteOpen(true), []);
useCommandPaletteHotkey(openPalette);
const adminVersionLabel = formatBuildLabel(
import.meta.env.VITE_APP_VERSION || '0.0',
import.meta.env.VITE_GIT_SHA || 'dev'
);
useEffect(() => {
let cancelled = false;
healthApi
.getAdminHealth()
.then((info) => {
if (cancelled) return;
setApiVersionLabel(
formatBuildLabel(info.version || '0.0', info.git_sha || 'unknown')
);
})
.catch(() => {
if (!cancelled) setApiVersionLabel(null);
});
return () => {
cancelled = true;
};
}, []);
const selectedKey = getMenuSelectedKey(location.pathname);
const groups = useMemo(() => filterNavGroupsByRole(NAV_GROUPS, user?.role), [user?.role]);
useEffect(() => {
const handler = (event: CustomEvent) => {
const msg = event.detail;
if (msg.type === 'report_created') {
if (!canReceiveWsNotification('report_created', user?.role)) return;
const { report_id, target_type, reason } = msg.data || {};
const inboxPath = report_id
? `/inbox/reports?selected=${report_id}`
: '/inbox/reports';
notify.info(
[
report_id ? t('ws.reportId', { id: report_id }) : t('ws.newReport'),
target_type ? t('ws.onTarget', { type: target_type }) : '',
reason ? `(${reason})` : '',
]
.filter(Boolean)
.join(' '),
{
action: {
label: t('ws.open'),
onClick: () => navigate(inboxPath),
},
}
);
} else if (msg.type === 'ticket_created') {
if (!canReceiveWsNotification('ticket_created', user?.role)) return;
const { ticket_id } = msg.data || {};
const inboxPath = ticket_id
? `/inbox/tickets?selected=${ticket_id}`
: '/inbox/tickets';
notify.info(
ticket_id ? t('ws.ticketCreated', { id: ticket_id }) : t('ws.newTicket'),
{
action: {
label: t('ws.open'),
onClick: () => navigate(inboxPath),
},
}
);
}
};
window.addEventListener('admin-ws-message', handler as EventListener);
return () => window.removeEventListener('admin-ws-message', handler as EventListener);
}, [navigate, user?.role, t]);
const handleLogout = async () => {
await logout();
navigate('/login');
};
const handleDensityToggle = () => {
const next = toggleTableDensity();
setDensity(next);
notify.info(next === 'compact' ? t('layout.densityCompact') : t('layout.densityNormal'));
};
const displayName = (() => {
const nick = user?.nickname;
const email = user?.email;
if (nick && nick !== '-' && nick !== 'undefined') return nick;
if (email && email !== '-' && email !== 'undefined') return email;
return user?.id ?? t('common.emDash');
})();
const initials = displayName.slice(0, 2).toUpperCase();
return (
<div className="flex min-h-screen bg-background">
<aside
className={cn(
'flex flex-col border-r bg-[hsl(var(--sidebar))] text-[hsl(var(--sidebar-foreground))] transition-[width]',
collapsed ? 'w-16' : 'w-60'
)}
>
<div className="flex h-14 items-center justify-between px-3 border-b border-white/10">
{!collapsed && (
<span className="font-semibold text-sm truncate">
{import.meta.env.VITE_APP_TITLE || t('common.appTitle')}
</span>
)}
<Button
variant="ghost"
size="icon"
className="text-sidebar-foreground hover:bg-sidebar-accent shrink-0"
onClick={() => setCollapsed((v) => !v)}
>
{collapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
</Button>
</div>
<ScrollArea className="flex-1 px-2 py-3">
<nav className="space-y-4">
{groups.map((group) => (
<div key={group.id}>
{!collapsed && (
<p className="mb-1 px-3 text-[11px] font-semibold uppercase tracking-wider text-sidebar-foreground/50">
{t(group.label)}
</p>
)}
<div className="space-y-0.5">
{group.items.map((item) => (
<NavLinkItem
key={item.key}
item={item}
collapsed={collapsed}
active={selectedKey === item.key}
/>
))}
</div>
</div>
))}
</nav>
</ScrollArea>
</aside>
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex h-14 items-center gap-3 border-b bg-card px-4">
<HeaderNodeMetrics className="min-w-0 flex-1" />
<div className="ml-auto flex shrink-0 items-center gap-1 sm:gap-2">
<Button
variant="outline"
size="sm"
className="hidden sm:inline-flex gap-2 text-muted-foreground"
onClick={openPalette}
>
<Search className="h-3.5 w-3.5" />
{t('layout.search')}
<kbd className="pointer-events-none rounded border bg-muted px-1.5 py-0.5 font-mono text-[10px]">
K
</kbd>
</Button>
<Button
variant="ghost"
size="icon"
className="sm:hidden"
title={t('layout.searchHotkey')}
onClick={openPalette}
>
<Search className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
title={density === 'compact' ? t('layout.densityToNormal') : t('layout.densityToCompact')}
onClick={handleDensityToggle}
>
<Rows3 className="h-4 w-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="gap-2 px-2">
<Avatar className="h-8 w-8">
<AvatarImage src={user?.avatar_url ?? undefined} alt={displayName} />
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<span className="hidden sm:inline max-w-[120px] truncate text-sm font-medium">
{displayName}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>
<div className="flex flex-col">
<span>{displayName}</span>
<span className="text-xs font-normal text-muted-foreground">{user?.role}</span>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => navigate('/profile')}>
<User className="mr-2 h-4 w-4" />
{t('layout.myProfile')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="flex items-center gap-2 text-xs font-normal text-muted-foreground">
<Languages className="h-3.5 w-3.5" />
{t('layout.uiLanguage')}
</DropdownMenuLabel>
<DropdownMenuItem
disabled={updateProfile.isPending}
onClick={() => {
if ((user?.language || 'ru') === 'ru') return;
updateProfile.mutate({ language: 'ru' });
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
(user?.language || 'ru') === 'ru' ? 'opacity-100' : 'opacity-0'
)}
/>
{t('common.langRu')}
</DropdownMenuItem>
<DropdownMenuItem
disabled={updateProfile.isPending}
onClick={() => {
if (user?.language === 'en') return;
updateProfile.mutate({ language: 'en' });
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
user?.language === 'en' ? 'opacity-100' : 'opacity-0'
)}
/>
{t('common.langEn')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="space-y-0.5 text-xs font-normal text-muted-foreground">
<div>{t('layout.adminVersion', { version: adminVersionLabel })}</div>
<div>
{apiVersionLabel
? t('layout.apiVersion', { version: apiVersionLabel })
: t('layout.apiVersionUnavailable')}
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout} className="text-destructive focus:text-destructive">
<LogOut className="mr-2 h-4 w-4" />
{t('layout.logout')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
<main className="cc-main-surface flex-1 overflow-auto p-4 md:p-6">
<ErrorBoundary>
<Outlet />
</ErrorBoundary>
</main>
</div>
<CommandPalette open={paletteOpen} onOpenChange={setPaletteOpen} />
</div>
);
};
export default ControlCenterLayout;