This commit is contained in:
2026-05-17 20:54:53 +05:00
parent 65a0babeab
commit 27eb4fd606
90 changed files with 12343 additions and 0 deletions
+926
View File
@@ -0,0 +1,926 @@
// SVG-мокапы лицевых панелей MikroTik. Подсвечивают живые порты по InterfaceInfo[].
// Сейчас реализован hAP ac lite (RB952Ui-5ac2nD): синий корпус, 5 ethernet,
// первый — PoE in (Internet), 24 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>
);
}