Files
ROSzetta/frontend/src/components/DeviceMockup.tsx
T
2026-05-17 23:35:52 +03:00

1073 lines
51 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
// hAP ac² (RBD52G-5HacD2HnD): отличаем по цифре «2» / «²» после «ac»,
// чтобы случайно не перехватить hAP ac lite.
const isHapAc2 = (b?: string | null): boolean =>
!!b && (/h\s*A\s*P\s*ac[\s\^]*[²2]/i.test(b) || /RBD52G/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)) {
return <HapAcLiteMockup interfaces={interfaces} />;
}
if (isHapAc2(boardName)) {
return <HapAc2Mockup interfaces={interfaces} />;
}
if (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>
);
}
// --------- hAP ac² ---------
// Чёрный пластиковый корпус (RBD52G-5HacD2HnD).
// Слева: DC 12-28V, утопленная кнопка res/wps, индикаторы pwr / usr.
// Справа: 5 GigE портов — ether1 «Internet/PoE in», ether2..ether5 «LAN».
// PoE-out нет (в отличие от hAP ac lite).
function HapAc2Mockup({ interfaces }: { interfaces: InterfaceInfo[] }) {
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 },
];
// Соотношение фото задней панели ~4.3:1. При height: 62px ширина ≈ 268px.
const W = 1180, H = 274;
const bodyR = 20;
const portW = 130, portH = 130;
const portGap = 14;
const firstPortX = 410;
const portsTopY = 60;
const lanStartX = firstPortX + portW + portGap;
const lanSpanW = 4 * portW + 3 * portGap;
return (
<div className="card">
<div className="text-xs text-mk-mute mb-2">
Лицевая панель <b>hAP ac²</b> · подсветка портов в реальном времени
</div>
<div className="overflow-x-auto">
<svg
viewBox={`0 0 ${W} ${H}`}
xmlns="http://www.w3.org/2000/svg"
style={{ height: '62px', width: 'auto', maxWidth: '100%', display: 'block' }}
>
{/* Чёрный пластиковый корпус */}
<rect x="2" y="2" width={W - 4} height={H - 4} rx={bodyR} ry={bodyR} fill="#1f1f1f" stroke="#050505" strokeWidth="2" />
{/* Утопленная плашка отсека (чуть темнее, со внутренней тенью обводки) */}
<rect x="20" y="22" width={W - 40} height={H - 64} rx="12" fill="#161616" stroke="#000" strokeWidth="1" />
{/* DC разъём */}
<circle cx="92" cy="148" r="36" fill="#0a0a0a" stroke="#3a3a3a" strokeWidth="3" />
<circle cx="92" cy="148" r="10" fill="#1a1a1a" stroke="#000" strokeWidth="2" />
<text x="92" y="235" fontSize="22" fill="#ffffff" textAnchor="middle" fontWeight="700">DC</text>
<text x="92" y="256" fontSize="14" fill="#cccccc" textAnchor="middle">12-28V</text>
{/* res/wps — утопленная кнопка */}
<circle cx="188" cy="148" r="10" fill="#0a0a0a" stroke="#555" strokeWidth="1.5" />
<circle cx="188" cy="148" r="3" fill="#222" />
<text x="188" y="92" fontSize="14" fill="#ffffff" textAnchor="middle" fontWeight="600">res/wps</text>
{/* pwr LED */}
<circle cx="252" cy="148" r="5" fill="#1f6f1f" />
<text x="252" y="92" fontSize="14" fill="#ffffff" textAnchor="middle" fontWeight="600">pwr</text>
{/* usr LED */}
<circle cx="312" cy="148" r="5" fill="#3a3a3a" />
<text x="312" y="92" fontSize="14" fill="#ffffff" textAnchor="middle" fontWeight="600">usr</text>
{/* Цифры над портами */}
{ports.map((p, i) => {
const x = firstPortX + i * (portW + portGap);
return (
<text
key={`lbl-${p.name}`}
x={x + portW / 2}
y="48"
fontSize="22"
fill="#ffffff"
fontWeight="700"
textAnchor="middle"
>
{p.label}
</text>
);
})}
{/* Порты */}
{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}>
{/* Металлический ободок RJ45 */}
<rect x={x} y={portsTopY} width={portW} height={portH} rx="6" fill="#c8c8c8" stroke="#666" 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 + 16} width={portW - 48} height="18" fill="#000" />
<rect x={x + 32} y={portsTopY + 34} width={portW - 64} height="10" fill="#000" />
{/* LED-индикатор линка */}
<circle
cx={x + portW - 18}
cy={portsTopY + portH - 18}
r="5"
fill={it?.running ? '#22c55e' : it?.disabled ? '#777' : '#5a1a1a'}
/>
{/* Имя интерфейса под портом — мелким шрифтом, чтобы не сливалось с подписями групп */}
<text x={x + portW / 2} y={portsTopY + portH + 16} fontSize="11" fill="#888" textAnchor="middle">
{p.name}
</text>
<title>
{p.name} (порт {p.label}){p.accent === 'poe-in' ? ' · Internet / PoE in' : ' · LAN'}
{'\n'}статус: {col.label}
{it?.comment ? `\ncomment: ${it.comment}` : ''}
{it?.mac_address ? `\nmac: ${it.mac_address}` : ''}
</title>
</g>
);
})}
{/* Группа Internet/PoE in под портом 1 */}
<line x1={firstPortX - 2} y1={H - 36} x2={firstPortX + portW + 2} y2={H - 36} stroke="#9aa0a6" strokeWidth="1.2" />
<circle cx={firstPortX - 2} cy={H - 36} r="3" fill="#9aa0a6" />
<circle cx={firstPortX + portW + 2} cy={H - 36} r="3" fill="#9aa0a6" />
<text x={firstPortX + portW / 2} y={H - 14} fontSize="14" fill="#ffffff" textAnchor="middle" fontWeight="600">
Internet/PoE in
</text>
{/* Группа LAN под портами 2-5 */}
<line x1={lanStartX - 2} y1={H - 36} x2={lanStartX + lanSpanW + 2} y2={H - 36} stroke="#9aa0a6" strokeWidth="1.2" />
<circle cx={lanStartX - 2} cy={H - 36} r="3" fill="#9aa0a6" />
<circle cx={lanStartX + lanSpanW + 2} cy={H - 36} r="3" fill="#9aa0a6" />
<text x={lanStartX + lanSpanW / 2} y={H - 14} fontSize="14" fill="#ffffff" textAnchor="middle" fontWeight="600">
LAN
</text>
</svg>
</div>
<MockupLegend />
</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>
);
}