start
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { api } from '@/api/client';
|
||||
|
||||
export default function AboutModal({ onClose }: { onClose: () => void }) {
|
||||
const [info, setInfo] = useState<{ name: string; version: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<{ name: string; version: string }>('/version')
|
||||
.then((r) => setInfo(r.data))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4" onClick={onClose}>
|
||||
<div className="card w-full max-w-md relative" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
className="absolute top-3 right-3 text-mk-mute hover:text-mk-text"
|
||||
onClick={onClose}
|
||||
aria-label="Закрыть"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<img src="/mikrotik-logo.svg" alt="logo" className="w-12 h-12" />
|
||||
<div>
|
||||
<div className="text-lg font-semibold">{info?.name ?? 'ROSzetta'}</div>
|
||||
<div className="text-xs text-mk-mute font-mono">v{info?.version ?? '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-mk-text space-y-2">
|
||||
<div>Контроллер для управления MikroTik / RouterOS устройствами.</div>
|
||||
<div className="pt-3 border-t border-mk-border">
|
||||
<div className="text-xs text-mk-mute uppercase tracking-wider mb-1">Разработчик</div>
|
||||
<div className="font-medium">CoRE group</div>
|
||||
<a
|
||||
href="http://core.uz"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-mk-accent2 hover:underline text-sm"
|
||||
>
|
||||
http://core.uz
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard, Router, LogOut, Info,
|
||||
CheckCircle2, AlertTriangle, Bell, Terminal,
|
||||
Menu, X, Settings as SettingsIcon,
|
||||
ChevronDown, ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '@/store/auth';
|
||||
import { api, Device } from '@/api/client';
|
||||
import AboutModal from './AboutModal';
|
||||
import { useSettings } from '@/store/settings';
|
||||
import { pickOkMessage } from '@/utils/okMessages';
|
||||
import { useT } from '@/i18n';
|
||||
|
||||
type MenuKey =
|
||||
| 'dashboard' | 'devices' | 'switches' | 'firmware' | 'alerts'
|
||||
| 'notif_center' | 'cli' | 'settings';
|
||||
|
||||
type NavChild = {
|
||||
tKey: string;
|
||||
to: string;
|
||||
/** Ключ из settings.menu для гранулярной видимости (если задан). */
|
||||
menuKey?: MenuKey;
|
||||
};
|
||||
|
||||
type NavItem = {
|
||||
/** Ключ родителя для settings.menu (видимость самой группы). */
|
||||
key: MenuKey;
|
||||
/** Куда переходить при клике по самому пункту (или endpoint первого подпункта). */
|
||||
to: string;
|
||||
tKey: string;
|
||||
icon: any;
|
||||
children?: NavChild[];
|
||||
};
|
||||
|
||||
const NAV_TOP: NavItem[] = [
|
||||
{ key: 'dashboard', to: '/dashboard', tKey: 'nav.dashboard', icon: LayoutDashboard },
|
||||
{
|
||||
key: 'devices', to: '/devices', tKey: 'nav.devices', icon: Router,
|
||||
children: [
|
||||
{ menuKey: 'devices', tKey: 'nav.devicesRouters', to: '/devices' },
|
||||
{ menuKey: 'switches', tKey: 'nav.switches', to: '/devices#switches' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'notif_center', to: '/notifications', tKey: 'nav.notifCenter', icon: Bell,
|
||||
children: [
|
||||
{ menuKey: 'alerts', tKey: 'nav.alerts', to: '/notifications#alerts' },
|
||||
{ tKey: 'nav.telegram', to: '/notifications#telegram' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'cli', to: '/cli', tKey: 'nav.automation', icon: Terminal,
|
||||
children: [
|
||||
{ tKey: 'nav.cli', to: '/cli' },
|
||||
{ menuKey: 'firmware', tKey: 'nav.firmware', to: '/cli#firmware' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const NAV_BOTTOM: NavItem[] = [
|
||||
{
|
||||
key: 'settings', to: '/settings', tKey: 'nav.settings', icon: SettingsIcon,
|
||||
children: [
|
||||
{ tKey: 'nav.settingsUsers', to: '/settings#users' },
|
||||
{ tKey: 'nav.settingsPassword', to: '/settings#password' },
|
||||
{ tKey: 'nav.settingsConfig', to: '/settings#config' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Header-виджеты (без изменений по сравнению с предыдущей версией)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
function GlobalHealth() {
|
||||
const [devices, setDevices] = useState<Device[] | null>(null);
|
||||
const settings = useSettings((s) => s.settings);
|
||||
const style = settings?.notify?.style ?? 'jokes';
|
||||
const [okMsg] = useState(() => pickOkMessage());
|
||||
const t = useT();
|
||||
|
||||
useEffect(() => {
|
||||
const load = () =>
|
||||
api.get<Device[]>('/devices').then((r) => setDevices(r.data)).catch(() => {});
|
||||
load();
|
||||
const t = setInterval(load, 30000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
|
||||
if (!devices) return <span className="text-xs text-mk-mute">…</span>;
|
||||
const n = settings?.notify;
|
||||
const problems = devices.filter((d) => {
|
||||
if (n?.device_status !== false && d.status === 'down') return true;
|
||||
if (n?.abnormal_reboot !== false && d.abnormal_reboot) return true;
|
||||
if (n?.internet !== false && d.internet_ok === false) return true;
|
||||
if (d.last_error) return true;
|
||||
return false;
|
||||
}).length;
|
||||
const total = devices.length;
|
||||
if (total === 0) return <span className="text-xs text-mk-mute">{t('health.empty')}</span>;
|
||||
if (problems === 0) {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 bg-mk-ok/15 text-mk-ok text-sm font-medium"
|
||||
title="Global system status"
|
||||
>
|
||||
<CheckCircle2 size={15} /> {t('health.ok')} · {total}
|
||||
{style === 'jokes' && <span className="text-xs opacity-80">· {okMsg}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-mk-err/15 text-mk-err text-sm font-medium">
|
||||
<AlertTriangle size={15} /> {t('health.issues')}: {problems} / {total}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertsBell() {
|
||||
const navigate = useNavigate();
|
||||
const [count, setCount] = useState(0);
|
||||
useEffect(() => {
|
||||
const load = () =>
|
||||
api.get<{ count: number }>('/alerts/unread-count')
|
||||
.then((r) => setCount(r.data.count)).catch(() => {});
|
||||
load();
|
||||
const t = setInterval(load, 20000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
return (
|
||||
<button
|
||||
onClick={() => navigate('/notifications#alerts')}
|
||||
className="relative p-2 hover:bg-white/[0.04] text-mk-text"
|
||||
title="Центр уведомлений"
|
||||
>
|
||||
<Bell size={18} />
|
||||
{count > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 min-w-[16px] h-[16px] px-1 bg-mk-err text-white text-[10px] font-bold flex items-center justify-center">
|
||||
{count > 99 ? '99+' : count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderClock() {
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setNow(new Date()), 1000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
const time = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
const date = now.toLocaleDateString([], { day: '2-digit', month: '2-digit' });
|
||||
return (
|
||||
<span
|
||||
className="hidden sm:inline-flex items-center gap-2 text-[11px] font-mono text-mk-mute px-2 py-0.5 border border-mk-border"
|
||||
title={now.toLocaleString()}
|
||||
>
|
||||
<span className="text-mk-mute/70">{date}</span>
|
||||
<span className="text-mk-text">{time}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function UserMenu({ email }: {
|
||||
email: string | null;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
|
||||
document.addEventListener('mousedown', onDoc);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDoc);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const initials = (email || '?').slice(0, 1).toUpperCase();
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="inline-flex items-center gap-1.5 p-1 pl-1 pr-2 hover:bg-white/[0.04] text-mk-text"
|
||||
title={email ?? ''}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span className="inline-flex items-center justify-center w-7 h-7 rounded-full bg-mk-accent/20 text-mk-accent2 text-xs font-semibold">
|
||||
{initials}
|
||||
</span>
|
||||
<span className="hidden md:inline text-xs text-mk-mute max-w-[140px] truncate">{email ?? '—'}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
className="absolute right-0 mt-1.5 w-64 border border-mk-border bg-mk-panel shadow-xl z-30"
|
||||
role="menu"
|
||||
>
|
||||
<div className="px-3 py-2 border-b border-mk-border">
|
||||
<div className="text-xs text-mk-mute">Вы вошли как</div>
|
||||
<div className="text-sm font-medium truncate" title={email ?? ''}>{email ?? '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Sidebar — стили строк по образцу (Zabbix-like): без скруглений,
|
||||
// активный пункт — тёмная плашка во всю ширину с акцентной полосой
|
||||
// слева; подменю — отдельный блок темнее, чем сама панель.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
const ROW_BASE =
|
||||
'group flex items-center gap-3 w-full px-4 py-2.5 text-[13.5px] transition-colors select-none ' +
|
||||
'border-l-2 border-transparent';
|
||||
const ROW_IDLE = 'text-mk-mute hover:bg-white/[0.04] hover:text-mk-text';
|
||||
const ROW_ACTIVE = 'bg-black/30 text-mk-text border-l-mk-accent';
|
||||
|
||||
const SUBMENU_WRAP = 'bg-black/20 border-y border-black/40';
|
||||
const CHILD_BASE =
|
||||
'flex items-center w-full pl-12 pr-4 py-2 text-[13px] transition-colors ' +
|
||||
'border-l-2 border-transparent';
|
||||
const CHILD_IDLE = 'text-mk-mute hover:bg-white/[0.04] hover:text-mk-text';
|
||||
const CHILD_ACTIVE = 'bg-black/30 text-mk-text border-l-mk-accent';
|
||||
|
||||
function isChildActive(c: NavChild, location: { pathname: string; hash: string }): boolean {
|
||||
const [path, hash] = c.to.split('#');
|
||||
if (location.pathname !== path) return false;
|
||||
const wantHash = hash ? '#' + hash : '';
|
||||
return location.hash === wantHash;
|
||||
}
|
||||
|
||||
function NavGroup({
|
||||
item, t, isVisibleChild,
|
||||
}: {
|
||||
item: NavItem;
|
||||
t: (k: string) => string;
|
||||
isVisibleChild: (c: NavChild) => boolean;
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const isOnParent =
|
||||
location.pathname === item.to || location.pathname.startsWith(item.to + '/');
|
||||
const [open, setOpen] = useState<boolean>(isOnParent);
|
||||
|
||||
useEffect(() => { if (isOnParent) setOpen(true); }, [isOnParent]);
|
||||
|
||||
const visibleChildren = (item.children ?? []).filter(isVisibleChild);
|
||||
if (visibleChildren.length === 0) return null;
|
||||
|
||||
const Caret = open ? ChevronUp : ChevronDown;
|
||||
const parentActive = isOnParent;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={`${ROW_BASE} ${parentActive ? ROW_ACTIVE : ROW_IDLE}`}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<item.icon size={18} className="shrink-0 opacity-90" />
|
||||
<span className="flex-1 text-left truncate">{t(item.tKey)}</span>
|
||||
<Caret size={15} className="opacity-60" />
|
||||
</button>
|
||||
{open && (
|
||||
<div className={SUBMENU_WRAP}>
|
||||
{visibleChildren.map((c) => (
|
||||
<NavLink
|
||||
key={c.to}
|
||||
to={c.to}
|
||||
className={() =>
|
||||
`${CHILD_BASE} ${isChildActive(c, location) ? CHILD_ACTIVE : CHILD_IDLE}`
|
||||
}
|
||||
>
|
||||
<span className="truncate">{t(c.tKey)}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavRow({ item, t }: { item: NavItem; t: (k: string) => string }) {
|
||||
return (
|
||||
<NavLink
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
`${ROW_BASE} ${isActive ? ROW_ACTIVE : ROW_IDLE}`
|
||||
}
|
||||
>
|
||||
<item.icon size={18} className="shrink-0 opacity-90" />
|
||||
<span className="truncate">{t(item.tKey)}</span>
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AppLayout() {
|
||||
const { email, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [aboutOpen, setAboutOpen] = useState(false);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [version, setVersion] = useState<string | null>(null);
|
||||
const settings = useSettings((s) => s.settings);
|
||||
const loadSettings = useSettings((s) => s.load);
|
||||
const t = useT();
|
||||
|
||||
useEffect(() => { setSidebarOpen(false); }, [location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<{ version: string }>('/version').then((r) => setVersion(r.data.version)).catch(() => {});
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
// Видимость родителя — из settings.menu по `key`.
|
||||
const isVisibleGroup = (n: NavItem): boolean =>
|
||||
!settings?.menu || settings.menu[n.key] !== false;
|
||||
// Видимость подпункта — по child.menuKey (если задан). Без menuKey — всегда виден.
|
||||
const isVisibleChild = (c: NavChild): boolean =>
|
||||
!c.menuKey || !settings?.menu || settings.menu[c.menuKey] !== false;
|
||||
|
||||
const topNav = useMemo(() => NAV_TOP.filter(isVisibleGroup), [settings]);
|
||||
const bottomNav = useMemo(() => NAV_BOTTOM.filter(isVisibleGroup), [settings]);
|
||||
|
||||
const onLogout = () => {
|
||||
if (!window.confirm(t('logout.confirm'))) return;
|
||||
logout();
|
||||
navigate('/login', { replace: true });
|
||||
};
|
||||
|
||||
const renderItem = (n: NavItem) =>
|
||||
n.children
|
||||
? <NavGroup key={n.to} item={n} t={t} isVisibleChild={isVisibleChild} />
|
||||
: <NavRow key={n.to} item={n} t={t} />;
|
||||
|
||||
return (
|
||||
<div className="flex h-full relative">
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-30 md:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<aside
|
||||
className={`w-60 shrink-0 bg-mk-panel border-r border-mk-border flex flex-col
|
||||
fixed md:static inset-y-0 left-0 z-40 transition-transform duration-200
|
||||
${sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'}`}
|
||||
>
|
||||
<div className="h-14 flex items-center gap-2 px-4 border-b border-mk-border">
|
||||
<img src="/mikrotik-logo.svg" alt="MikroTik" className="w-6 h-6 shrink-0" />
|
||||
<div className="flex flex-col min-w-0 flex-1 leading-tight">
|
||||
<span className="font-semibold tracking-wide text-sm text-mk-text">ROSzetta</span>
|
||||
{settings?.ui?.instance_name && (
|
||||
<span
|
||||
className="text-[11px] text-mk-mute truncate"
|
||||
title={settings.ui.instance_name}
|
||||
>
|
||||
{settings.ui.instance_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className="md:hidden p-1 text-mk-mute hover:text-mk-text"
|
||||
aria-label="Закрыть меню"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Верхняя часть — основное меню. Прижато к верху, скроллится. */}
|
||||
<nav className="flex-1 overflow-y-auto py-1">
|
||||
{topNav.map(renderItem)}
|
||||
</nav>
|
||||
|
||||
{/* Нижняя часть — Настройки и Выход. Прижата к низу. */}
|
||||
<div className="border-t border-mk-border/70">
|
||||
{bottomNav.map(renderItem)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
className={`${ROW_BASE} ${ROW_IDLE}`}
|
||||
title={email ?? ''}
|
||||
>
|
||||
<LogOut size={18} className="shrink-0 opacity-90" />
|
||||
<span className="truncate">{t('nav.logout')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="flex-1 min-w-0 overflow-auto">
|
||||
<header className="h-12 border-b border-mk-border flex md:grid md:grid-cols-3 items-center gap-2 md:gap-3 px-3 md:px-5 sticky top-0 bg-mk-bg/85 backdrop-blur z-10">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="md:hidden p-1.5 -ml-1 text-mk-text hover:bg-white/[0.04]"
|
||||
aria-label="Открыть меню"
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
<div className="flex items-center min-w-0 flex-1 md:flex-none">
|
||||
{settings?.ui?.instance_name && (
|
||||
<span
|
||||
className="inline-flex items-center text-sm font-medium text-mk-text truncate"
|
||||
title={settings.ui.instance_name}
|
||||
>
|
||||
{settings.ui.instance_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="hidden md:flex items-center justify-center gap-2">
|
||||
<span className="text-sm text-mk-mute whitespace-nowrap">Состояние системы:</span>
|
||||
<GlobalHealth />
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-1 md:gap-2">
|
||||
<span className="hidden lg:inline-flex"><HeaderClock /></span>
|
||||
{version && (
|
||||
<span className="hidden sm:inline-flex text-[11px] text-mk-mute font-mono px-2 py-0.5 border border-mk-border">
|
||||
v{version}
|
||||
</span>
|
||||
)}
|
||||
<AlertsBell />
|
||||
<button
|
||||
onClick={() => setAboutOpen(true)}
|
||||
className="hidden sm:inline-flex p-2 hover:bg-white/[0.04] text-mk-mute hover:text-mk-text"
|
||||
title="О программе"
|
||||
>
|
||||
<Info size={18} />
|
||||
</button>
|
||||
<UserMenu email={email} />
|
||||
</div>
|
||||
</header>
|
||||
<div className="md:hidden px-3 pt-3 flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-mk-mute">Состояние системы:</span>
|
||||
<GlobalHealth />
|
||||
</div>
|
||||
<div className="p-3 md:p-5">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{aboutOpen && <AboutModal onClose={() => setAboutOpen(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { FormEvent, useState } from 'react';
|
||||
import { X, Send, Bot } from 'lucide-react';
|
||||
|
||||
interface Msg {
|
||||
who: 'bot' | 'me';
|
||||
text: string;
|
||||
ts: number;
|
||||
}
|
||||
|
||||
const HINT = `Это заглушка чат-бота. Здесь будет интеграция с Telegram/AI.
|
||||
Можно спрашивать про устройства, настройки, бэкапы.`;
|
||||
|
||||
function botReply(q: string): string {
|
||||
const s = q.toLowerCase();
|
||||
if (/устройств|devices/.test(s)) return 'Список устройств доступен в разделе "Devices".';
|
||||
if (/бэкап|backup/.test(s)) return 'Бэкапы создаются на странице устройства, кнопкой "Backup".';
|
||||
if (/прошив|firmware/.test(s)) return 'Репозиторий прошивок — в левом меню "Прошивки".';
|
||||
if (/привет|hi|hello/.test(s)) return 'Привет! Чем помочь?';
|
||||
return 'Ок, принял. (бот пока в режиме заглушки)';
|
||||
}
|
||||
|
||||
interface ChatBotProps {
|
||||
open?: boolean;
|
||||
onClose?: () => void;
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
export default function ChatBot({ open = true, onClose, embedded = false }: ChatBotProps) {
|
||||
const [msgs, setMsgs] = useState<Msg[]>([
|
||||
{ who: 'bot', text: HINT, ts: Date.now() },
|
||||
]);
|
||||
const [input, setInput] = useState('');
|
||||
|
||||
const send = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const text = input.trim();
|
||||
if (!text) return;
|
||||
const now = Date.now();
|
||||
setMsgs((m) => [...m, { who: 'me', text, ts: now }]);
|
||||
setInput('');
|
||||
setTimeout(() => {
|
||||
setMsgs((m) => [...m, { who: 'bot', text: botReply(text), ts: Date.now() }]);
|
||||
}, 350);
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
const wrapperCls = embedded
|
||||
? 'card p-0 flex flex-col h-[60vh] min-h-[360px]'
|
||||
: 'fixed bottom-5 left-60 z-40 w-80 h-96 card p-0 flex flex-col shadow-2xl';
|
||||
return (
|
||||
<div className={wrapperCls}>
|
||||
<div className="px-4 py-3 border-b border-mk-border flex items-center gap-2">
|
||||
<Bot size={18} className="text-mk-accent2" />
|
||||
<div className="font-medium text-sm">Помощник</div>
|
||||
<span className="ml-2 text-xs text-mk-mute">beta</span>
|
||||
{!embedded && onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="ml-auto p-1 rounded hover:bg-mk-panel2 text-mk-mute hover:text-mk-text"
|
||||
aria-label="Закрыть"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-3 space-y-2 text-sm">
|
||||
{msgs.map((m, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`max-w-[85%] px-3 py-2 rounded-lg whitespace-pre-wrap ${
|
||||
m.who === 'me'
|
||||
? 'ml-auto bg-mk-accent/20 text-mk-text'
|
||||
: 'mr-auto bg-mk-panel2 text-mk-text'
|
||||
}`}
|
||||
>
|
||||
{m.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<form onSubmit={send} className="p-2 border-t border-mk-border flex gap-2">
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Спросите бота…"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
/>
|
||||
<button className="btn-primary" type="submit" aria-label="Отправить">
|
||||
<Send size={14} />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,926 @@
|
||||
// SVG-мокапы лицевых панелей MikroTik. Подсвечивают живые порты по InterfaceInfo[].
|
||||
// Сейчас реализован hAP ac lite (RB952Ui-5ac2nD): синий корпус, 5 ethernet,
|
||||
// первый — PoE in (Internet), 2–4 LAN, 5 — PoE out (оранжевая обводка).
|
||||
|
||||
import { InterfaceInfo } from '@/api/client';
|
||||
|
||||
export interface DeviceMockupProps {
|
||||
/** Имя модели из RouterOS (board-name), например "hAP ac lite". */
|
||||
boardName: string | null | undefined;
|
||||
/** Текущий снимок интерфейсов с устройства. */
|
||||
interfaces: InterfaceInfo[];
|
||||
}
|
||||
|
||||
const isHapAcLite = (b?: string | null): boolean =>
|
||||
!!b && /h\s*A\s*P\s*ac\s*lite/i.test(b);
|
||||
|
||||
const isHapLike = (b?: string | null): boolean => !!b && /\bh\s*A\s*P\b/i.test(b);
|
||||
|
||||
const isRb5009 = (b?: string | null): boolean =>
|
||||
!!b && /RB?\s*5009/i.test(b);
|
||||
|
||||
const isChr = (b?: string | null): boolean =>
|
||||
!!b && /\bCHR\b/i.test(b);
|
||||
|
||||
const isHexS = (b?: string | null): boolean =>
|
||||
!!b && /h\s*EX\s*S|RB?\s*760/i.test(b);
|
||||
|
||||
const isL009 = (b?: string | null): boolean =>
|
||||
!!b && /\bL\s*009/i.test(b);
|
||||
|
||||
const isRb4011 = (b?: string | null): boolean =>
|
||||
!!b && /RB?\s*4011/i.test(b);
|
||||
|
||||
// Найти интерфейс по базовому имени, допуская суффиксы вида `ether1-Uztelecom`,
|
||||
// `ether2_LAN`, `ether3 description` и т.п. Сначала пробуем точное совпадение, потом по префиксу.
|
||||
function findPort(interfaces: InterfaceInfo[], baseName: string): InterfaceInfo | undefined {
|
||||
const exact = interfaces.find((x) => x.name === baseName);
|
||||
if (exact) return exact;
|
||||
const re = new RegExp(`^${baseName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([\\s\\-_.:]|$)`, 'i');
|
||||
return interfaces.find((x) => re.test(x.name));
|
||||
}
|
||||
|
||||
// Цвета порта по статусу.
|
||||
function portColor(it: InterfaceInfo | undefined): { fill: string; stroke: string; label: string } {
|
||||
if (!it) return { fill: '#0c0c0c', stroke: '#3a3a3a', label: 'нет данных' };
|
||||
if (it.disabled) return { fill: '#1a1a1a', stroke: '#5b5b5b', label: 'отключён' };
|
||||
if (it.running) return { fill: '#0a3a14', stroke: '#22c55e', label: 'up' };
|
||||
return { fill: '#1a1a1a', stroke: '#ef4444', label: 'down' };
|
||||
}
|
||||
|
||||
export default function DeviceMockup({ boardName, interfaces }: DeviceMockupProps) {
|
||||
if (isHapAcLite(boardName) || (isHapLike(boardName) && interfaces.filter((it) => /^ether/.test(it.name)).length === 5)) {
|
||||
return <HapAcLiteMockup interfaces={interfaces} />;
|
||||
}
|
||||
if (isRb5009(boardName)) {
|
||||
return <Rb5009Mockup interfaces={interfaces} />;
|
||||
}
|
||||
if (isRb4011(boardName)) {
|
||||
return <Rb4011Mockup interfaces={interfaces} />;
|
||||
}
|
||||
if (isHexS(boardName)) {
|
||||
return <HexSMockup interfaces={interfaces} />;
|
||||
}
|
||||
if (isL009(boardName)) {
|
||||
return <L009Mockup interfaces={interfaces} />;
|
||||
}
|
||||
if (isChr(boardName)) {
|
||||
return <ChrMockup interfaces={interfaces} />;
|
||||
}
|
||||
return (
|
||||
<div className="card text-sm text-mk-mute">
|
||||
Мокап для модели <span className="font-mono">{boardName || '—'}</span> ещё не подготовлен.
|
||||
Статусы интерфейсов смотрите во вкладке «Интерфейсы».
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --------- hAP ac lite ---------
|
||||
|
||||
function HapAcLiteMockup({ interfaces }: { interfaces: InterfaceInfo[] }) {
|
||||
const byName = new Map(interfaces.map((it) => [it.name, it]));
|
||||
// Раскладка портов: ether1 = Internet/PoE in, ether2..ether4 = LAN, ether5 = PoE out.
|
||||
const ports = [
|
||||
{ name: 'ether1', label: 'Internet', poe: 'in' as const },
|
||||
{ name: 'ether2', label: '2', poe: null as const },
|
||||
{ name: 'ether3', label: '3', poe: null as const },
|
||||
{ name: 'ether4', label: '4', poe: null as const },
|
||||
{ name: 'ether5', label: '5', poe: 'out' as const },
|
||||
];
|
||||
|
||||
// Размеры в условных единицах — масштабируются через viewBox.
|
||||
const W = 1180, H = 230;
|
||||
const bodyR = 14;
|
||||
const portW = 130, portH = 110;
|
||||
const firstPortX = 360;
|
||||
const portGap = 12;
|
||||
const portsTopY = 50;
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="text-xs text-mk-mute mb-2">
|
||||
Лицевая панель <b>hAP ac lite</b> · подсветка портов в реальном времени
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{ height: '66px', width: 'auto', maxWidth: '100%', display: 'block' }}
|
||||
>
|
||||
{/* Корпус */}
|
||||
<rect x="2" y="2" width={W - 4} height={H - 4} rx={bodyR} ry={bodyR} fill="#5cb4e5" stroke="#3990c2" strokeWidth="2" />
|
||||
|
||||
{/* Power разъём + подпись */}
|
||||
<text x="60" y="35" fontSize="20" fill="#ffffff" fontWeight="700">Power</text>
|
||||
<circle cx="60" cy="100" r="28" fill="#0a0a0a" stroke="#143d59" strokeWidth="3" />
|
||||
<circle cx="60" cy="100" r="9" fill="#1a1a1a" stroke="#0a0a0a" strokeWidth="2" />
|
||||
<text x="60" y="180" fontSize="13" fill="#ffffff" textAnchor="middle">DC10-28V</text>
|
||||
|
||||
{/* hAPaclite лого */}
|
||||
<text x="225" y="40" fontSize="34" fill="#ffffff" fontWeight="800" fontFamily="Inter, sans-serif">hAP</text>
|
||||
<text x="310" y="27" fontSize="13" fill="#ffffff" fontWeight="700">ac</text>
|
||||
<text x="310" y="42" fontSize="13" fill="#ffffff" fontWeight="700">lite</text>
|
||||
{/* WiFi-дуга над лого */}
|
||||
<path d="M 230 14 Q 260 -2 290 14" fill="none" stroke="#ffffff" strokeWidth="2.5" />
|
||||
|
||||
{/* RES (кнопка с кругом и подписью WPS) */}
|
||||
<circle cx="160" cy="100" r="14" fill="none" stroke="#d04848" strokeWidth="3" />
|
||||
<circle cx="160" cy="100" r="4" fill="#222" />
|
||||
<text x="160" y="78" fontSize="13" fill="#ffffff" textAnchor="middle" fontWeight="700">RES</text>
|
||||
<text x="160" y="135" fontSize="11" fill="#ffffff" textAnchor="middle">WPS</text>
|
||||
|
||||
{/* PWR кнопка (квадрат) */}
|
||||
<text x="210" y="78" fontSize="13" fill="#ffffff" textAnchor="middle" fontWeight="700">PWR</text>
|
||||
<rect x="197" y="88" width="26" height="22" rx="3" fill="#444" stroke="#222" strokeWidth="2" />
|
||||
|
||||
{/* USR светодиод */}
|
||||
<text x="260" y="78" fontSize="13" fill="#ffffff" textAnchor="middle" fontWeight="700">USR</text>
|
||||
<rect x="251" y="92" width="18" height="14" rx="2" fill="#1f6f1f" />
|
||||
|
||||
{/* Тёмная полоса фоны для верхних/нижних лейблов */}
|
||||
<rect x="350" y="8" width={W - 360} height="26" fill="#1c1c1c" />
|
||||
<rect x="350" y="178" width={W - 360} height="40" fill="#1c1c1c" />
|
||||
|
||||
{/* Оранжевая зона PoE out над портом 5 */}
|
||||
<rect
|
||||
x={firstPortX + 4 * (portW + portGap) - 6}
|
||||
y="8"
|
||||
width={portW + 12}
|
||||
height="26"
|
||||
fill="#f0851a"
|
||||
/>
|
||||
{/* Оранжевая зона PoE out внизу */}
|
||||
<rect
|
||||
x={firstPortX + 4 * (portW + portGap) - 6}
|
||||
y="178"
|
||||
width={portW + 12}
|
||||
height="40"
|
||||
fill="#f0851a"
|
||||
/>
|
||||
|
||||
{/* Порты */}
|
||||
{ports.map((p, i) => {
|
||||
const x = firstPortX + i * (portW + portGap);
|
||||
const it = findPort(interfaces, p.name);
|
||||
const col = portColor(it);
|
||||
return (
|
||||
<g key={p.name}>
|
||||
{/* Верхний лейбл (Internet / 2 / 3 / 4 / 5) */}
|
||||
<text
|
||||
x={x + portW / 2}
|
||||
y="27"
|
||||
fontSize="16"
|
||||
fill="#ffffff"
|
||||
fontWeight="700"
|
||||
textAnchor="middle"
|
||||
>
|
||||
{p.label}
|
||||
</text>
|
||||
|
||||
{/* Корпус порта (металлический ободок) */}
|
||||
<rect x={x} y={portsTopY} width={portW} height={portH} rx="6" fill="#d4d4d4" stroke="#888" strokeWidth="1.5" />
|
||||
{/* Внутренний экран порта */}
|
||||
<rect x={x + 8} y={portsTopY + 8} width={portW - 16} height={portH - 16} rx="3" fill={col.fill} stroke={col.stroke} strokeWidth="3" />
|
||||
{/* RJ45 «зубчики» */}
|
||||
<rect x={x + 24} y={portsTopY + 14} width={portW - 48} height="14" fill="#000" />
|
||||
<rect x={x + 30} y={portsTopY + 28} width={portW - 60} height="8" fill="#000" />
|
||||
{/* LED-индикатор (точка) */}
|
||||
<circle
|
||||
cx={x + portW - 18}
|
||||
cy={portsTopY + portH - 16}
|
||||
r="4"
|
||||
fill={it?.running ? '#22c55e' : it?.disabled ? '#777' : '#5a1a1a'}
|
||||
/>
|
||||
{/* Имя интерфейса под портом для понятности */}
|
||||
<text x={x + portW / 2} y={portsTopY + portH - 6} fontSize="10" fill="#999" textAnchor="middle">{p.name}</text>
|
||||
|
||||
{/* Тултип через <title> */}
|
||||
<title>
|
||||
{p.name} ({p.label}){p.poe === 'in' ? ' · PoE in' : p.poe === 'out' ? ' · PoE out' : ''}
|
||||
{'\n'}статус: {col.label}
|
||||
{it?.comment ? `\ncomment: ${it.comment}` : ''}
|
||||
{it?.mac_address ? `\nmac: ${it.mac_address}` : ''}
|
||||
</title>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Нижние подписи: PoE in / LAN / PoE out */}
|
||||
<text x={firstPortX + portW / 2} y="202" fontSize="14" fill="#ffffff" textAnchor="middle" fontWeight="600">PoE in</text>
|
||||
<text x={firstPortX + portW + portGap + (portW * 3 + portGap * 2) / 2} y="202" fontSize="14" fill="#ffffff" textAnchor="middle" fontWeight="600">LAN</text>
|
||||
<text x={firstPortX + 4 * (portW + portGap) + portW / 2} y="202" fontSize="14" fill="#ffffff" textAnchor="middle" fontWeight="600">PoE out</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Легенда */}
|
||||
<div className="flex flex-wrap items-center gap-4 mt-3 text-xs text-mk-mute">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="inline-block w-3 h-3 rounded-sm bg-mk-ok/30 ring-1 ring-mk-ok" /> up (running)
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="inline-block w-3 h-3 rounded-sm bg-mk-err/10 ring-1 ring-mk-err" /> down
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="inline-block w-3 h-3 rounded-sm bg-mk-panel2 ring-1 ring-mk-mute" /> disabled / нет данных
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --------- RB5009UG+S+ ---------
|
||||
// Чёрный корпус, 8 GigE портов (ether1..ether8) + 1 SFP+ (sfp-sfpplus1).
|
||||
// Слева: DC jack 12-57V, кнопка R (reset), USB 3.0 порт.
|
||||
// ether1 — PoE in (жёлтая обводка), ether8 — 2.5GbE (синяя обводка), sfp-sfpplus1 — 10G.
|
||||
|
||||
function Rb5009Mockup({ interfaces }: { interfaces: InterfaceInfo[] }) {
|
||||
const byName = new Map(interfaces.map((it) => [it.name, it]));
|
||||
const W = 520, H = 66;
|
||||
const portW = 32, portH = 32, gap = 3;
|
||||
const portsY = (H - portH) / 2 - 1;
|
||||
const portsStartX = 132;
|
||||
const sfpW = 60;
|
||||
const sfp = findPort(interfaces, 'sfp-sfpplus1') || findPort(interfaces, 'sfpplus1');
|
||||
|
||||
const ports = [
|
||||
{ name: 'ether1', label: '1', accent: 'poe' as const },
|
||||
{ name: 'ether2', label: '2', accent: null as const },
|
||||
{ name: 'ether3', label: '3', accent: null as const },
|
||||
{ name: 'ether4', label: '4', accent: null as const },
|
||||
{ name: 'ether5', label: '5', accent: null as const },
|
||||
{ name: 'ether6', label: '6', accent: null as const },
|
||||
{ name: 'ether7', label: '7', accent: null as const },
|
||||
{ name: 'ether8', label: '8', accent: '2g5' as const },
|
||||
];
|
||||
|
||||
const accentColor = (a: 'poe' | '2g5' | null) =>
|
||||
a === 'poe' ? '#f0851a' : a === '2g5' ? '#2563eb' : null;
|
||||
const sfpX = portsStartX + ports.length * (portW + gap) + 6;
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="text-xs text-mk-mute mb-2">
|
||||
Лицевая панель <b>RB5009UG+S+</b> · подсветка портов в реальном времени
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{ width: `${W}px`, height: '66px', maxWidth: '100%', display: 'block' }}
|
||||
preserveAspectRatio="xMinYMid meet"
|
||||
>
|
||||
{/* Чёрный корпус */}
|
||||
<rect x="1" y="1" width={W - 2} height={H - 2} rx="4" fill="#1a1a1a" stroke="#3a3a3a" strokeWidth="1" />
|
||||
|
||||
{/* DC jack */}
|
||||
<text x="14" y="9" fontSize="3.5" fill="#cccccc" fontWeight="700" textAnchor="middle">12-57V DC</text>
|
||||
<circle cx="14" cy="32" r="9" fill="#0a0a0a" stroke="#444" strokeWidth="0.8" />
|
||||
<circle cx="14" cy="32" r="3" fill="#222" />
|
||||
<text x="14" y="58" fontSize="3" fill="#888" textAnchor="middle">DC IN</text>
|
||||
|
||||
{/* RES */}
|
||||
<text x="38" y="9" fontSize="4" fill="#cccccc" fontWeight="700" textAnchor="middle">R</text>
|
||||
<circle cx="38" cy="22" r="2.5" fill="none" stroke="#d04848" strokeWidth="0.8" />
|
||||
<circle cx="38" cy="22" r="1" fill="#222" />
|
||||
<text x="38" y="58" fontSize="3" fill="#888" textAnchor="middle">RES</text>
|
||||
|
||||
{/* USB 3.0 */}
|
||||
<text x="72" y="9" fontSize="4" fill="#cccccc" fontWeight="700" textAnchor="middle">USB</text>
|
||||
<rect x="56" y="20" width="32" height="22" rx="1" fill="#0a0a0a" stroke="#666" strokeWidth="0.5" />
|
||||
<rect x="58" y="22" width="28" height="18" fill="#1a4b8c" />
|
||||
<rect x="66" y="26" width="12" height="6" fill="#0a0a0a" />
|
||||
<text x="72" y="58" fontSize="3" fill="#888" textAnchor="middle">USB 3.0</text>
|
||||
|
||||
{/* PWR/USR LED */}
|
||||
<circle cx="104" cy="12" r="2" fill="#22c55e" />
|
||||
<text x="104" y="22" fontSize="3" fill="#888" textAnchor="middle">PWR</text>
|
||||
<circle cx="120" cy="12" r="2" fill="#1f6f1f" />
|
||||
<text x="120" y="22" fontSize="3" fill="#888" textAnchor="middle">USR</text>
|
||||
|
||||
{/* Лейблы цифр над портами + полоса акцента (PoE/2.5G) */}
|
||||
{ports.map((p, i) => {
|
||||
const x = portsStartX + i * (portW + gap);
|
||||
const accent = accentColor(p.accent);
|
||||
return (
|
||||
<g key={`lbl-${p.name}`}>
|
||||
{accent && (
|
||||
<rect x={x} y="1" width={portW} height="3" fill={accent} />
|
||||
)}
|
||||
<text x={x + portW / 2} y="10" fontSize="6" fill="#ffffff" fontWeight="800" textAnchor="middle">{p.label}</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Порты */}
|
||||
{ports.map((p, i) => {
|
||||
const x = portsStartX + i * (portW + gap);
|
||||
const it = findPort(interfaces, p.name);
|
||||
const col = portColor(it);
|
||||
return (
|
||||
<g key={p.name}>
|
||||
<rect x={x} y={portsY} width={portW} height={portH} rx="2" fill="#c8c8c8" stroke="#666" strokeWidth="0.5" />
|
||||
<rect x={x + 2} y={portsY + 2} width={portW - 4} height={portH - 4} rx="1" fill={col.fill} stroke={col.stroke} strokeWidth="1.5" />
|
||||
<rect x={x + 6} y={portsY + 4} width={portW - 12} height="5" fill="#000" />
|
||||
<circle cx={x + portW - 4} cy={portsY + portH - 4} r="1.3" fill={it?.running ? '#22c55e' : it?.disabled ? '#777' : '#5a1a1a'} />
|
||||
<title>
|
||||
{p.name} (порт {p.label})
|
||||
{p.accent === 'poe' ? ' · PoE in' : ''}
|
||||
{p.accent === '2g5' ? ' · 2.5 GbE' : ''}
|
||||
{'\n'}статус: {col.label}
|
||||
{it?.comment ? `\ncomment: ${it.comment}` : ''}
|
||||
{it?.mac_address ? `\nmac: ${it.mac_address}` : ''}
|
||||
</title>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* SFP+ слот */}
|
||||
{(() => {
|
||||
const col = portColor(sfp);
|
||||
return (
|
||||
<g>
|
||||
<rect x={sfpX} y="1" width={sfpW} height="3" fill="#7c3aed" />
|
||||
<text x={sfpX + sfpW / 2} y="10" fontSize="6" fill="#ffffff" fontWeight="800" textAnchor="middle">SFP+</text>
|
||||
<rect x={sfpX} y={portsY} width={sfpW} height={portH} rx="2" fill="#1a1a1a" stroke="#666" strokeWidth="0.5" />
|
||||
<rect x={sfpX + 3} y={portsY + 3} width={sfpW - 6} height={portH - 6} rx="1" fill={col.fill} stroke={col.stroke} strokeWidth="1.5" />
|
||||
<rect x={sfpX + 3} y={portsY + 3} width="4" height={portH - 6} fill="#0a0a0a" />
|
||||
<rect x={sfpX + sfpW - 7} y={portsY + 3} width="4" height={portH - 6} fill="#0a0a0a" />
|
||||
<circle cx={sfpX + sfpW - 5} cy={portsY + portH - 4} r="1.3" fill={sfp?.running ? '#22c55e' : sfp?.disabled ? '#777' : '#5a1a1a'} />
|
||||
<text x={sfpX + sfpW / 2} y={H - 2} fontSize="3.5" fill="#888" textAnchor="middle">10G SFP+</text>
|
||||
<title>
|
||||
sfp-sfpplus1 · 10 GbE SFP+
|
||||
{'\n'}статус: {col.label}
|
||||
{sfp?.comment ? `\ncomment: ${sfp.comment}` : ''}
|
||||
</title>
|
||||
</g>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Подписи акцентов снизу */}
|
||||
<text x={portsStartX + portW / 2} y={H - 2} fontSize="3" fill="#f0851a" textAnchor="middle">PoE in</text>
|
||||
<text x={portsStartX + 7 * (portW + gap) + portW / 2} y={H - 2} fontSize="3" fill="#2563eb" textAnchor="middle">2.5G</text>
|
||||
</svg>
|
||||
</div>
|
||||
<MockupLegend />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --------- RB4011iGS+ ---------
|
||||
// Чёрный корпус 1U: слева RESET + PWR LED, затем SFP+ слот, 5 GigE портов (1-5, PoE-in 18-57V на ether1),
|
||||
// центральная LED-матрица статусов (1-5 сверху, 6-10 снизу) и 5 GigE портов (6-10, PoE-out на ether10).
|
||||
|
||||
function Rb4011Mockup({ interfaces }: { interfaces: InterfaceInfo[] }) {
|
||||
const W = 500, H = 66;
|
||||
const portW = 32, portH = 32, gap = 3;
|
||||
const portsY = (H - portH) / 2 - 1;
|
||||
const sfpW = 50;
|
||||
const sfpX = 30;
|
||||
const group1StartX = sfpX + sfpW + 4;
|
||||
const ledBlockW = 24;
|
||||
const ledBlockGap = 4;
|
||||
const group2StartX =
|
||||
group1StartX + 5 * (portW + gap) - gap + ledBlockGap + ledBlockW + ledBlockGap;
|
||||
|
||||
const sfp = findPort(interfaces, 'sfp-sfpplus1') || findPort(interfaces, 'sfpplus1');
|
||||
|
||||
const portsLeft = [
|
||||
{ name: 'ether1', label: '1' },
|
||||
{ name: 'ether2', label: '2' },
|
||||
{ name: 'ether3', label: '3' },
|
||||
{ name: 'ether4', label: '4' },
|
||||
{ name: 'ether5', label: '5' },
|
||||
];
|
||||
const portsRight = [
|
||||
{ name: 'ether6', label: '6' },
|
||||
{ name: 'ether7', label: '7' },
|
||||
{ name: 'ether8', label: '8' },
|
||||
{ name: 'ether9', label: '9' },
|
||||
{ name: 'ether10', label: '10' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="text-xs text-mk-mute mb-2">
|
||||
Лицевая панель <b>RB4011iGS+</b> · подсветка портов в реальном времени
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{ width: `${W}px`, height: '66px', maxWidth: '100%', display: 'block' }}
|
||||
preserveAspectRatio="xMinYMid meet"
|
||||
>
|
||||
{/* Чёрный корпус */}
|
||||
<rect x="1" y="1" width={W - 2} height={H - 2} rx="4" fill="#1a1a1a" stroke="#3a3a3a" strokeWidth="1" />
|
||||
|
||||
{/* RESET кнопка */}
|
||||
<circle cx="10" cy="24" r="3" fill="none" stroke="#d04848" strokeWidth="0.8" />
|
||||
<circle cx="10" cy="24" r="1.2" fill="#222" />
|
||||
<text x="10" y="44" fontSize="3.5" fill="#888" textAnchor="middle">RESET</text>
|
||||
|
||||
{/* PWR LED */}
|
||||
<text x="22" y="20" fontSize="3.5" fill="#cccccc" fontWeight="700" textAnchor="middle">PWR</text>
|
||||
<circle cx="22" cy="26" r="1.6" fill="#22c55e" />
|
||||
|
||||
{/* SFP+ слот */}
|
||||
{(() => {
|
||||
const col = portColor(sfp);
|
||||
return (
|
||||
<g>
|
||||
<rect x={sfpX} y="1" width={sfpW} height="3" fill="#7c3aed" />
|
||||
<text x={sfpX + sfpW / 2} y="10" fontSize="5.5" fill="#ffffff" fontWeight="800" textAnchor="middle">SFP+</text>
|
||||
<rect x={sfpX} y={portsY} width={sfpW} height={portH} rx="2" fill="#1a1a1a" stroke="#666" strokeWidth="0.5" />
|
||||
<rect x={sfpX + 3} y={portsY + 3} width={sfpW - 6} height={portH - 6} rx="1" fill={col.fill} stroke={col.stroke} strokeWidth="1.5" />
|
||||
<rect x={sfpX + 3} y={portsY + 3} width="4" height={portH - 6} fill="#0a0a0a" />
|
||||
<rect x={sfpX + sfpW - 7} y={portsY + 3} width="4" height={portH - 6} fill="#0a0a0a" />
|
||||
<circle cx={sfpX + sfpW - 5} cy={portsY + portH - 4} r="1.3" fill={sfp?.running ? '#22c55e' : sfp?.disabled ? '#777' : '#5a1a1a'} />
|
||||
<text x={sfpX + sfpW / 2} y={H - 2} fontSize="3.5" fill="#aaaaaa" textAnchor="middle">SFP+ 10G</text>
|
||||
<title>
|
||||
sfp-sfpplus1 · 10 GbE SFP+
|
||||
{'\n'}статус: {col.label}
|
||||
{sfp?.comment ? `\ncomment: ${sfp.comment}` : ''}
|
||||
</title>
|
||||
</g>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Акцентная полоска PoE-in над ether1 */}
|
||||
<rect x={group1StartX} y="1" width={portW} height="3" fill="#f0851a" />
|
||||
|
||||
{/* Лейблы цифр над портами 1-5 */}
|
||||
{portsLeft.map((p, i) => {
|
||||
const x = group1StartX + i * (portW + gap);
|
||||
return (
|
||||
<text key={`lbl-${p.name}`} x={x + portW / 2} y="10" fontSize="6" fill="#ffffff" fontWeight="800" textAnchor="middle">
|
||||
{p.label}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
{/* Порты 1-5 */}
|
||||
{portsLeft.map((p, i) => {
|
||||
const x = group1StartX + i * (portW + gap);
|
||||
const it = findPort(interfaces, p.name);
|
||||
const col = portColor(it);
|
||||
const isPoeIn = i === 0;
|
||||
return (
|
||||
<g key={p.name}>
|
||||
<rect x={x} y={portsY} width={portW} height={portH} rx="2" fill="#c8c8c8" stroke="#666" strokeWidth="0.5" />
|
||||
<rect x={x + 2} y={portsY + 2} width={portW - 4} height={portH - 4} rx="1" fill={col.fill} stroke={col.stroke} strokeWidth="1.5" />
|
||||
<rect x={x + 6} y={portsY + 4} width={portW - 12} height="5" fill="#000" />
|
||||
<circle cx={x + portW - 4} cy={portsY + portH - 4} r="1.3" fill={it?.running ? '#22c55e' : it?.disabled ? '#777' : '#5a1a1a'} />
|
||||
<title>
|
||||
{p.name} (порт {p.label}){isPoeIn ? ' · PoE in 18-57V' : ''}
|
||||
{'\n'}статус: {col.label}
|
||||
{it?.comment ? `\ncomment: ${it.comment}` : ''}
|
||||
{it?.mac_address ? `\nmac: ${it.mac_address}` : ''}
|
||||
</title>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Подпись группы 1-5 снизу */}
|
||||
<text
|
||||
x={group1StartX + (5 * (portW + gap) - gap) / 2}
|
||||
y={H - 2}
|
||||
fontSize="3.5"
|
||||
fill="#f0851a"
|
||||
textAnchor="middle"
|
||||
fontWeight="700"
|
||||
>
|
||||
PoE in 18-57V
|
||||
</text>
|
||||
|
||||
{/* Центральная LED-матрица статусов */}
|
||||
{(() => {
|
||||
const lx = group1StartX + 5 * (portW + gap) - gap + ledBlockGap;
|
||||
const cy1 = portsY + 9;
|
||||
const cy2 = portsY + portH - 9;
|
||||
return (
|
||||
<g>
|
||||
<rect x={lx} y={portsY} width={ledBlockW} height={portH} rx="1.5" fill="#0a0a0a" stroke="#444" strokeWidth="0.4" />
|
||||
{[0, 1, 2, 3, 4].map((i) => {
|
||||
const cx = lx + 3.5 + i * 4.2;
|
||||
const top = findPort(interfaces, `ether${i + 1}`);
|
||||
const bot = findPort(interfaces, `ether${i + 6}`);
|
||||
return (
|
||||
<g key={`led-${i}`}>
|
||||
<circle cx={cx} cy={cy1} r="1.3" fill={top?.running ? '#22c55e' : top?.disabled ? '#444' : '#1f3f1f'}>
|
||||
<title>{top ? `ether${i + 1}: ${top.running ? 'up' : top.disabled ? 'disabled' : 'down'}` : `ether${i + 1}: нет данных`}</title>
|
||||
</circle>
|
||||
<circle cx={cx} cy={cy2} r="1.3" fill={bot?.running ? '#22c55e' : bot?.disabled ? '#444' : '#1f3f1f'}>
|
||||
<title>{bot ? `ether${i + 6}: ${bot.running ? 'up' : bot.disabled ? 'disabled' : 'down'}` : `ether${i + 6}: нет данных`}</title>
|
||||
</circle>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Акцентная полоска PoE-out над ether10 */}
|
||||
<rect x={group2StartX + 4 * (portW + gap)} y="1" width={portW} height="3" fill="#f0851a" />
|
||||
|
||||
{/* Лейблы цифр над портами 6-10 */}
|
||||
{portsRight.map((p, i) => {
|
||||
const x = group2StartX + i * (portW + gap);
|
||||
return (
|
||||
<text key={`lbl-${p.name}`} x={x + portW / 2} y="10" fontSize="6" fill="#ffffff" fontWeight="800" textAnchor="middle">
|
||||
{p.label}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
{/* Порты 6-10 */}
|
||||
{portsRight.map((p, i) => {
|
||||
const x = group2StartX + i * (portW + gap);
|
||||
const it = findPort(interfaces, p.name);
|
||||
const col = portColor(it);
|
||||
const isPoeOut = i === 4;
|
||||
return (
|
||||
<g key={p.name}>
|
||||
<rect x={x} y={portsY} width={portW} height={portH} rx="2" fill="#c8c8c8" stroke="#666" strokeWidth="0.5" />
|
||||
<rect x={x + 2} y={portsY + 2} width={portW - 4} height={portH - 4} rx="1" fill={col.fill} stroke={col.stroke} strokeWidth="1.5" />
|
||||
<rect x={x + 6} y={portsY + 4} width={portW - 12} height="5" fill="#000" />
|
||||
<circle cx={x + portW - 4} cy={portsY + portH - 4} r="1.3" fill={it?.running ? '#22c55e' : it?.disabled ? '#777' : '#5a1a1a'} />
|
||||
<title>
|
||||
{p.name} (порт {p.label}){isPoeOut ? ' · PoE out' : ''}
|
||||
{'\n'}статус: {col.label}
|
||||
{it?.comment ? `\ncomment: ${it.comment}` : ''}
|
||||
{it?.mac_address ? `\nmac: ${it.mac_address}` : ''}
|
||||
</title>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Подпись группы 6-10 снизу */}
|
||||
<text
|
||||
x={group2StartX + (5 * (portW + gap) - gap) / 2}
|
||||
y={H - 2}
|
||||
fontSize="3.5"
|
||||
fill="#f0851a"
|
||||
textAnchor="middle"
|
||||
fontWeight="700"
|
||||
>
|
||||
PoE out
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
<MockupLegend />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --------- CHR (Cloud Hosted Router) ---------
|
||||
// Виртуальная машина MikroTik — нет физической панели.
|
||||
// Простой белый прямоугольник: слева лейбл «CHR», справа порты ether* в ряд.
|
||||
// Количество портов — динамическое (сколько отдало устройство).
|
||||
|
||||
function ChrMockup({ interfaces }: { interfaces: InterfaceInfo[] }) {
|
||||
const ports = interfaces
|
||||
.filter((it) => /^ether/i.test(it.name))
|
||||
.sort((a, b) => {
|
||||
const ai = parseInt(a.name.replace(/\D/g, ''), 10) || 0;
|
||||
const bi = parseInt(b.name.replace(/\D/g, ''), 10) || 0;
|
||||
return ai - bi;
|
||||
});
|
||||
|
||||
// Фиксированные размеры: 500×66 px. SVG в viewBox 1:1 пикселям, scale=1.
|
||||
// Порты 30×32 px начинаются после блока «mikrotik» слева, если все не помещаются —
|
||||
// их можно прокрутить горизонтально через overflow-x-auto обёртки.
|
||||
const W = 500;
|
||||
const H = 66;
|
||||
const padX = 6;
|
||||
const labelW = 92;
|
||||
const gap = 4;
|
||||
const portW = 30;
|
||||
const portH = 32;
|
||||
const portsY = (H - portH) / 2 - 2;
|
||||
const portsStartX = padX + labelW + 6;
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="text-xs text-mk-mute mb-2">
|
||||
Виртуальный роутер <b>MikroTik CHR</b> · подсветка портов в реальном времени
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{ width: '500px', height: '66px', maxWidth: '100%', display: 'block' }}
|
||||
preserveAspectRatio="xMinYMid meet"
|
||||
>
|
||||
{/* Белый фон-корпус */}
|
||||
<rect x="1" y="1" width={W - 2} height={H - 2} rx="6" fill="#ffffff" stroke="#cccccc" strokeWidth="1" />
|
||||
|
||||
{/* Лейбл mikrotik слева (шрифт в 2 раза мельче) */}
|
||||
<text x={padX} y={H / 2} fontSize="14" fill="#1a1a1a" fontWeight="800" fontFamily="Inter, sans-serif">mikrotik</text>
|
||||
<text x={padX} y={H / 2 + 12} fontSize="6" fill="#666666">Cloud Hosted Router</text>
|
||||
|
||||
{/* Разделитель */}
|
||||
<line x1={padX + labelW - 4} y1="8" x2={padX + labelW - 4} y2={H - 8} stroke="#dddddd" strokeWidth="1" />
|
||||
|
||||
{/* Порты */}
|
||||
{ports.length === 0 && (
|
||||
<text x={portsStartX + 10} y={H / 2 + 3} fontSize="7" fill="#888888">нет интерфейсов ether*</text>
|
||||
)}
|
||||
{ports.map((it, i) => {
|
||||
const x = portsStartX + i * (portW + gap);
|
||||
const col = portColor(it);
|
||||
// Короткий лейбл — только номер порта (ether7 → "7").
|
||||
const num = (it.name.match(/(\d+)$/) || [, it.name])[1];
|
||||
return (
|
||||
<g key={it.name}>
|
||||
{/* Корпус виртуального порта */}
|
||||
<rect
|
||||
x={x}
|
||||
y={portsY}
|
||||
width={portW}
|
||||
height={portH}
|
||||
rx="3"
|
||||
fill={col.fill}
|
||||
stroke={col.stroke}
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
{/* Номер порта внутри */}
|
||||
<text
|
||||
x={x + portW / 2}
|
||||
y={portsY + portH / 2 + 4}
|
||||
fontSize="12"
|
||||
fill={it.running ? '#86efac' : it.disabled ? '#bbbbbb' : '#fca5a5'}
|
||||
fontWeight="700"
|
||||
textAnchor="middle"
|
||||
fontFamily="monospace"
|
||||
>
|
||||
{num}
|
||||
</text>
|
||||
{/* Имя интерфейса под портом */}
|
||||
<text
|
||||
x={x + portW / 2}
|
||||
y={portsY + portH + 8}
|
||||
fontSize="5"
|
||||
fill="#888888"
|
||||
textAnchor="middle"
|
||||
fontFamily="monospace"
|
||||
>
|
||||
{it.name}
|
||||
</text>
|
||||
|
||||
<title>
|
||||
{it.name}
|
||||
{it.type ? ` · ${it.type}` : ''}
|
||||
{'\n'}статус: {col.label}
|
||||
{it.comment ? `\ncomment: ${it.comment}` : ''}
|
||||
{it.mac_address ? `\nmac: ${it.mac_address}` : ''}
|
||||
</title>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Легенда */}
|
||||
<div className="flex flex-wrap items-center gap-4 mt-3 text-xs text-mk-mute">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="inline-block w-3 h-3 rounded-sm bg-mk-ok/30 ring-1 ring-mk-ok" /> up (running)
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="inline-block w-3 h-3 rounded-sm bg-mk-err/10 ring-1 ring-mk-err" /> down
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="inline-block w-3 h-3 rounded-sm bg-mk-panel2 ring-1 ring-mk-mute" /> disabled / нет данных
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --------- hEX S (RB760iGS) ---------
|
||||
// Тёмно-серый корпус, Power DC + лого, SFP, 5 GigE портов.
|
||||
// ether1 = INTERNET / PoE in, ether2-4 = LAN, ether5 = PoE out (оранжевый), sfp1.
|
||||
|
||||
function HexSMockup({ interfaces }: { interfaces: InterfaceInfo[] }) {
|
||||
const byName = new Map(interfaces.map((it) => [it.name, it]));
|
||||
const W = 320, H = 66;
|
||||
const padX = 4;
|
||||
const portW = 32, portH = 32, gap = 3;
|
||||
const portsY = (H - portH) / 2 - 1;
|
||||
const portsStartX = 96;
|
||||
const sfp = findPort(interfaces, 'sfp1') || findPort(interfaces, 'sfp-sfpplus1');
|
||||
|
||||
const ports = [
|
||||
{ name: 'ether1', label: '1', accent: 'poe-in' as const },
|
||||
{ name: 'ether2', label: '2', accent: null as const },
|
||||
{ name: 'ether3', label: '3', accent: null as const },
|
||||
{ name: 'ether4', label: '4', accent: null as const },
|
||||
{ name: 'ether5', label: '5', accent: 'poe-out' as const },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="text-xs text-mk-mute mb-2">
|
||||
Лицевая панель <b>hEX S</b> · подсветка портов в реальном времени
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{ width: `${W}px`, height: '66px', maxWidth: '100%', display: 'block' }}
|
||||
preserveAspectRatio="xMinYMid meet"
|
||||
>
|
||||
{/* Корпус тёмно-серый */}
|
||||
<rect x="1" y="1" width={W - 2} height={H - 2} rx="4" fill="#3a3f47" stroke="#1f2227" strokeWidth="1" />
|
||||
|
||||
{/* Power разъём + подпись */}
|
||||
<text x="14" y="13" fontSize="5" fill="#dddddd" fontWeight="700">Power</text>
|
||||
<circle cx="14" cy="32" r="7" fill="#0a0a0a" stroke="#222" strokeWidth="0.8" />
|
||||
<circle cx="14" cy="32" r="2.2" fill="#222" />
|
||||
<text x="14" y="48" fontSize="4" fill="#aaaaaa" textAnchor="middle">12-57V DC</text>
|
||||
|
||||
{/* hEX s лого */}
|
||||
<text x="44" y="14" fontSize="11" fill="#ffffff" fontWeight="900" fontFamily="Inter, sans-serif">hEX</text>
|
||||
<text x="68" y="11" fontSize="5" fill="#ffffff" fontWeight="700">s</text>
|
||||
|
||||
{/* SFP слот */}
|
||||
<rect x="42" y="22" width="28" height="22" rx="2" fill="#0a0a0a" stroke="#555" strokeWidth="0.5" />
|
||||
{(() => {
|
||||
const col = portColor(sfp);
|
||||
return <rect x="44" y="24" width="24" height="18" rx="1" fill={col.fill} stroke={col.stroke} strokeWidth="1">
|
||||
<title>{sfp ? `${sfp.name} · SFP\nстатус: ${col.label}` : 'SFP · нет данных'}</title>
|
||||
</rect>;
|
||||
})()}
|
||||
<text x="56" y="52" fontSize="4" fill="#aaaaaa" textAnchor="middle">SFP</text>
|
||||
<text x="56" y="58" fontSize="4" fill="#888888" textAnchor="middle" fontStyle="italic">INTERNET</text>
|
||||
|
||||
{/* Passive/af/at подпись над портом 1 */}
|
||||
<rect x={portsStartX - 1} y="3" width={portW + 2} height="8" rx="2" fill="#1f2227" stroke="#555" strokeWidth="0.4" />
|
||||
<text x={portsStartX + portW / 2} y="9" fontSize="4" fill="#dddddd" fontWeight="700" textAnchor="middle">Passive/af/at</text>
|
||||
|
||||
{/* Оранжевая зона над/под портом 5 (PoE out) */}
|
||||
<rect x={portsStartX + 4 * (portW + gap) - 1} y="0" width={portW + 2} height="12" fill="#f0851a" />
|
||||
<rect x={portsStartX + 4 * (portW + gap) - 1} y={H - 8} width={portW + 2} height="8" fill="#f0851a" />
|
||||
|
||||
{/* Лейблы цифр над портами 2-5 */}
|
||||
{ports.slice(1).map((p, idx) => {
|
||||
const i = idx + 1;
|
||||
const x = portsStartX + i * (portW + gap);
|
||||
return (
|
||||
<text key={p.label} x={x + portW / 2} y="9" fontSize="6" fill="#ffffff" fontWeight="800" textAnchor="middle">
|
||||
{p.label}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Порты */}
|
||||
{ports.map((p, i) => {
|
||||
const x = portsStartX + i * (portW + gap);
|
||||
const it = findPort(interfaces, p.name);
|
||||
const col = portColor(it);
|
||||
return (
|
||||
<g key={p.name}>
|
||||
<rect x={x} y={portsY} width={portW} height={portH} rx="2" fill="#d4d0c4" stroke="#666" strokeWidth="0.5" />
|
||||
<rect x={x + 2} y={portsY + 2} width={portW - 4} height={portH - 4} rx="1" fill={col.fill} stroke={col.stroke} strokeWidth="1.2" />
|
||||
<rect x={x + 6} y={portsY + 4} width={portW - 12} height="5" fill="#000" />
|
||||
<circle cx={x + portW - 4} cy={portsY + portH - 4} r="1.3" fill={it?.running ? '#22c55e' : it?.disabled ? '#777' : '#5a1a1a'} />
|
||||
<title>{p.name} (порт {p.label}){p.accent === 'poe-in' ? ' · PoE in' : p.accent === 'poe-out' ? ' · PoE out' : ''}{'\n'}статус: {col.label}{it?.comment ? `\ncomment: ${it.comment}` : ''}</title>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Нижние подписи */}
|
||||
<text x={portsStartX + portW / 2} y={H - 2} fontSize="3.5" fill="#dddddd" textAnchor="middle">PoE in</text>
|
||||
<text x={portsStartX + (portW + gap) + (3 * (portW + gap) - gap) / 2} y={H - 2} fontSize="3.5" fill="#aaaaaa" textAnchor="middle">LAN</text>
|
||||
<text x={portsStartX + 4 * (portW + gap) + portW / 2} y={H - 2} fontSize="3.5" fill="#ffffff" textAnchor="middle" fontWeight="700">PoE out</text>
|
||||
</svg>
|
||||
</div>
|
||||
<MockupLegend />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --------- L009 (L009UiGS-RM) ---------
|
||||
// Красный 19" rack: RES, DC 24-56V, SFP, USB 3.0, 8 GigE портов.
|
||||
// ether1 = PoE in, ether8 = PoE out (оранжевый), sfp1.
|
||||
|
||||
function L009Mockup({ interfaces }: { interfaces: InterfaceInfo[] }) {
|
||||
const byName = new Map(interfaces.map((it) => [it.name, it]));
|
||||
const W = 480, H = 66;
|
||||
const portW = 36, portH = 32, gap = 3;
|
||||
const portsY = (H - portH) / 2 - 1;
|
||||
// Слева до портов: RES + DC + SFP + USB ≈ 110px
|
||||
const portsStartX = 116;
|
||||
// Между ether4 и ether5 — небольшой визуальный разрыв
|
||||
const groupGap = 8;
|
||||
const sfp = findPort(interfaces, 'sfp1');
|
||||
|
||||
const ports = [
|
||||
{ name: 'ether1', label: '1', accent: 'poe-in' as const },
|
||||
{ name: 'ether2', label: '2', accent: null as const },
|
||||
{ name: 'ether3', label: '3', accent: null as const },
|
||||
{ name: 'ether4', label: '4', accent: null as const },
|
||||
{ name: 'ether5', label: '5', accent: null as const },
|
||||
{ name: 'ether6', label: '6', accent: null as const },
|
||||
{ name: 'ether7', label: '7', accent: null as const },
|
||||
{ name: 'ether8', label: '8', accent: 'poe-out' as const },
|
||||
];
|
||||
|
||||
const xOf = (i: number) => portsStartX + i * (portW + gap) + (i >= 4 ? groupGap : 0);
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="text-xs text-mk-mute mb-2">
|
||||
Лицевая панель <b>L009UiGS</b> · подсветка портов в реальном времени
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{ width: `${W}px`, height: '66px', maxWidth: '100%', display: 'block' }}
|
||||
preserveAspectRatio="xMinYMid meet"
|
||||
>
|
||||
{/* Красный корпус */}
|
||||
<rect x="1" y="1" width={W - 2} height={H - 2} rx="4" fill="#c92020" stroke="#7a1010" strokeWidth="1" />
|
||||
|
||||
{/* RES кнопка */}
|
||||
<text x="10" y="9" fontSize="4" fill="#ffffff" fontWeight="700" textAnchor="middle">RES</text>
|
||||
<circle cx="10" cy="22" r="2.2" fill="none" stroke="#ffffff" strokeWidth="0.8" />
|
||||
<circle cx="10" cy="22" r="0.9" fill="#222" />
|
||||
{/* power led */}
|
||||
<text x="10" y="58" fontSize="3" fill="#ffffff" textAnchor="middle">⏻</text>
|
||||
|
||||
{/* DC разъём */}
|
||||
<text x="28" y="9" fontSize="3.5" fill="#ffffff" textAnchor="middle">24-56 V DC</text>
|
||||
<circle cx="28" cy="32" r="9" fill="#0a0a0a" stroke="#5a0a0a" strokeWidth="1" />
|
||||
<circle cx="28" cy="32" r="3" fill="#222" />
|
||||
<text x="28" y="58" fontSize="3" fill="#ffffff" textAnchor="middle">⊖-⊙-⊕</text>
|
||||
|
||||
{/* SFP слот */}
|
||||
<text x="60" y="9" fontSize="4" fill="#ffffff" fontWeight="700" textAnchor="middle">SFP</text>
|
||||
<rect x="48" y="16" width="24" height="32" rx="1.5" fill="#0a0a0a" stroke="#888" strokeWidth="0.5" />
|
||||
{(() => {
|
||||
const col = portColor(sfp);
|
||||
return <rect x="50" y="18" width="20" height="28" rx="1" fill={col.fill} stroke={col.stroke} strokeWidth="1">
|
||||
<title>{sfp ? `${sfp.name} · SFP\nстатус: ${col.label}` : 'SFP · нет данных'}</title>
|
||||
</rect>;
|
||||
})()}
|
||||
<text x="60" y="58" fontSize="3" fill="#ffffff" textAnchor="middle">SFP</text>
|
||||
|
||||
{/* USB 3.0 */}
|
||||
<text x="92" y="9" fontSize="4" fill="#ffffff" fontWeight="700" textAnchor="middle">USB</text>
|
||||
<rect x="78" y="20" width="28" height="22" rx="1" fill="#0a0a0a" stroke="#888" strokeWidth="0.5" />
|
||||
<rect x="80" y="22" width="24" height="18" fill="#1a4b8c" />
|
||||
<rect x="88" y="26" width="8" height="6" fill="#0a0a0a" />
|
||||
<text x="92" y="58" fontSize="3" fill="#ffffff" textAnchor="middle">USB 3.0</text>
|
||||
|
||||
{/* Оранжевая зона над/под портом 8 (PoE out) */}
|
||||
<rect x={xOf(7) - 1} y="0" width={portW + 2} height="11" fill="#f0851a" />
|
||||
<rect x={xOf(7) - 1} y={H - 8} width={portW + 2} height="8" fill="#f0851a" />
|
||||
|
||||
{/* Лейблы цифр над портами */}
|
||||
{ports.map((p, i) => (
|
||||
<text key={p.label} x={xOf(i) + portW / 2} y="8" fontSize="5.5" fill="#ffffff" fontWeight="800" textAnchor="middle">
|
||||
{p.label}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{/* Порты */}
|
||||
{ports.map((p, i) => {
|
||||
const x = xOf(i);
|
||||
const it = findPort(interfaces, p.name);
|
||||
const col = portColor(it);
|
||||
return (
|
||||
<g key={p.name}>
|
||||
<rect x={x} y={portsY} width={portW} height={portH} rx="2" fill="#d4d0c4" stroke="#666" strokeWidth="0.5" />
|
||||
<rect x={x + 2} y={portsY + 2} width={portW - 4} height={portH - 4} rx="1" fill={col.fill} stroke={col.stroke} strokeWidth="1.2" />
|
||||
<rect x={x + 6} y={portsY + 4} width={portW - 12} height="5" fill="#000" />
|
||||
<circle cx={x + portW - 4} cy={portsY + portH - 4} r="1.3" fill={it?.running ? '#22c55e' : it?.disabled ? '#777' : '#5a1a1a'} />
|
||||
<title>{p.name} (порт {p.label}){p.accent === 'poe-in' ? ' · PoE in' : p.accent === 'poe-out' ? ' · PoE out' : ''}{'\n'}статус: {col.label}{it?.comment ? `\ncomment: ${it.comment}` : ''}</title>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Нижние подписи скоростей */}
|
||||
<text x={xOf(0) + portW / 2} y={H - 2} fontSize="3.5" fill="#ffffff" textAnchor="middle" fontWeight="700">PoE in</text>
|
||||
<text x={xOf(7) + portW / 2} y={H - 2} fontSize="3.5" fill="#ffffff" textAnchor="middle" fontWeight="700">PoE out</text>
|
||||
</svg>
|
||||
</div>
|
||||
<MockupLegend />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Общая мини-легенда для физических мокапов.
|
||||
function MockupLegend() {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3 mt-2 text-[10px] text-mk-mute">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="inline-block w-2 h-2 rounded-sm bg-mk-ok/30 ring-1 ring-mk-ok" /> up
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="inline-block w-2 h-2 rounded-sm bg-mk-err/10 ring-1 ring-mk-err" /> down
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="inline-block w-2 h-2 rounded-sm bg-mk-panel2 ring-1 ring-mk-mute" /> disabled
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Layers, RefreshCw, CheckCircle2, AlertTriangle } from 'lucide-react';
|
||||
import { api, FirmwareChannelsOut } from '@/api/client';
|
||||
|
||||
function fmtDt(s?: string): string {
|
||||
if (!s) return '—';
|
||||
try { return new Date(s).toLocaleString(); } catch { return s; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Самодостаточная карточка «Каналы RouterOS» — сама грузит данные и
|
||||
* умеет запускать проверку обновлений. Используется на дашборде и
|
||||
* во вкладке «Репозиторий прошивок» страницы Автоматизации.
|
||||
*/
|
||||
export default function FirmwareChannelsCard() {
|
||||
const [data, setData] = useState<FirmwareChannelsOut | null>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const reload = () => api.get<FirmwareChannelsOut>('/firmware/channels')
|
||||
.then((r) => setData(r.data)).catch(() => {});
|
||||
|
||||
useEffect(() => { reload(); }, []);
|
||||
|
||||
const onRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await api.post('/firmware/check');
|
||||
await reload();
|
||||
} catch { /* ignore */ }
|
||||
finally { setRefreshing(false); }
|
||||
};
|
||||
|
||||
if (!data) return null;
|
||||
const order = data.available_channels;
|
||||
return (
|
||||
<div className="card space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers size={14} className="text-mk-accent2" />
|
||||
<h3 className="text-sm font-semibold">Каналы RouterOS</h3>
|
||||
<button className="ml-auto btn-ghost !py-1 !text-xs" onClick={onRefresh} disabled={refreshing}>
|
||||
<RefreshCw size={13} className={refreshing ? 'animate-spin' : ''} /> Проверить
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
{order.map((ch) => {
|
||||
const info = data.channels[ch];
|
||||
const ok = info?.last_check_ok !== false && info?.version;
|
||||
return (
|
||||
<div key={ch} className="border border-mk-border rounded-md p-3 bg-mk-panel2/30">
|
||||
<div className="flex items-center gap-2">
|
||||
{ok ? (
|
||||
<CheckCircle2 size={14} className="text-mk-ok" />
|
||||
) : (
|
||||
<AlertTriangle size={14} className="text-mk-warn" />
|
||||
)}
|
||||
<span className="font-medium text-sm">{ch}</span>
|
||||
</div>
|
||||
<div className="text-lg font-semibold mt-1">{info?.version || '—'}</div>
|
||||
<div className="text-[11px] text-mk-mute mt-1">
|
||||
Выпущена: {fmtDt(info?.released_at)}
|
||||
</div>
|
||||
<div className="text-[11px] text-mk-mute">
|
||||
Проверено: {fmtDt(info?.last_check)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user