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
+11
View File
@@ -0,0 +1,11 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev"]
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<link rel="icon" type="image/svg+xml" href="/mikrotik-logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ROSzetta</title>
</head>
<body class="bg-unifi-bg text-unifi-text">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2803
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "mikrotik-controller-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc -b && vite build",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"axios": "^1.7.7",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.27.0",
"zustand": "^5.0.0",
"lucide-react": "^0.453.0",
"recharts": "^2.13.0"
},
"devDependencies": {
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.2",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.13",
"typescript": "^5.6.2",
"vite": "^5.4.8"
}
}
+3
View File
@@ -0,0 +1,3 @@
export default {
plugins: { tailwindcss: {}, autoprefixer: {} },
};
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<!-- Стилизованный логотип в духе MikroTik: квадрат с голубой буквой M -->
<rect x="2" y="2" width="60" height="60" rx="10" fill="#0b0e14" stroke="#1b78ff" stroke-width="2"/>
<path d="M12 48 V20 L24 36 L32 24 L40 36 L52 20 V48"
stroke="#1b78ff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<circle cx="32" cy="52" r="2.4" fill="#1b78ff"/>
</svg>

After

Width:  |  Height:  |  Size: 553 B

+48
View File
@@ -0,0 +1,48 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import { useAuth } from '@/store/auth';
import Login from '@/pages/Login';
import AppLayout from '@/components/AppLayout';
import Dashboard from '@/pages/Dashboard';
import DevicesIndex from '@/pages/DevicesIndex';
import DeviceDetail from '@/pages/DeviceDetail';
import AlertsPage from '@/pages/Alerts';
import CLIPage from '@/pages/CLI';
import NotificationCenter from '@/pages/NotificationCenter';
import SettingsPage from '@/pages/Settings';
function Protected({ children }: { children: JSX.Element }) {
const token = useAuth((s) => s.accessToken);
if (!token) return <Navigate to="/login" replace />;
return children;
}
export default function App() {
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
<Protected>
<AppLayout />
</Protected>
}
>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="devices" element={<DevicesIndex />} />
<Route path="devices/:id" element={<DeviceDetail />} />
<Route path="switches" element={<Navigate to="/devices#switches" replace />} />
<Route path="firmware" element={<Navigate to="/cli#firmware" replace />} />
<Route path="notifications" element={<NotificationCenter />} />
<Route path="alerts" element={<AlertsPage />} />
<Route path="cli" element={<CLIPage />} />
<Route path="audit" element={<Navigate to="/notifications" replace />} />
<Route path="logs" element={<Navigate to="/dashboard" replace />} />
<Route path="network_map" element={<Navigate to="/dashboard" replace />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
+234
View File
@@ -0,0 +1,234 @@
import axios from 'axios';
import { useAuth } from '@/store/auth';
export const api = axios.create({
baseURL: '/api/v1',
timeout: 15000,
});
api.interceptors.request.use((config) => {
const token = useAuth.getState().accessToken;
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
api.interceptors.response.use(
(r) => r,
(err) => {
if (err?.response?.status === 401) {
useAuth.getState().logout();
}
return Promise.reject(err);
},
);
export interface Device {
id: number;
name: string;
kind: 'router' | 'switch' | string;
host: string;
port: number;
use_tls: boolean;
username: string;
identity: string | null;
model: string | null;
serial: string | null;
ros_version: string | null;
architecture: string | null;
status: 'up' | 'down' | 'unknown' | string;
last_error: string | null;
last_seen: string | null;
internet_ok: boolean | null;
last_uptime_seconds: number | null;
abnormal_reboot: boolean;
last_log_warning: string | null;
monitored_interfaces: string | null;
uplink_interfaces: string | null;
interface_history_hours: number;
created_at: string;
}
export interface InterfaceInfo {
name: string;
rx_bytes: number;
tx_bytes: number;
running: boolean;
disabled?: boolean;
type: string | null;
comment: string | null;
mac_address?: string | null;
}
export interface InterfaceTrafficPoint {
ts: string;
rx_bps: number | null;
tx_bps: number | null;
running: boolean;
}
export interface InterfaceTrafficOut {
series: Record<string, InterfaceTrafficPoint[]>;
hours: number;
}
export interface UplinkStatus {
name: string;
running: boolean | null;
ts: string | null;
}
export interface DhcpLease {
address: string;
mac_address: string;
host_name: string | null;
comment: string | null;
server: string | null;
status: string | null;
dynamic: boolean;
blocked: boolean;
last_seen: string | null;
expires_after: string | null;
}
export interface DeviceResource {
cpu_load: number | null;
free_memory: number | null;
total_memory: number | null;
uptime: string | null;
version: string | null;
board_name: string | null;
architecture_name: string | null;
}
export interface DeviceBackup {
id: number;
device_id: number;
filename: string;
fmt: 'binary' | 'text' | string;
size: number;
created_at: string;
}
export interface Firmware {
id: number;
name: string;
version: string | null;
architecture: string | null;
channel: string | null;
size: number;
sha256: string | null;
source_url: string | null;
created_at: string;
}
export interface Alert {
id: number;
severity: 'info' | 'warning' | 'error' | 'critical' | string;
category: string;
source: string | null;
title: string;
message: string | null;
acknowledged: boolean;
created_at: string;
}
export interface MetricPoint {
ts: string;
cpu_load: number | null;
mem_used_pct: number | null;
uptime_seconds: number | null;
internet_ok: boolean | null;
rx_bps: number | null;
tx_bps: number | null;
}
export interface CLIDeviceResult {
device_id: number;
device_name: string | null;
ok: boolean;
rows: Record<string, unknown>[] | null;
error: string | null;
}
export interface CLIRunOut {
command: string;
results: CLIDeviceResult[];
}
export interface AppSettings {
ui: {
instance_name: string;
locale: 'ru' | 'en' | 'uz' | string;
theme: string;
heartbeat_hours: number;
probe_interval_minutes: number;
};
menu: {
dashboard: boolean;
devices: boolean;
switches: boolean;
firmware: boolean;
notif_center: boolean;
cli: boolean;
settings: boolean;
};
notify: {
device_status: boolean;
internet: boolean;
abnormal_reboot: boolean;
firmware: boolean;
style: 'jokes' | 'serious';
};
telegram: {
enabled: boolean;
bot_token: string;
chat_id: string;
min_severity: 'info' | 'warning' | 'error' | 'critical' | string;
};
}
export interface FirmwareChannelInfo {
version?: string;
released_at?: string;
last_check?: string;
last_check_ok?: boolean;
}
export interface FirmwareChannelsOut {
channels: Record<string, FirmwareChannelInfo>;
available_channels: string[];
architectures: string[];
}
export interface FirmwareBulkResult {
architecture: string;
ok: boolean;
firmware_id: number | null;
error: string | null;
skipped?: boolean;
}
export interface FirmwareBulkOut {
version: string;
channel: string | null;
results: FirmwareBulkResult[];
}
export type HeartbeatBucket = 'up' | 'no-net' | 'down' | 'none';
export interface HeartbeatDevice {
id: number;
name: string;
host: string;
status: string;
buckets: HeartbeatBucket[];
}
export interface HeartbeatOut {
since: string;
until: string;
bins: number;
hours: number;
devices: HeartbeatDevice[];
}
+49
View File
@@ -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>
);
}
+457
View File
@@ -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>
);
}
+93
View File
@@ -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>
);
}
+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>
);
}
@@ -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>
);
}
+165
View File
@@ -0,0 +1,165 @@
// Минимальный i18n: словарь + хук useT(). Без внешних зависимостей.
import { useSettings } from '../store/settings';
export type Locale = 'ru' | 'en' | 'uz';
const dict: Record<Locale, Record<string, string>> = {
ru: {
'nav.dashboard': 'Дашборд',
'nav.devices': 'Мониторинг',
'nav.devicesRouters': 'Роутеры',
'nav.firmware': 'Прошивки',
'nav.alerts': 'Алерты',
'nav.cli': 'CLI',
'nav.automation':'Автоматизация',
'nav.switches': 'Свичи',
'nav.audit': 'Аудит',
'nav.logs': 'Просмотр логов',
'nav.notifCenter':'Центр уведомлений',
'nav.telegram': 'Telegram',
'nav.settings': 'Настройки',
'nav.settingsUsers': 'Пользователи',
'nav.settingsPassword': 'Смена пароля',
'nav.settingsConfig': 'Конфигурация',
'nav.logout': 'Выйти',
'logout.confirm':'Выйти из системы?',
'health.ok': 'Всё ОК',
'health.issues': 'Проблем',
'health.empty': 'Нет устройств',
'settings.title': 'Настройки',
'settings.identity': 'Идентификация установки',
'settings.identity.hint': 'Это название отображается в шапке интерфейса.',
'settings.instanceName': 'Название установки',
'settings.locale': 'Язык интерфейса',
'settings.theme': 'Тема оформления',
'settings.menu': 'Видимость пунктов меню',
'settings.notify': 'Уведомления',
'settings.telegram': 'Telegram-бот',
'settings.heartbeat': 'Окно Heartbeat на дашборде',
'settings.heartbeat.hint':'Сколько времени отображается в сетке состояния устройств.',
'settings.probe': 'Автоматический опрос устройств',
'settings.probe.hint': 'Как часто опрашивать все устройства (сбор метрик, статуса, интернета).',
'common.save': 'Сохранить',
'common.saved': 'Сохранено',
'common.cancel': 'Отмена',
},
en: {
'nav.dashboard': 'Dashboard',
'nav.devices': 'Monitoring',
'nav.devicesRouters': 'Routers',
'nav.firmware': 'Firmware',
'nav.alerts': 'Alerts',
'nav.cli': 'CLI',
'nav.automation':'Automation',
'nav.switches': 'Switches',
'nav.audit': 'Audit',
'nav.logs': 'View logs',
'nav.notifCenter':'Notification Center',
'nav.telegram': 'Telegram',
'nav.settings': 'Settings',
'nav.settingsUsers': 'Users',
'nav.settingsPassword': 'Change password',
'nav.settingsConfig': 'Configuration',
'nav.logout': 'Logout',
'logout.confirm':'Sign out?',
'health.ok': 'All OK',
'health.issues': 'Issues',
'health.empty': 'No devices',
'settings.title': 'Settings',
'settings.identity': 'Installation identity',
'settings.identity.hint': 'This name is shown in the header.',
'settings.instanceName': 'Installation name',
'settings.locale': 'Interface language',
'settings.theme': 'Theme',
'settings.menu': 'Menu items visibility',
'settings.notify': 'Notifications',
'settings.telegram': 'Telegram bot',
'settings.heartbeat': 'Dashboard Heartbeat window',
'settings.heartbeat.hint':'How much history is shown in the device heartbeat grid.',
'settings.probe': 'Automatic device polling',
'settings.probe.hint': 'How often to probe all devices (metrics, status, internet check).',
'common.save': 'Save',
'common.saved': 'Saved',
'common.cancel': 'Cancel',
},
uz: {
'nav.dashboard': 'Boshqaruv paneli',
'nav.devices': 'Monitoring',
'nav.devicesRouters': 'Routerlar',
'nav.firmware': 'Proshivkalar',
'nav.alerts': 'Ogohlantirishlar',
'nav.cli': 'CLI',
'nav.automation':'Avtomatlashtirish',
'nav.switches': 'Switchlar',
'nav.audit': 'Audit',
'nav.logs': "Loglarni ko'rish",
'nav.notifCenter':'Bildirishnomalar markazi',
'nav.telegram': 'Telegram',
'nav.settings': 'Sozlamalar',
'nav.settingsUsers': 'Foydalanuvchilar',
'nav.settingsPassword': "Parolni o'zgartirish",
'nav.settingsConfig': 'Konfiguratsiya',
'nav.logout': 'Chiqish',
'logout.confirm':'Tizimdan chiqasizmi?',
'health.ok': "Hammasi joyida",
'health.issues': 'Muammolar',
'health.empty': "Qurilmalar yo'q",
'settings.title': 'Sozlamalar',
'settings.identity': 'Tizim identifikatsiyasi',
'settings.identity.hint': 'Bu nom interfeys sarlavhasida ko\'rsatiladi.',
'settings.instanceName': 'Tizim nomi',
'settings.locale': 'Interfeys tili',
'settings.theme': 'Mavzu',
'settings.menu': 'Menyu elementlari ko\'rinishi',
'settings.notify': 'Bildirishnomalar',
'settings.telegram': 'Telegram bot',
'settings.heartbeat': 'Boshqaruv panelidagi Heartbeat oynasi',
'settings.heartbeat.hint':'Qurilmalar holati panelida qancha vaqt ko\'rsatiladi.',
'settings.probe': 'Qurilmalarni avtomatik so\'rash',
'settings.probe.hint': 'Barcha qurilmalar qanchalik tez-tez so\'raladi (metrikalar, holat, internet).',
'common.save': 'Saqlash',
'common.saved': 'Saqlandi',
'common.cancel': 'Bekor qilish',
},
};
export function t(locale: Locale, key: string): string {
return dict[locale]?.[key] ?? dict.ru[key] ?? key;
}
export function useT() {
const locale = useSettings((s) => (s.settings?.ui?.locale as Locale) ?? 'ru');
return (key: string) => t(locale, key);
}
export const LOCALES: { code: Locale; label: string }[] = [
{ code: 'ru', label: 'Русский' },
{ code: 'en', label: 'English' },
{ code: 'uz', label: "O'zbekcha" },
];
export const THEMES: { id: string; label: string; swatch: [string, string, string] }[] = [
{ id: 'mk-dark', label: 'ROSzetta Dark', swatch: ['#0b0e14', '#11151c', '#1b78ff'] },
{ id: 'abyss', label: 'Abyss (VS Code)', swatch: ['#000c18', '#051336', '#4d9cff'] },
{ id: 'midnight', label: 'Midnight', swatch: ['#0a0f1f', '#121a30', '#5b6cff'] },
{ id: 'dracula', label: 'Dracula', swatch: ['#282a36', '#343746', '#bd93f9'] },
{ id: 'light', label: 'Light', swatch: ['#ffffff', '#f5f6f8', '#1b78ff'] },
{ id: 'solarized-light', label: 'Solarized Light', swatch: ['#fdf6e3', '#eee8d5', '#268bd2'] },
];
// Доступные окна Heartbeat (в часах).
export const HEARTBEAT_RANGES: { hours: number; label: string }[] = [
{ hours: 6, label: '6ч' },
{ hours: 3, label: '3ч' },
{ hours: 1, label: '1ч' },
{ hours: 0.5, label: '30м' },
];
// Допустимые интервалы автоопроса устройств (мин).
export const PROBE_INTERVALS: { minutes: number; label: string }[] = [
{ minutes: 1, label: '1 мин' },
{ minutes: 2, label: '2 мин' },
{ minutes: 3, label: '3 мин' },
{ minutes: 5, label: '5 мин' },
{ minutes: 10, label: '10 мин' },
];
+131
View File
@@ -0,0 +1,131 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ── Темы оформления ──────────────────────────────────────────────── */
/* Значения цветов хранятся как raw "R G B", чтобы Tailwind мог применять opacity-модификаторы (/15 и т.п.) */
/* По умолчанию — ROSzetta Dark */
:root,
[data-theme='mk-dark'],
[data-theme='unifi-dark'] {
--c-bg: 11 15 23;
--c-panel: 17 22 34;
--c-panel2: 24 31 46;
--c-border: 38 47 65;
--c-text: 230 236 245;
--c-mute: 130 145 170;
--c-accent: 37 99 235;
--c-accent2: 59 130 246;
--c-ok: 34 197 94;
--c-warn: 245 158 11;
--c-err: 239 68 68;
}
/* VS Code Abyss */
[data-theme='abyss'] {
--c-bg: 0 12 24;
--c-panel: 5 19 54;
--c-panel2: 8 34 82;
--c-border: 19 68 151;
--c-text: 102 136 204;
--c-mute: 64 99 133;
--c-accent: 47 126 255;
--c-accent2: 77 156 255;
--c-ok: 34 197 94;
--c-warn: 245 158 11;
--c-err: 255 83 112;
}
/* Midnight (тёмно-синий) */
[data-theme='midnight'] {
--c-bg: 10 15 31;
--c-panel: 18 26 48;
--c-panel2: 26 35 64;
--c-border: 36 48 89;
--c-text: 216 222 240;
--c-mute: 119 133 168;
--c-accent: 91 108 255;
--c-accent2: 138 150 255;
--c-ok: 34 197 94;
--c-warn: 245 158 11;
--c-err: 239 68 68;
}
/* Dracula-like */
[data-theme='dracula'] {
--c-bg: 40 42 54;
--c-panel: 52 55 70;
--c-panel2: 61 64 83;
--c-border: 74 77 99;
--c-text: 248 248 242;
--c-mute: 138 141 166;
--c-accent: 189 147 249;
--c-accent2: 255 121 198;
--c-ok: 80 250 123;
--c-warn: 241 250 140;
--c-err: 255 85 85;
}
/* Light */
[data-theme='light'] {
--c-bg: 255 255 255;
--c-panel: 245 246 248;
--c-panel2: 236 239 244;
--c-border: 214 218 226;
--c-text: 26 32 44;
--c-mute: 107 114 128;
--c-accent: 5 89 201;
--c-accent2: 27 120 255;
--c-ok: 22 163 74;
--c-warn: 217 119 6;
--c-err: 220 38 38;
}
/* Solarized Light */
[data-theme='solarized-light'] {
--c-bg: 253 246 227;
--c-panel: 238 232 213;
--c-panel2: 227 220 198;
--c-border: 211 205 179;
--c-text: 7 54 66;
--c-mute: 88 110 117;
--c-accent: 38 139 210;
--c-accent2: 42 161 152;
--c-ok: 133 153 0;
--c-warn: 181 137 0;
--c-err: 220 50 47;
}
@layer base {
html, body, #root { height: 100%; }
body {
font-family: 'Inter', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
background-color: var(--c-bg);
color: var(--c-text);
}
}
@layer components {
.btn {
@apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded-md
text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-primary { @apply btn bg-mk-accent hover:bg-mk-accent2 text-white; }
.btn-ghost { @apply btn bg-transparent hover:bg-mk-panel2 text-mk-text border border-mk-border; }
.card {
@apply bg-mk-panel border border-mk-border rounded-xl p-3 sm:p-5;
}
.table-wrap {
@apply -mx-3 sm:mx-0 overflow-x-auto;
}
.table-wrap > table {
@apply min-w-[640px] w-full;
}
.input {
@apply w-full bg-mk-panel2 border border-mk-border rounded-md px-3 py-2 text-sm
placeholder-mk-mute focus:outline-none focus:border-mk-accent2;
}
.badge {
@apply inline-flex items-center px-2 py-0.5 rounded text-xs font-medium;
}
.badge-up { @apply badge bg-mk-ok/15 text-mk-ok; }
.badge-down { @apply badge bg-mk-err/15 text-mk-err; }
.badge-unk { @apply badge bg-mk-mute/15 text-mk-mute; }
}
+13
View File
@@ -0,0 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
);
+101
View File
@@ -0,0 +1,101 @@
import { useEffect, useState } from 'react';
import { Bell, CheckCheck, Trash2, AlertTriangle, AlertCircle, Info, Eraser } from 'lucide-react';
import { api, Alert as AlertT } from '@/api/client';
function sevIcon(s: string) {
if (s === 'critical' || s === 'error') return <AlertCircle size={14} className="text-mk-err" />;
if (s === 'warning') return <AlertTriangle size={14} className="text-mk-warn" />;
return <Info size={14} className="text-mk-accent2" />;
}
export default function AlertsPage() {
const [alerts, setAlerts] = useState<AlertT[]>([]);
const [onlyUnack, setOnlyUnack] = useState(false);
const reload = () =>
api.get<AlertT[]>('/alerts', { params: { only_unack: onlyUnack } })
.then((r) => setAlerts(r.data));
useEffect(() => { reload(); }, [onlyUnack]);
const ack = async (id: number) => { await api.post(`/alerts/${id}/ack`); reload(); };
const ackAll = async () => { await api.post('/alerts/ack-all'); reload(); };
const remove = async (id: number) => {
if (!confirm('Удалить алерт?')) return;
await api.delete(`/alerts/${id}`); reload();
};
const purge = async () => {
const onlyAcked = confirm('OK — удалить только прочитанные.\nОтмена — удалить все.');
if (!confirm(onlyAcked ? 'Удалить все прочитанные алерты?' : 'Удалить ВСЕ алерты?')) return;
await api.delete('/alerts', { params: { only_acked: onlyAcked } });
reload();
};
return (
<div className="space-y-3">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<Bell size={16} />
<h2 className="text-base font-semibold">Alert Center</h2>
<span className="text-xs text-mk-mute">всего: {alerts.length}</span>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-mk-mute flex items-center gap-1.5">
<input type="checkbox" checked={onlyUnack} onChange={(e) => setOnlyUnack(e.target.checked)} />
только непрочитанные
</label>
<button className="btn-ghost !py-1 !text-xs" onClick={ackAll}>
<CheckCheck size={13} /> Прочитать всё
</button>
<button className="btn-ghost !py-1 !text-xs text-mk-warn" onClick={purge}>
<Eraser size={13} /> Очистить
</button>
</div>
</div>
<div className="card p-0 overflow-hidden">
<table className="w-full text-[13px]">
<thead className="bg-mk-panel2 text-mk-mute text-[10px] uppercase tracking-wider">
<tr>
<th className="px-2 py-1.5 w-8"></th>
<th className="text-left px-2 py-1.5">Заголовок</th>
<th className="text-left px-2 py-1.5">Категория</th>
<th className="text-left px-2 py-1.5">Источник</th>
<th className="text-left px-2 py-1.5">Время</th>
<th className="text-right px-2 py-1.5">Действия</th>
</tr>
</thead>
<tbody>
{alerts.length === 0 && (
<tr><td colSpan={6} className="px-3 py-3 text-center text-mk-mute">Нет алертов</td></tr>
)}
{alerts.map((a) => (
<tr key={a.id} className={`border-t border-mk-border hover:bg-mk-panel2/40 ${
a.acknowledged ? 'opacity-60' : ''
}`}>
<td className="px-2 py-1">{sevIcon(a.severity)}</td>
<td className="px-2 py-1">
<div className={a.acknowledged ? '' : 'font-medium'}>{a.title}</div>
{a.message && <div className="text-[11px] text-mk-mute">{a.message}</div>}
</td>
<td className="px-2 py-1 text-mk-mute">{a.category}</td>
<td className="px-2 py-1 text-mk-mute font-mono text-[11px]">{a.source ?? '—'}</td>
<td className="px-2 py-1 text-mk-mute text-[11px]">{new Date(a.created_at).toLocaleString()}</td>
<td className="px-2 py-1 text-right">
{!a.acknowledged && (
<button className="btn-ghost !py-0.5 !px-1.5" onClick={() => ack(a.id)} title="Прочитано">
<CheckCheck size={12} />
</button>
)}
<button className="btn-ghost !py-0.5 !px-1.5 ml-1" onClick={() => remove(a.id)} title="Удалить">
<Trash2 size={12} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+203
View File
@@ -0,0 +1,203 @@
import { useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Terminal, Play, AlertTriangle, Bot, HardDrive } from 'lucide-react';
import { api, CLIRunOut, Device } from '@/api/client';
import ChatBot from '@/components/ChatBot';
import FirmwarePage from '@/pages/Firmware';
const PRESETS = [
'/system/identity/print',
'/system/resource/print',
'/interface/print',
'/ip/address/print',
'/ip/route/print',
'/system/clock/print',
'/log/print',
];
const DANGEROUS = [
'/system/reboot',
'/system/shutdown',
'/system/reset-configuration',
'/system/routerboard/upgrade',
'/file/remove',
];
export default function CLIPage() {
const [params] = useSearchParams();
const [devices, setDevices] = useState<Device[]>([]);
const initialIds = (params.get('ids') ?? '').split(',').map(Number).filter(Boolean);
const [selected, setSelected] = useState<Set<number>>(new Set(initialIds));
const [command, setCommand] = useState('/system/resource/print');
const [out, setOut] = useState<CLIRunOut | null>(null);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [tab, setTab] = useState<'cli' | 'assistant' | 'firmware'>(() => {
const h = (typeof window !== 'undefined' ? window.location.hash : '').replace('#', '');
if (h === 'assistant' || h === 'firmware' || h === 'cli') return h;
return 'cli';
});
useEffect(() => {
if (typeof window !== 'undefined') {
window.history.replaceState(null, '', `#${tab}`);
}
}, [tab]);
useEffect(() => {
api.get<Device[]>('/devices').then((r) => setDevices(r.data));
}, []);
const isDangerous = useMemo(
() => DANGEROUS.some((p) => command.trim().startsWith(p)),
[command],
);
const toggle = (id: number) => {
setSelected((s) => {
const x = new Set(s);
if (x.has(id)) x.delete(id); else x.add(id);
return x;
});
};
const run = async () => {
setErr(null);
if (selected.size === 0) { setErr('Выберите хотя бы одно устройство'); return; }
if (!command.trim()) { setErr('Введите команду'); return; }
if (isDangerous && !confirm(`Опасная команда!\n\n${command}\n\nЗапустить на ${selected.size} устройств?`)) {
return;
}
setBusy(true);
try {
const r = await api.post<CLIRunOut>('/cli/run', {
device_ids: Array.from(selected),
command,
confirm: isDangerous,
});
setOut(r.data);
} catch (ex: any) {
setErr(ex?.response?.data?.detail ?? 'Ошибка');
} finally { setBusy(false); }
};
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Terminal size={16} />
<h2 className="text-base font-semibold">Автоматизация</h2>
<span className="text-xs text-mk-mute">CLI и помощник</span>
</div>
<div className="flex items-center gap-1 border-b border-mk-border">
<button
onClick={() => setTab('cli')}
className={`inline-flex items-center gap-1.5 px-3 py-2 text-sm border-b-2 -mb-px transition-colors ${
tab === 'cli'
? 'border-mk-accent text-mk-text'
: 'border-transparent text-mk-mute hover:text-mk-text'
}`}
>
<Terminal size={14} /> CLI
</button>
<button
onClick={() => setTab('assistant')}
className={`inline-flex items-center gap-1.5 px-3 py-2 text-sm border-b-2 -mb-px transition-colors ${
tab === 'assistant'
? 'border-mk-accent text-mk-text'
: 'border-transparent text-mk-mute hover:text-mk-text'
}`}
>
<Bot size={14} /> Помощник
</button>
<button
onClick={() => setTab('firmware')}
className={`inline-flex items-center gap-1.5 px-3 py-2 text-sm border-b-2 -mb-px transition-colors ${
tab === 'firmware'
? 'border-mk-accent text-mk-text'
: 'border-transparent text-mk-mute hover:text-mk-text'
}`}
>
<HardDrive size={14} /> Репозиторий прошивок
</button>
</div>
{tab === 'assistant' && <ChatBot embedded />}
{tab === 'firmware' && <FirmwarePage embedded />}
{tab === 'cli' && (
<div className="space-y-3">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div className="card p-3">
<h3 className="text-xs uppercase tracking-wider text-mk-mute mb-2">Устройства ({selected.size})</h3>
<div className="max-h-64 overflow-auto space-y-0.5">
{devices.map((d) => (
<label key={d.id} className="flex items-center gap-2 text-sm hover:bg-mk-panel2 px-2 py-1 rounded">
<input type="checkbox" checked={selected.has(d.id)} onChange={() => toggle(d.id)} />
<span className={`w-2 h-2 rounded-full ${d.status === 'up' ? 'bg-mk-ok' : d.status === 'down' ? 'bg-mk-err' : 'bg-mk-mute'}`} />
<span className="truncate">{d.identity || d.name}</span>
<span className="ml-auto text-xs text-mk-mute font-mono">{d.host}</span>
</label>
))}
</div>
</div>
<div className="card p-3 md:col-span-2 space-y-2">
<h3 className="text-xs uppercase tracking-wider text-mk-mute">Команда</h3>
<textarea
className="input font-mono text-sm h-20"
value={command}
onChange={(e) => setCommand(e.target.value)}
placeholder="/system/resource/print"
/>
<div className="flex flex-wrap gap-1">
{PRESETS.map((p) => (
<button
key={p}
className="text-[11px] px-2 py-0.5 rounded bg-mk-panel2 hover:bg-mk-panel2/60 text-mk-mute font-mono"
onClick={() => setCommand(p)}
>{p}</button>
))}
</div>
{isDangerous && (
<div className="text-xs text-mk-warn flex items-center gap-1.5">
<AlertTriangle size={12} /> Опасная команда потребуется подтверждение
</div>
)}
{err && <div className="text-sm text-mk-err">{err}</div>}
<div className="flex justify-end">
<button className="btn-primary" onClick={run} disabled={busy}>
<Play size={14} /> {busy ? 'Выполнение…' : 'Запустить'}
</button>
</div>
</div>
</div>
{out && (
<div className="card p-0 overflow-hidden">
<div className="px-3 py-2 border-b border-mk-border text-xs text-mk-mute font-mono">
$ {out.command}
</div>
<div className="divide-y divide-mk-border">
{out.results.map((r) => (
<div key={r.device_id} className="p-3">
<div className="flex items-center gap-2 mb-1">
<span className={`w-2 h-2 rounded-full ${r.ok ? 'bg-mk-ok' : 'bg-mk-err'}`} />
<span className="text-sm font-medium">{r.device_name ?? `device:${r.device_id}`}</span>
</div>
{r.error && <div className="text-xs text-mk-err font-mono">{r.error}</div>}
{r.ok && r.rows && (
<pre className="text-[11px] font-mono bg-mk-bg p-2 rounded overflow-auto max-h-64">
{JSON.stringify(r.rows, null, 2)}
</pre>
)}
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
);
}
+166
View File
@@ -0,0 +1,166 @@
import { useEffect, useState } from 'react';
import { Activity, Router as RouterIcon, AlertTriangle, CheckCircle2, WifiOff, Bell } from 'lucide-react';
import { api, Alert as AlertT, Device, HeartbeatBucket, HeartbeatOut } from '@/api/client';
import { useSettings } from '@/store/settings';
function StatCard({
icon: Icon, label, value, accent,
}: { icon: any; label: string; value: string | number; accent: string }) {
return (
<div className="card flex items-center gap-3 !p-4">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${accent}`}>
<Icon size={20} />
</div>
<div>
<div className="text-[10px] text-mk-mute uppercase tracking-wider">{label}</div>
<div className="text-xl font-semibold leading-tight">{value}</div>
</div>
</div>
);
}
const BUCKET_COLORS: Record<HeartbeatBucket, string> = {
up: 'bg-mk-ok',
'no-net':'bg-mk-warn',
down: 'bg-mk-err',
none: 'bg-mk-panel2',
};
const BUCKET_LABEL: Record<HeartbeatBucket, string> = {
up: 'OK', 'no-net': 'нет интернета', down: 'оффлайн', none: 'нет данных',
};
function HeartbeatGrid({ data }: { data: HeartbeatOut }) {
const since = new Date(data.since);
const until = new Date(data.until);
const binMin = Math.round(((until.getTime() - since.getTime()) / data.bins) / 60000);
return (
<div className="card space-y-3">
<div className="flex flex-wrap items-center gap-2">
<Activity size={14} className="text-mk-accent2" />
<h3 className="text-sm font-semibold">Heartbeat {data.hours < 1 ? `${Math.round(data.hours * 60)}м` : `${data.hours}ч`}</h3>
<span className="text-[11px] text-mk-mute sm:ml-auto">
{data.bins} бинов × {binMin} мин
</span>
</div>
{data.devices.length === 0 ? (
<div className="text-sm text-mk-mute">Нет устройств.</div>
) : (
<div className="space-y-1 overflow-x-auto -mx-1 px-1">
{data.devices.map((d) => (
<div key={d.id} className="flex items-center gap-2 sm:gap-3 min-w-[420px]">
<div className="w-24 sm:w-32 shrink-0 truncate text-xs">
<div className="font-medium truncate">{d.name}</div>
<div className="text-[10px] text-mk-mute font-mono truncate">{d.host}</div>
</div>
<div className="flex-1 grid gap-[1px]" style={{ gridTemplateColumns: `repeat(${data.bins}, minmax(0,1fr))` }}>
{d.buckets.map((b, i) => (
<div
key={i}
className={`h-3.5 rounded-[2px] ${BUCKET_COLORS[b]}`}
title={`Бин ${i + 1}/${data.bins}: ${BUCKET_LABEL[b]}`}
/>
))}
</div>
<span className={`badge-${d.status === 'up' ? 'up' : d.status === 'down' ? 'down' : 'unk'} text-[10px]`}>
{d.status}
</span>
</div>
))}
</div>
)}
<div className="flex flex-wrap gap-3 text-[11px] text-mk-mute pt-1 border-t border-mk-border">
{(['up', 'no-net', 'down', 'none'] as HeartbeatBucket[]).map((b) => (
<span key={b} className="inline-flex items-center gap-1.5">
<span className={`w-3 h-3 rounded-sm ${BUCKET_COLORS[b]}`} /> {BUCKET_LABEL[b]}
</span>
))}
</div>
</div>
);
}
export default function Dashboard() {
const [devices, setDevices] = useState<Device[]>([]);
const [hb, setHb] = useState<HeartbeatOut | null>(null);
const hours = useSettings((s) => s.settings?.ui?.heartbeat_hours ?? 6);
useEffect(() => {
api.get<Device[]>('/devices').then((r) => setDevices(r.data)).catch(() => {});
}, []);
useEffect(() => {
// бины: ~120 ячеек, не меньше 60, не больше 180 (более мелкая сетка)
const bins = Math.max(60, Math.min(180, Math.round(Number(hours) * 24)));
const loadHb = () => api.get<HeartbeatOut>(`/heartbeat?hours=${hours}&bins=${bins}`)
.then((r) => setHb(r.data)).catch(() => {});
loadHb();
const t = setInterval(loadHb, 60000);
return () => clearInterval(t);
}, [hours]);
const up = devices.filter((d) => d.status === 'up').length;
const down = devices.filter((d) => d.status === 'down').length;
const unknown = devices.filter((d) => d.status !== 'up' && d.status !== 'down').length;
const noNet = devices.filter((d) => d.internet_ok === false).length;
const abnormal = devices.filter((d) => d.abnormal_reboot).length;
return (
<div className="space-y-5">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
<StatCard icon={RouterIcon} label="Устройства" value={devices.length} accent="bg-mk-accent/15 text-mk-accent2" />
<StatCard icon={CheckCircle2} label="Online" value={up} accent="bg-mk-ok/15 text-mk-ok" />
<StatCard icon={AlertTriangle} label="Offline" value={down} accent="bg-mk-err/15 text-mk-err" />
<StatCard icon={WifiOff} label="Без интернета" value={noNet} accent="bg-mk-warn/15 text-mk-warn" />
<StatCard icon={Activity} label="Аварийные reboot" value={abnormal} accent="bg-mk-warn/15 text-mk-warn" />
</div>
{hb && <HeartbeatGrid data={hb} />}
<ActivityWidget />
{unknown > 0 && (
<div className="card text-xs text-mk-mute">
Устройства без статуса: {unknown}. Откройте карточку устройства и нажмите «Опросить».
</div>
)}
</div>
);
}
function ActivityWidget() {
const [alerts, setAlerts] = useState<AlertT[]>([]);
useEffect(() => {
api.get<AlertT[]>('/alerts', { params: { only_unack: false } })
.then((r) => setAlerts(r.data.slice(0, 30))).catch(() => {});
}, []);
const sevColor = (s: string) =>
s === 'critical' ? 'text-mk-err' :
s === 'error' ? 'text-mk-err' :
s === 'warning' ? 'text-mk-warn' :
'text-mk-mute';
return (
<div className="card !p-0 overflow-hidden">
<div className="flex border-b border-mk-border">
<div className="px-4 py-2 text-xs font-medium inline-flex items-center gap-1.5 border-b-2 -mb-px border-mk-accent2 text-mk-text">
<Bell size={13} /> Алерты <span className="text-[10px] opacity-70">({alerts.length})</span>
</div>
</div>
<div className="max-h-72 overflow-y-auto divide-y divide-mk-border">
{alerts.length === 0 && (
<div className="px-4 py-6 text-center text-mk-mute text-sm">Нет алертов</div>
)}
{alerts.map((a) => (
<div key={a.id} className={`px-3 py-1.5 text-xs flex items-start gap-2 ${a.acknowledged ? 'opacity-60' : ''}`}>
<span className={`${sevColor(a.severity)} font-medium uppercase text-[10px] mt-0.5 w-14 shrink-0`}>{a.severity}</span>
<div className="flex-1 min-w-0">
<div className="truncate">{a.title}</div>
{a.message && <div className="text-[11px] text-mk-mute truncate">{a.message}</div>}
</div>
<span className="text-[10px] text-mk-mute font-mono shrink-0">{new Date(a.created_at).toLocaleTimeString()}</span>
</div>
))}
</div>
</div>
);
}
+904
View File
@@ -0,0 +1,904 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { useParams, Link } from 'react-router-dom';
import {
ArrowLeft, RefreshCw, Power, ShieldAlert, Save, Download, Trash2, ArrowUpCircle,
Wifi, WifiOff, AlertTriangle, Activity as ActivityIcon, Network,
Globe, HardDrive, Pencil, Cloud, Package, Info,
} from 'lucide-react';
import {
AreaChart, Area, LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend,
} from 'recharts';
import {
api, Device, DeviceBackup, DeviceResource, Firmware, MetricPoint,
InterfaceInfo, InterfaceTrafficOut, UplinkStatus, DhcpLease,
} from '@/api/client';
import { useAuth } from '@/store/auth';
import { latestStableVersion, isOutdated } from '@/utils/version';
import { EditDeviceModal } from './Devices';
import DeviceMockup from '@/components/DeviceMockup';
type Tab = 'overview' | 'about' | 'interfaces' | 'firmware' | 'backups' | 'ipmgmt';
function StatusDot({ status }: { status: string }) {
const cls =
status === 'up' ? 'bg-mk-ok' :
status === 'down' ? 'bg-mk-err' :
'bg-mk-mute';
return <span className={`inline-block w-4 h-4 rounded-full ${cls}`} />;
}
function fmtSize(b: number): string {
if (b < 1024) return `${b} B`;
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KiB`;
return `${(b / 1024 / 1024).toFixed(2)} MiB`;
}
function fmtBps(b: number): string {
if (b < 1000) return `${b.toFixed(0)} bps`;
if (b < 1_000_000) return `${(b / 1000).toFixed(1)} Kbps`;
if (b < 1_000_000_000) return `${(b / 1_000_000).toFixed(2)} Mbps`;
return `${(b / 1_000_000_000).toFixed(2)} Gbps`;
}
function parseList(s: string | null | undefined): string[] {
if (!s) return [];
return s.split(',').map((x) => x.trim()).filter(Boolean);
}
const COLORS = ['#22c55e', '#3b82f6', '#f59e0b', '#a855f7', '#ec4899', '#14b8a6', '#ef4444', '#eab308'];
export default function DeviceDetail() {
const { id } = useParams();
const [d, setD] = useState<Device | null>(null);
const [tab, setTab] = useState<Tab>('about');
const [res, setRes] = useState<DeviceResource | null>(null);
const [busy, setBusy] = useState(false);
const [actionBusy, setActionBusy] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
const [actionMsg, setActionMsg] = useState<string | null>(null);
const [backups, setBackups] = useState<DeviceBackup[]>([]);
const [firmwares, setFirmwares] = useState<Firmware[]>([]);
const [latestVer, setLatestVer] = useState<string | null>(null);
const [metrics, setMetrics] = useState<MetricPoint[]>([]);
const [editing, setEditing] = useState(false);
const [selectedFw, setSelectedFw] = useState<number | ''>('');
const [showAllFw, setShowAllFw] = useState(false);
const [upgradeChannel, setUpgradeChannel] = useState<'stable' | 'long-term' | 'testing' | 'development'>('stable');
const token = useAuth((s) => s.accessToken);
const load = () => api.get<Device>(`/devices/${id}`).then((r) => setD(r.data));
const loadBackups = () => api.get<DeviceBackup[]>(`/devices/${id}/backups`).then((r) => setBackups(r.data));
const loadMetrics = () => api.get<MetricPoint[]>(`/devices/${id}/metrics`, { params: { hours: 24 } }).then((r) => setMetrics(r.data));
useEffect(() => {
load();
loadBackups();
loadMetrics();
api.get<Firmware[]>('/firmware').then((r) => {
setFirmwares(r.data);
setLatestVer(latestStableVersion(r.data));
}).catch(() => {});
}, [id]);
const probe = async () => {
setBusy(true); setErr(null);
try {
const { data } = await api.post<DeviceResource>(`/devices/${id}/probe`);
setRes(data);
await load();
await loadMetrics();
} catch (ex: any) {
setErr(ex?.response?.data?.detail ?? 'Ошибка опроса');
} finally { setBusy(false); }
};
const reboot = async () => {
if (!confirm('Перезагрузить устройство?')) return;
setActionBusy('reboot'); setActionMsg(null);
try { await api.post(`/devices/${id}/reboot`); setActionMsg('Команда reboot отправлена'); }
catch (ex: any) { setActionMsg(ex?.response?.data?.detail ?? 'Ошибка reboot'); }
finally { setActionBusy(null); }
};
const safeMode = async () => {
setActionBusy('safemode'); setActionMsg(null);
try { await api.post(`/devices/${id}/safe-mode`); setActionMsg('Safe mode переключён'); }
catch (ex: any) { setActionMsg(ex?.response?.data?.detail ?? 'Ошибка safe mode'); }
finally { setActionBusy(null); }
};
const makeBackup = async () => {
setActionBusy('backup'); setActionMsg(null);
try { await api.post(`/devices/${id}/backups`); await loadBackups(); setActionMsg('Бэкап создан'); }
catch (ex: any) { setActionMsg(ex?.response?.data?.detail ?? 'Ошибка бэкапа'); }
finally { setActionBusy(null); }
};
const upgradeFromInternet = async () => {
if (!confirm(`Обновить RouterOS из интернета (канал ${upgradeChannel})?\nУстройство будет перезагружено.`)) return;
setActionBusy('upgrade-net'); setActionMsg(null);
try {
const { data } = await api.post(`/devices/${id}/upgrade/internet`, null, {
params: { channel: upgradeChannel, install: true },
});
setActionMsg(`Обновление запущено: ${JSON.stringify(data)}`);
} catch (ex: any) {
setActionMsg(ex?.response?.data?.detail ?? 'Ошибка обновления');
} finally { setActionBusy(null); }
};
const upgradeFromLocal = async () => {
if (!selectedFw) { setActionMsg('Сначала выберите прошивку из репозитория'); return; }
if (!confirm('Загрузить прошивку с контроллера и перезагрузить устройство для установки?')) return;
setActionBusy('upgrade-local'); setActionMsg(null);
try {
const { data } = await api.post(`/devices/${id}/upgrade/local`, null, {
params: { firmware_id: selectedFw, reboot: true },
});
setActionMsg(`Прошивка загружена и перезагрузка отправлена: ${JSON.stringify(data)}`);
} catch (ex: any) {
setActionMsg(ex?.response?.data?.detail ?? 'Ошибка локального обновления');
} finally { setActionBusy(null); }
};
const downloadBackup = (b: DeviceBackup) => {
fetch(`/api/v1/backups/${b.id}/download`, { headers: { Authorization: `Bearer ${token}` } })
.then((r) => r.blob())
.then((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = b.filename; a.click();
URL.revokeObjectURL(url);
});
};
const deleteBackup = async (b: DeviceBackup) => {
if (!confirm(`Удалить ${b.filename}?`)) return;
await api.delete(`/backups/${b.id}`);
await loadBackups();
};
// Нормализация имени архитектуры (например, "x86-64" и "x86_64" — одно и то же)
const normArch = (s: string | null | undefined) =>
(s || '').toLowerCase().replace(/[-_]/g, '');
const deviceArch = normArch(d?.architecture);
const filteredFirmwares = useMemo(() => {
if (showAllFw || !deviceArch) return firmwares;
return firmwares.filter((f) => normArch(f.architecture) === deviceArch);
}, [firmwares, deviceArch, showAllFw]);
// Сбросить выбор, если текущая прошивка отфильтрована
useEffect(() => {
if (selectedFw && !filteredFirmwares.some((f) => f.id === selectedFw)) {
setSelectedFw('');
}
}, [filteredFirmwares, selectedFw]);
if (!d) return <div className="text-mk-mute">Загрузка</div>;
const memUsedPct = res?.total_memory && res?.free_memory
? Math.round(100 - (res.free_memory / res.total_memory) * 100) : null;
const chartData = metrics.map((m) => ({
...m,
t: new Date(m.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
}));
return (
<div className="space-y-3">
<Link to="/devices" className="inline-flex items-center gap-2 text-sm text-mk-mute hover:text-mk-text">
<ArrowLeft size={14} /> Назад
</Link>
<div className="card !py-2 !px-3">
<div className="flex items-center gap-3 flex-wrap">
<StatusDot status={d.status} />
<h2 className="text-lg font-semibold leading-none">{d.identity || d.name}</h2>
<span className={`text-xs px-2 py-0.5 ${
d.status === 'up' ? 'badge-up' : d.status === 'down' ? 'badge-down' : 'badge-unk'
}`}>{d.status.toUpperCase()}</span>
{/* Мета-блок: host, internet, модель, RouterOS, arch — одной строкой */}
<div className="text-xs text-mk-mute flex items-center gap-2 flex-wrap">
<span>{d.host}:{d.port}{d.use_tls ? ' (TLS)' : ''}</span>
{d.internet_ok === true && (
<span className="inline-flex items-center gap-1 text-mk-ok"><Wifi size={11} /> ok</span>
)}
{d.internet_ok === false && (
<span className="inline-flex items-center gap-1 text-mk-err"><WifiOff size={11} /> no internet</span>
)}
{d.abnormal_reboot && (
<span className="inline-flex items-center gap-1 text-mk-warn"><AlertTriangle size={11} /> abnormal reboot</span>
)}
<span className="text-mk-mute/70">·</span>
<span>{d.model || '—'} · {d.ros_version || '—'}</span>
{d.architecture && (
<span className="inline-flex items-center gap-1 px-1.5 py-0 rounded bg-mk-panel2 text-mk-text font-mono">
<HardDrive size={10} /> {d.architecture}
</span>
)}
{isOutdated(d.ros_version, latestVer) && (
<span className="inline-flex items-center gap-1 px-1.5 py-0 rounded bg-mk-warn/15 text-mk-warn font-medium">
<ArrowUpCircle size={11} /> {latestVer}
</span>
)}
</div>
{/* Кнопки прижаты к правому краю */}
<div className="flex gap-1.5 flex-wrap justify-end ml-auto">
<button className="btn-ghost !py-1 !px-2 !text-xs" onClick={() => setEditing(true)}>
<Pencil size={12} /> Изменить
</button>
<button className="btn-ghost !py-1 !px-2 !text-xs" onClick={safeMode} disabled={actionBusy !== null}>
<ShieldAlert size={12} className={actionBusy === 'safemode' ? 'animate-pulse' : ''} /> Safe Mode
</button>
<button className="btn-ghost !py-1 !px-2 !text-xs" onClick={reboot} disabled={actionBusy !== null}>
<Power size={12} className={actionBusy === 'reboot' ? 'animate-pulse' : ''} /> Reboot
</button>
<button className="btn-primary !py-1 !px-2 !text-xs" onClick={probe} disabled={busy}>
<RefreshCw size={12} className={busy ? 'animate-spin' : ''} /> {busy ? 'Опрос…' : 'Опросить'}
</button>
</div>
</div>
{d.last_error && (
<div className="text-xs text-mk-err mt-1.5" title={d.last_error}>
Последняя ошибка: {d.last_error}
</div>
)}
</div>
{err && <div className="card text-mk-err text-sm">{err}</div>}
{actionMsg && <div className="card text-mk-ok text-sm whitespace-pre-wrap">{actionMsg}</div>}
<div className="flex border-b border-mk-border gap-1">
<TabBtn active={tab === 'about'} onClick={() => setTab('about')} icon={Info} label="Об устройстве" />
<TabBtn active={tab === 'overview'} onClick={() => setTab('overview')} icon={ActivityIcon} label="Обзор" />
<TabBtn active={tab === 'interfaces'} onClick={() => setTab('interfaces')} icon={Network} label="Интерфейсы" />
<TabBtn active={tab === 'ipmgmt'} onClick={() => setTab('ipmgmt')} icon={Globe} label="IP Management | DHCP" />
<TabBtn active={tab === 'backups'} onClick={() => setTab('backups')} icon={Save} label="Бэкапы" />
<TabBtn active={tab === 'firmware'} onClick={() => setTab('firmware')} icon={HardDrive} label="Прошивка" />
</div>
{tab === 'overview' && (
<div className="space-y-3">
{res && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div className="card">
<div className="text-xs text-mk-mute uppercase">CPU load</div>
<div className="text-3xl font-semibold">{res.cpu_load ?? '—'}%</div>
</div>
<div className="card">
<div className="text-xs text-mk-mute uppercase">Memory</div>
<div className="text-3xl font-semibold">{memUsedPct ?? '—'}%</div>
<div className="text-xs text-mk-mute mt-1">
{res.free_memory != null && res.total_memory != null
? `${(res.free_memory / 1024 / 1024).toFixed(1)} / ${(res.total_memory / 1024 / 1024).toFixed(1)} MiB free`
: '—'}
</div>
</div>
<div className="card">
<div className="text-xs text-mk-mute uppercase">Uptime</div>
<div className="text-3xl font-semibold">{res.uptime ?? '—'}</div>
<div className="text-xs text-mk-mute mt-1">{res.architecture_name ?? ''}</div>
</div>
</div>
)}
{metrics.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="card">
<div className="text-xs uppercase text-mk-mute mb-2">CPU за 24ч</div>
<ResponsiveContainer width="100%" height={160}>
<AreaChart data={chartData}>
<defs>
<linearGradient id="gC" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#22c55e" stopOpacity={0.5} />
<stop offset="100%" stopColor="#22c55e" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="2 2" stroke="#2a2f36" />
<XAxis dataKey="t" stroke="#8b95a5" fontSize={10} minTickGap={30} />
<YAxis stroke="#8b95a5" fontSize={10} domain={[0, 100]} unit="%" />
<Tooltip contentStyle={{ background: '#1e242b', border: '1px solid #2a2f36', fontSize: 12 }} />
<Area type="monotone" dataKey="cpu_load" stroke="#22c55e" fill="url(#gC)" />
</AreaChart>
</ResponsiveContainer>
</div>
<div className="card">
<div className="text-xs uppercase text-mk-mute mb-2">Memory за 24ч</div>
<ResponsiveContainer width="100%" height={160}>
<AreaChart data={chartData}>
<defs>
<linearGradient id="gM" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3b82f6" stopOpacity={0.5} />
<stop offset="100%" stopColor="#3b82f6" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="2 2" stroke="#2a2f36" />
<XAxis dataKey="t" stroke="#8b95a5" fontSize={10} minTickGap={30} />
<YAxis stroke="#8b95a5" fontSize={10} domain={[0, 100]} unit="%" />
<Tooltip contentStyle={{ background: '#1e242b', border: '1px solid #2a2f36', fontSize: 12 }} />
<Area type="monotone" dataKey="mem_used_pct" stroke="#3b82f6" fill="url(#gM)" />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
)}
</div>
)}
{tab === 'backups' && (
<div className="space-y-3">
<div className="card flex items-center gap-3 flex-wrap">
<Save size={16} className="text-mk-accent2" />
<div className="text-sm">
Хранится максимум <b>10 пар</b> (binary <code>.backup</code> + text <code>.rsc</code>) с ротацией.
Доставка через встроенный FTP контроллера (push с устройства).
</div>
<button
className="btn-primary !text-xs ml-auto"
onClick={makeBackup}
disabled={actionBusy !== null}
>
<Save size={14} /> {actionBusy === 'backup' ? 'Снимаем…' : 'Снять бэкап сейчас'}
</button>
</div>
<div className="card p-0 overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-mk-border">
<h3 className="text-sm font-semibold">Бэкапы конфигурации</h3>
<span className="text-[11px] text-mk-mute">{backups.length} файлов</span>
</div>
<table className="w-full text-[13px]">
<thead className="bg-mk-panel2 text-mk-mute text-[10px] uppercase tracking-wider">
<tr>
<th className="text-left px-3 py-1">Файл</th>
<th className="text-left px-3 py-1">Формат</th>
<th className="text-left px-3 py-1">Размер</th>
<th className="text-left px-3 py-1">Создан</th>
<th className="text-right px-3 py-1">Действия</th>
</tr>
</thead>
<tbody>
{backups.length === 0 && (
<tr><td colSpan={5} className="px-3 py-3 text-center text-mk-mute">Нет бэкапов</td></tr>
)}
{backups.map((b) => (
<tr key={b.id} className="border-t border-mk-border hover:bg-mk-panel2/40">
<td className="px-3 py-1 font-mono text-xs">{b.filename}</td>
<td className="px-3 py-1">
<span className={b.fmt === 'binary' ? 'badge-up' : 'badge-unk'}>{b.fmt}</span>
</td>
<td className="px-3 py-1">{fmtSize(b.size)}</td>
<td className="px-3 py-1 text-mk-mute text-xs">{new Date(b.created_at).toLocaleString()}</td>
<td className="px-3 py-1 text-right whitespace-nowrap">
<button className="btn-ghost !py-0.5 !px-1.5" onClick={() => downloadBackup(b)} title="Скачать">
<Download size={12} />
</button>
<button className="btn-ghost !py-0.5 !px-1.5 ml-1" onClick={() => deleteBackup(b)} title="Удалить">
<Trash2 size={12} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{tab === 'about' && <AboutTab device={d} resource={res} />}
{tab === 'interfaces' && <InterfacesTab device={d} onSaved={load} />}
{tab === 'firmware' && (
<div className="space-y-3">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div className="card">
<div className="text-xs text-mk-mute uppercase">Текущая версия</div>
<div className="text-2xl font-semibold mt-0.5">{d.ros_version ?? '—'}</div>
{latestVer && d.ros_version && isOutdated(d.ros_version, latestVer) && (
<div className="text-[11px] text-mk-warn mt-1">
доступна {latestVer} (stable)
</div>
)}
</div>
<div className="card">
<div className="text-xs text-mk-mute uppercase">Архитектура</div>
<div className="text-2xl font-semibold mt-0.5 font-mono">
{d.architecture ?? <span className="text-mk-warn">неизвестна</span>}
</div>
{!d.architecture && (
<div className="text-[11px] text-mk-mute mt-1">
Нажмите «Опросить» в шапке карточки, чтобы определить.
</div>
)}
</div>
<div className="card">
<div className="text-xs text-mk-mute uppercase">Stable в репозитории</div>
<div className="text-2xl font-semibold mt-0.5">{latestVer ?? '—'}</div>
<div className="text-[11px] text-mk-mute mt-1">
Всего файлов: {firmwares.length}
</div>
</div>
</div>
<div className="card space-y-3">
<div className="flex items-center gap-2">
<ArrowUpCircle size={16} className="text-mk-accent2" />
<h3 className="text-base font-semibold">Обновление прошивки</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="border border-mk-border rounded p-3 space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
<Cloud size={14} className="text-mk-accent2" />
Из интернета (RouterOS update)
</div>
<div className="text-[11px] text-mk-mute">
Устройство загрузит обновление с серверов MikroTik самостоятельно. Требует исходящий доступ в интернет.
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-mk-mute">Канал:</label>
<select
className="input !py-1 !text-xs !w-auto"
value={upgradeChannel}
onChange={(e) => setUpgradeChannel(e.target.value as any)}
>
<option value="stable">stable</option>
<option value="long-term">long-term</option>
<option value="testing">testing</option>
<option value="development">development</option>
</select>
</div>
<button
className="btn-primary !text-xs"
onClick={upgradeFromInternet}
disabled={actionBusy !== null}
>
{actionBusy === 'upgrade-net' ? 'Запускается…' : 'Обновить из интернета'}
</button>
</div>
<div className="border border-mk-border rounded p-3 space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
<Package size={14} className="text-mk-accent2" />
Из локального репозитория (через FTP)
</div>
<div className="text-[11px] text-mk-mute">
Контроллер запустит на устройстве <code>/tool fetch ftp</code>, чтобы скачать выбранный <code>.npk</code>, и отправит reboot для установки.
</div>
<div className="text-[11px] flex items-center gap-2 flex-wrap">
<span className="text-mk-mute">Платформа устройства:</span>
{d.architecture ? (
<span className="px-1.5 py-0.5 rounded bg-mk-panel2 font-mono">{d.architecture}</span>
) : (
<span className="text-mk-warn">неизвестна нажмите «Опросить»</span>
)}
<label className="ml-auto inline-flex items-center gap-1 cursor-pointer">
<input
type="checkbox"
checked={showAllFw}
onChange={(e) => setShowAllFw(e.target.checked)}
/>
<span className="text-mk-mute">показать все архитектуры</span>
</label>
</div>
<select
className="input !py-1 !text-xs"
value={selectedFw}
onChange={(e) => setSelectedFw(e.target.value ? Number(e.target.value) : '')}
>
<option value=""> выберите файл </option>
{filteredFirmwares.map((f) => (
<option key={f.id} value={f.id}>
{f.name} {f.version ? `(${f.version})` : ''} {f.architecture ? `· ${f.architecture}` : ''}
</option>
))}
</select>
{filteredFirmwares.length === 0 && (
<div className="text-[11px] text-mk-warn">
Нет прошивок для архитектуры <span className="font-mono">{d.architecture || '?'}</span>.
Загрузите подходящий <code>.npk</code> в разделе «Прошивки».
</div>
)}
<button
className="btn-primary !text-xs"
onClick={upgradeFromLocal}
disabled={actionBusy !== null || !selectedFw}
>
{actionBusy === 'upgrade-local' ? 'Загрузка…' : 'Обновить из репозитория'}
</button>
</div>
</div>
</div>
</div>
)}
{tab === 'ipmgmt' && <IpMgmtTab deviceId={Number(id)} />}
{editing && <EditDeviceModal device={d} onClose={() => setEditing(false)} onSaved={load} />}
</div>
);
}
function TabBtn({
active, onClick, icon: Icon, label,
}: { active: boolean; onClick: () => void; icon: any; label: string }) {
return (
<button
onClick={onClick}
className={`px-3 py-1.5 text-xs inline-flex items-center gap-1.5 border-b-2 -mb-px ${
active ? 'border-mk-accent2 text-mk-text font-medium' : 'border-transparent text-mk-mute hover:text-mk-text'
}`}
>
<Icon size={13} /> {label}
</button>
);
}
// Физические типы RouterOS интерфейсов. Всё остальное — логические (vlan/bridge/ppp/vpn/...).
const PHYSICAL_TYPE_RE = /^(ether|wlan|wireless|sfp|qsfp)/i;
const isPhysicalIface = (it: InterfaceInfo): boolean =>
PHYSICAL_TYPE_RE.test((it.type || '').trim());
function InterfacesTab({ device, onSaved }: { device: Device; onSaved: () => void }) {
const [ifs, setIfs] = useState<InterfaceInfo[]>([]);
const [monitored, setMonitored] = useState<Set<string>>(new Set(parseList(device.monitored_interfaces)));
const [uplinks, setUplinks] = useState<Set<string>>(new Set(parseList(device.uplink_interfaces)));
const [hours, setHours] = useState<number>(device.interface_history_hours ?? 24);
const [traffic, setTraffic] = useState<InterfaceTrafficOut | null>(null);
const [uplinkStatus, setUplinkStatus] = useState<UplinkStatus[]>([]);
const [saveBusy, setSaveBusy] = useState(false);
const [saveMsg, setSaveMsg] = useState<string | null>(null);
const [subTab, setSubTab] = useState<'physical' | 'ports'>('physical');
const loadIfs = () =>
api.get<InterfaceInfo[]>(`/devices/${device.id}/interfaces`).then((r) => setIfs(r.data)).catch(() => {});
const loadTraffic = () => {
if (monitored.size === 0) { setTraffic(null); return; }
api.get<InterfaceTrafficOut>(`/devices/${device.id}/interface-traffic`, {
params: { names: Array.from(monitored).join(','), hours },
}).then((r) => setTraffic(r.data)).catch(() => {});
};
const loadUplinkStatus = () => {
api.get<UplinkStatus[]>(`/devices/${device.id}/uplink-status`)
.then((r) => setUplinkStatus(r.data)).catch(() => {});
};
useEffect(() => { loadIfs(); loadTraffic(); loadUplinkStatus(); }, [device.id]);
useEffect(() => { loadTraffic(); }, [Array.from(monitored).join(','), hours]);
const toggle = (set: Set<string>, setSet: (s: Set<string>) => void, name: string) => {
const next = new Set(set);
if (next.has(name)) next.delete(name); else next.add(name);
setSet(next);
};
const save = async () => {
setSaveBusy(true); setSaveMsg(null);
try {
await api.patch(`/devices/${device.id}`, {
monitored_interfaces: Array.from(monitored).join(','),
uplink_interfaces: Array.from(uplinks).join(','),
interface_history_hours: hours,
});
setSaveMsg('Сохранено. Данные начнут собираться в ближайшем цикле опроса.');
onSaved();
} catch (ex: any) {
setSaveMsg(ex?.response?.data?.detail ?? 'Ошибка сохранения');
} finally { setSaveBusy(false); }
};
// Build chart data: rows = timestamps, columns = interfaces, values = rx_bps & tx_bps
const chart = useMemo(() => {
if (!traffic) return [];
const tsMap: Record<string, any> = {};
for (const [name, points] of Object.entries(traffic.series)) {
for (const p of points) {
const k = p.ts;
if (!tsMap[k]) tsMap[k] = { t: new Date(p.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), ts: k };
tsMap[k][`${name}_rx`] = p.rx_bps;
tsMap[k][`${name}_tx`] = p.tx_bps;
}
}
return Object.values(tsMap).sort((a: any, b: any) => a.ts.localeCompare(b.ts));
}, [traffic]);
const trafficNames = traffic ? Object.keys(traffic.series) : [];
return (
<div className="space-y-3">
<div className="card space-y-3">
<div className="flex items-center gap-2">
<Network size={14} className="text-mk-accent2" />
<h3 className="text-sm font-semibold">Конфигурация мониторинга</h3>
</div>
<div className="text-[11px] text-mk-mute">
Отметьте интерфейсы, нагрузку которых нужно сохранять, и uplink-интерфейсы (например <code>uztelecom</code>, <code>lte1</code>) для индикатора связи.
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
<div>
<div className="text-xs text-mk-mute mb-1">Глубина истории, часов:</div>
<input
type="number" min={1} max={168}
className="input !py-1 !text-xs !w-32"
value={hours}
onChange={(e) => setHours(Math.max(1, Math.min(168, Number(e.target.value) || 24)))}
/>
</div>
</div>
<div className="flex items-center gap-1 border-b border-mk-border -mx-3 px-3">
{([
{ key: 'physical' as const, label: 'Интерфейсы', count: ifs.filter(isPhysicalIface).length },
{ key: 'ports' as const, label: 'Порты', count: ifs.filter((it) => !isPhysicalIface(it)).length },
]).map((s) => (
<button
key={s.key}
type="button"
onClick={() => setSubTab(s.key)}
className={`px-3 py-1.5 text-xs inline-flex items-center gap-1.5 border-b-2 -mb-px ${
subTab === s.key
? 'border-mk-accent2 text-mk-text font-medium'
: 'border-transparent text-mk-mute hover:text-mk-text'
}`}
>
{s.label} <span className="text-[10px] opacity-70">({s.count})</span>
</button>
))}
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead className="text-mk-mute text-[10px] uppercase tracking-wider">
<tr>
<th className="text-center px-2 py-1">Статус</th>
<th className="text-left px-2 py-1">Имя</th>
<th className="text-left px-2 py-1">Тип</th>
<th className="text-left px-2 py-1">Comment</th>
<th className="text-left px-2 py-1">MAC</th>
<th className="text-center px-2 py-1">Граф</th>
<th className="text-center px-2 py-1">Uplink</th>
</tr>
</thead>
<tbody>
{(() => {
const filtered = ifs.filter((it) =>
subTab === 'physical' ? isPhysicalIface(it) : !isPhysicalIface(it)
);
if (ifs.length === 0) {
return (
<tr><td colSpan={7} className="px-2 py-3 text-center text-mk-mute">Нет данных. Опросите устройство.</td></tr>
);
}
if (filtered.length === 0) {
return (
<tr><td colSpan={7} className="px-2 py-3 text-center text-mk-mute">
{subTab === 'physical' ? 'Физических интерфейсов не найдено.' : 'Логических портов не найдено.'}
</td></tr>
);
}
const statusBadge = (it: InterfaceInfo) => {
if (it.disabled) return <span className="inline-flex items-center gap-1 text-mk-mute"><span></span> disabled</span>;
if (it.running) return <span className="inline-flex items-center gap-1 text-mk-ok"><span></span> running</span>;
return <span className="inline-flex items-center gap-1 text-mk-err"><span></span> down</span>;
};
return filtered.map((it) => (
<tr key={it.name} className="border-t border-mk-border hover:bg-mk-panel2/40">
<td className="px-2 py-1 text-center">{statusBadge(it)}</td>
<td className="px-2 py-1 font-mono">{it.name}</td>
<td className="px-2 py-1 text-mk-mute">{it.type || '—'}</td>
<td className="px-2 py-1 text-mk-mute truncate max-w-[200px]">{it.comment || ''}</td>
<td className="px-2 py-1 text-mk-mute font-mono text-[11px]">{it.mac_address || ''}</td>
<td className="px-2 py-1 text-center">
<input type="checkbox" checked={monitored.has(it.name)} onChange={() => toggle(monitored, setMonitored, it.name)} />
</td>
<td className="px-2 py-1 text-center">
<input type="checkbox" checked={uplinks.has(it.name)} onChange={() => toggle(uplinks, setUplinks, it.name)} />
</td>
</tr>
));
})()}
</tbody>
</table>
</div>
<div className="flex items-center gap-3">
<button className="btn-primary !text-xs" onClick={save} disabled={saveBusy}>
<Save size={13} /> {saveBusy ? 'Сохранение…' : 'Сохранить'}
</button>
{saveMsg && <span className="text-xs text-mk-mute">{saveMsg}</span>}
</div>
</div>
<div className="card space-y-2">
<div className="flex items-center gap-2">
<Wifi size={14} className="text-mk-accent2" />
<h3 className="text-sm font-semibold">Состояние uplink</h3>
</div>
{uplinkStatus.length === 0 ? (
<div className="text-xs text-mk-mute">Не выбраны uplink-интерфейсы или нет данных.</div>
) : (
<div className="flex flex-wrap gap-2">
{uplinkStatus.map((u) => (
<div
key={u.name}
className={`px-3 py-2 rounded border ${
u.running
? 'border-mk-ok/40 bg-mk-ok/10 text-mk-ok'
: 'border-mk-err/40 bg-mk-err/10 text-mk-err'
}`}
>
<div className="flex items-center gap-2 text-sm font-medium">
{u.running ? <Wifi size={14} /> : <WifiOff size={14} />}
{u.name}
</div>
<div className="text-[10px] opacity-70 mt-0.5">
{u.running ? 'CONNECTED' : 'DOWN'}{u.ts ? ` · ${new Date(u.ts).toLocaleTimeString()}` : ''}
</div>
</div>
))}
</div>
)}
</div>
{traffic && trafficNames.length > 0 && (
<div className="card space-y-2">
<div className="flex items-center gap-2">
<ActivityIcon size={14} className="text-mk-accent2" />
<h3 className="text-sm font-semibold">Трафик за {hours}ч</h3>
<span className="text-[11px] text-mk-mute ml-auto">
шкала: бит/сек, отрицательные дельты после ребута пропускаются
</span>
</div>
<ResponsiveContainer width="100%" height={280}>
<LineChart data={chart}>
<CartesianGrid strokeDasharray="2 2" stroke="#2a2f36" />
<XAxis dataKey="t" stroke="#8b95a5" fontSize={10} minTickGap={40} />
<YAxis stroke="#8b95a5" fontSize={10} tickFormatter={fmtBps} />
<Tooltip
contentStyle={{ background: '#1e242b', border: '1px solid #2a2f36', fontSize: 12 }}
formatter={(v: any) => fmtBps(Number(v))}
/>
<Legend wrapperStyle={{ fontSize: 10 }} />
{trafficNames.flatMap((name, idx) => [
<Line
key={`${name}_rx`}
type="monotone"
dataKey={`${name}_rx`}
stroke={COLORS[idx % COLORS.length]}
dot={false}
name={`${name} RX`}
/>,
<Line
key={`${name}_tx`}
type="monotone"
dataKey={`${name}_tx`}
stroke={COLORS[idx % COLORS.length]}
strokeDasharray="3 3"
dot={false}
name={`${name} TX`}
/>,
])}
</LineChart>
</ResponsiveContainer>
</div>
)}
</div>
);
}
function IpMgmtTab({ deviceId }: { deviceId: number }) {
const [leases, setLeases] = useState<DhcpLease[]>([]);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const load = async () => {
setBusy(true); setErr(null);
try {
const { data } = await api.get<DhcpLease[]>(`/devices/${deviceId}/dhcp-leases`);
setLeases(data);
} catch (ex: any) {
setErr(ex?.response?.data?.detail ?? 'Ошибка получения leases');
} finally { setBusy(false); }
};
useEffect(() => { load(); }, [deviceId]);
return (
<div className="card p-0 overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-mk-border">
<div className="flex items-center gap-2">
<HardDrive size={14} className="text-mk-accent2" />
<h3 className="text-sm font-semibold">DHCP Leases</h3>
<span className="text-[11px] text-mk-mute">всего: {leases.length}</span>
</div>
<button className="btn-ghost !py-1 !text-xs" onClick={load} disabled={busy}>
<RefreshCw size={13} className={busy ? 'animate-spin' : ''} /> Обновить
</button>
</div>
{err && <div className="px-4 py-2 text-xs text-mk-err">{err}</div>}
<table className="w-full text-xs">
<thead className="bg-mk-panel2 text-mk-mute text-[10px] uppercase tracking-wider">
<tr>
<th className="text-left px-3 py-1">Адрес</th>
<th className="text-left px-3 py-1">MAC</th>
<th className="text-left px-3 py-1">Hostname</th>
<th className="text-left px-3 py-1">Comment</th>
<th className="text-left px-3 py-1">Server</th>
<th className="text-left px-3 py-1">Status</th>
<th className="text-left px-3 py-1">Expires</th>
<th className="text-center px-3 py-1">Static</th>
</tr>
</thead>
<tbody>
{leases.length === 0 && !busy && (
<tr><td colSpan={8} className="px-3 py-3 text-center text-mk-mute">Нет lease</td></tr>
)}
{leases.map((l, i) => (
<tr key={i} className="border-t border-mk-border hover:bg-mk-panel2/40">
<td className="px-3 py-1 font-mono">{l.address}</td>
<td className="px-3 py-1 font-mono text-mk-mute">{l.mac_address}</td>
<td className="px-3 py-1">{l.host_name || '—'}</td>
<td className="px-3 py-1 text-mk-mute">{l.comment || ''}</td>
<td className="px-3 py-1 text-mk-mute">{l.server || '—'}</td>
<td className="px-3 py-1">
<span className={l.status === 'bound' ? 'text-mk-ok' : 'text-mk-mute'}>{l.status || '—'}</span>
</td>
<td className="px-3 py-1 text-mk-mute text-[11px]">{l.expires_after || '—'}</td>
<td className="px-3 py-1 text-center">{l.dynamic === false ? '●' : ''}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function AboutTab({ device, resource }: { device: Device; resource: DeviceResource | null }) {
const [ifs, setIfs] = useState<InterfaceInfo[]>([]);
useEffect(() => {
api.get<InterfaceInfo[]>(`/devices/${device.id}/interfaces`).then((r) => setIfs(r.data)).catch(() => {});
const t = setInterval(() => {
api.get<InterfaceInfo[]>(`/devices/${device.id}/interfaces`).then((r) => setIfs(r.data)).catch(() => {});
}, 15000);
return () => clearInterval(t);
}, [device.id]);
const board = device.model || resource?.board_name || null;
const rows: [string, ReactNode][] = [
['Имя (identity)', device.identity || '—'],
['Модель', board || '—'],
['Архитектура', device.architecture || '—'],
['RouterOS', device.ros_version || '—'],
['Серийный', device.serial || '—'],
['Адрес', `${device.host}:${device.port}${device.use_tls ? ' (api-ssl)' : ''}`],
['Аптайм', resource?.uptime || '—'],
['Последний опрос', device.last_seen ? new Date(device.last_seen).toLocaleString() : '—'],
['Статус', device.status],
];
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
<DeviceMockup boardName={board} interfaces={ifs} />
<div className="card !py-2 !px-3 h-full flex flex-col">
<div className="flex items-center gap-1.5 mb-1">
<Info size={13} className="text-mk-accent2" />
<h3 className="text-xs font-semibold uppercase tracking-wide text-mk-mute">Описание</h3>
</div>
<dl className="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-0 text-xs flex-1 content-start">
{rows.map(([k, v]) => (
<div key={k} className="flex items-baseline gap-2 leading-tight py-0.5">
<dt className="text-mk-mute min-w-[100px] shrink-0">{k}</dt>
<dd className="font-mono text-mk-text break-all">{v}</dd>
</div>
))}
</dl>
</div>
</div>
);
}
+292
View File
@@ -0,0 +1,292 @@
import { FormEvent, useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import {
Plus, Trash2, Check, AlertCircle,
ArrowUpCircle, Wifi, WifiOff,
} from 'lucide-react';
import { api, Device, Firmware } from '@/api/client';
import { latestStableVersion, isOutdated } from '@/utils/version';
function StatusDot({ status }: { status: string }) {
const cls =
status === 'up' ? 'bg-mk-ok' :
status === 'down' ? 'bg-mk-err' :
'bg-mk-mute' ;
return <span className={`inline-block w-2 h-2 rounded-full ${cls} flex-shrink-0`} />;
}
function CheckIcon({ device }: { device: Device }) {
if (device.last_error || device.abnormal_reboot) {
const t = device.abnormal_reboot ? 'Аварийный reboot' : (device.last_error ?? 'ошибка');
return (
<span title={t} className="inline-flex items-center text-mk-err">
<AlertCircle size={14} />
</span>
);
}
if (device.status === 'up') {
return (
<span title="OK" className="inline-flex items-center text-mk-ok">
<Check size={14} />
</span>
);
}
return <span className="inline-flex items-center text-mk-mute">·</span>;
}
export default function Devices() {
const [list, setList] = useState<Device[]>([]);
const [firmware, setFirmware] = useState<Firmware[]>([]);
const [open, setOpen] = useState(false);
const reload = () =>
api.get<Device[]>('/devices', { params: { kind: 'router' } }).then((r) => setList(r.data));
useEffect(() => {
reload();
api.get<Firmware[]>('/firmware').then((r) => setFirmware(r.data)).catch(() => {});
}, []);
const latestVer = useMemo(() => latestStableVersion(firmware), [firmware]);
const remove = async (id: number) => {
if (!confirm('Удалить устройство?')) return;
await api.delete(`/devices/${id}`);
await reload();
};
return (
<div className="space-y-3">
<div className="flex justify-end items-center">
<button className="btn-primary !py-1 !text-xs" onClick={() => setOpen(true)}>
<Plus size={13} /> Добавить
</button>
</div>
<div className="card p-0 overflow-hidden">
<table className="w-full text-[13px]">
<thead className="bg-mk-panel2 text-mk-mute text-[10px] uppercase tracking-wider">
<tr>
<th className="text-left px-2 py-1 w-8">#</th>
<th className="text-left px-2 py-1 w-6"></th>
<th className="text-left px-2 py-1 w-5"></th>
<th className="text-left px-2 py-1">Имя</th>
<th className="text-left px-2 py-1">Хост</th>
<th className="text-left px-2 py-1">Модель</th>
<th className="text-left px-2 py-1">RouterOS</th>
<th className="text-left px-2 py-1">Internet</th>
<th className="text-left px-2 py-1">Статус</th>
<th className="text-right px-2 py-1 w-10"></th>
</tr>
</thead>
<tbody>
{list.length === 0 && (
<tr><td colSpan={10} className="px-3 py-3 text-center text-mk-mute">Нет устройств</td></tr>
)}
{list.map((d, idx) => {
const outdated = isOutdated(d.ros_version, latestVer);
return (
<tr
key={d.id}
className={`border-t border-mk-border hover:bg-mk-panel2/40 ${
outdated ? 'bg-mk-warn/[0.06]' : ''
}`}
>
<td className="px-2 py-0.5 text-mk-mute text-xs">{idx + 1}</td>
<td className="px-2 py-0.5"><CheckIcon device={d} /></td>
<td className="px-2 py-0.5"><StatusDot status={d.status} /></td>
<td className="px-2 py-0.5">
<Link to={`/devices/${d.id}`} className="text-mk-accent2 hover:underline">
{d.identity || d.name}
</Link>
{d.last_error && (
<div className="text-[10px] text-mk-err truncate max-w-[260px]" title={d.last_error}>
{d.last_error}
</div>
)}
</td>
<td className="px-2 py-0.5 text-mk-mute">{d.host}:{d.port}{d.use_tls ? ' (TLS)' : ''}</td>
<td className="px-2 py-0.5 text-mk-mute">{d.model || '—'}</td>
<td className="px-2 py-0.5">
<span className="inline-flex items-center gap-1.5">
{d.ros_version || '—'}
{outdated && (
<span
className="inline-flex items-center gap-0.5 text-mk-warn text-[10px]"
title={`Доступна: ${latestVer}`}
>
<ArrowUpCircle size={11} /> {latestVer}
</span>
)}
</span>
</td>
<td className="px-2 py-0.5">
{d.internet_ok === true && <Wifi size={13} className="text-mk-ok" />}
{d.internet_ok === false && <WifiOff size={13} className="text-mk-warn" />}
{d.internet_ok === null && <span className="text-mk-mute"></span>}
</td>
<td className="px-2 py-0.5">
<span className={`text-[10px] px-1.5 py-0.5 ${
d.status === 'up' ? 'badge-up' : d.status === 'down' ? 'badge-down' : 'badge-unk'
}`}>
{d.status.toUpperCase()}
</span>
</td>
<td className="px-2 py-0.5 text-right">
<button className="btn-ghost !py-0.5 !px-1.5" onClick={() => remove(d.id)} title="Удалить">
<Trash2 size={12} />
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{open && <AddDeviceModal onClose={() => setOpen(false)} onCreated={reload} />}
</div>
);
}
function AddDeviceModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
const [form, setForm] = useState({
name: '', host: '', port: 8729, use_tls: true, username: 'admin', password: '',
});
const [err, setErr] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const submit = async (e: FormEvent) => {
e.preventDefault();
setSaving(true); setErr(null);
try {
await api.post('/devices', form);
onCreated(); onClose();
} catch (ex: any) {
setErr(ex?.response?.data?.detail ?? 'Ошибка');
} finally { setSaving(false); }
};
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="card w-full max-w-md">
<h3 className="text-base font-semibold mb-4">Новое устройство</h3>
<form onSubmit={submit} className="space-y-3">
{(['name', 'host', 'username', 'password'] as const).map((k) => (
<div key={k}>
<label className="text-xs text-mk-mute">{k}</label>
<input
className="input"
type={k === 'password' ? 'password' : 'text'}
value={(form as any)[k]}
onChange={(e) => setForm({ ...form, [k]: e.target.value })}
required
/>
</div>
))}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-mk-mute">port</label>
<input
className="input" type="number"
value={form.port}
onChange={(e) => setForm({ ...form, port: Number(e.target.value) })}
/>
</div>
<label className="flex items-end gap-2 text-sm pb-2">
<input
type="checkbox" checked={form.use_tls}
onChange={(e) => setForm({ ...form, use_tls: e.target.checked })}
/>
api-ssl
</label>
</div>
{err && <div className="text-sm text-mk-err">{err}</div>}
<div className="flex gap-2 justify-end pt-2">
<button type="button" className="btn-ghost" onClick={onClose}>Отмена</button>
<button className="btn-primary" disabled={saving}>{saving ? 'Сохранение…' : 'Создать'}</button>
</div>
</form>
</div>
</div>
);
}
export function EditDeviceModal({ device, onClose, onSaved }: { device: Device; onClose: () => void; onSaved: () => void }) {
const [form, setForm] = useState({
name: device.name,
host: device.host,
port: device.port,
use_tls: device.use_tls,
username: device.username,
password: '',
});
const [err, setErr] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const submit = async (e: FormEvent) => {
e.preventDefault();
setSaving(true); setErr(null);
const payload: Record<string, unknown> = { ...form };
if (!payload.password) delete payload.password;
try {
await api.patch(`/devices/${device.id}`, payload);
onSaved(); onClose();
} catch (ex: any) {
setErr(ex?.response?.data?.detail ?? 'Ошибка');
} finally { setSaving(false); }
};
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="card w-full max-w-md">
<h3 className="text-base font-semibold mb-4">Редактировать устройство</h3>
<form onSubmit={submit} className="space-y-3">
{(['name', 'host', 'username'] as const).map((k) => (
<div key={k}>
<label className="text-xs text-mk-mute">{k}</label>
<input
className="input"
type="text"
value={form[k]}
onChange={(e) => setForm({ ...form, [k]: e.target.value })}
required
/>
</div>
))}
<div>
<label className="text-xs text-mk-mute">password (оставьте пустым без изменений)</label>
<input
className="input"
type="password"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-mk-mute">port</label>
<input
className="input" type="number"
value={form.port}
onChange={(e) => setForm({ ...form, port: Number(e.target.value) })}
/>
</div>
<label className="flex items-end gap-2 text-sm pb-2">
<input
type="checkbox" checked={form.use_tls}
onChange={(e) => setForm({ ...form, use_tls: e.target.checked })}
/>
api-ssl
</label>
</div>
{err && <div className="text-sm text-mk-err">{err}</div>}
<div className="flex gap-2 justify-end pt-2">
<button type="button" className="btn-ghost" onClick={onClose}>Отмена</button>
<button className="btn-primary" disabled={saving}>{saving ? 'Сохранение…' : 'Сохранить'}</button>
</div>
</form>
</div>
</div>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { Router as RouterIcon, Cpu, HardDrive } from 'lucide-react';
import Devices from './Devices';
import SwitchesPage from './Switches';
type TabKey = 'routers' | 'switches';
const TABS: { key: TabKey; label: string; icon: any }[] = [
{ key: 'routers', label: 'Роутеры', icon: RouterIcon },
{ key: 'switches', label: 'Свичи', icon: Cpu },
];
function parseHash(h: string): TabKey {
const v = h.replace(/^#/, '');
return v === 'switches' ? 'switches' : 'routers';
}
export default function DevicesIndex() {
const location = useLocation();
const navigate = useNavigate();
const [tab, setTab] = useState<TabKey>(() => parseHash(location.hash));
useEffect(() => { setTab(parseHash(location.hash)); }, [location.hash]);
const switchTab = (k: TabKey) => {
setTab(k);
navigate({ pathname: location.pathname, hash: `#${k}` }, { replace: true });
};
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<HardDrive size={16} className="text-mk-accent2" />
<h2 className="text-base font-semibold">Устройства</h2>
</div>
<div className="flex items-center gap-1 border-b border-mk-border">
{TABS.map((tb) => {
const Icon = tb.icon;
const active = tb.key === tab;
return (
<button
key={tb.key}
onClick={() => switchTab(tb.key)}
className={`inline-flex items-center gap-1.5 px-3 py-2 text-sm border-b-2 -mb-px transition-colors ${
active
? 'border-mk-accent text-mk-text'
: 'border-transparent text-mk-mute hover:text-mk-text'
}`}
>
<Icon size={14} />
{tb.label}
</button>
);
})}
</div>
<div>
{tab === 'routers' && <Devices />}
{tab === 'switches' && <SwitchesPage />}
</div>
</div>
);
}
+484
View File
@@ -0,0 +1,484 @@
import { FormEvent, useEffect, useState } from 'react';
import { Download, HardDrive, Plus, Trash2, RefreshCw, Layers, CheckCircle2, AlertTriangle, Upload } from 'lucide-react';
import {
api, Firmware, FirmwareBulkOut, FirmwareChannelsOut,
} from '@/api/client';
import { useAuth } from '@/store/auth';
function fmtSize(b: number): string {
if (b < 1024) return `${b} B`;
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KiB`;
return `${(b / 1024 / 1024).toFixed(2)} MiB`;
}
function fmtDt(s?: string): string {
if (!s) return '—';
try { return new Date(s).toLocaleString(); } catch { return s; }
}
function ChannelsWidget({ data, onRefresh, refreshing }: {
data: FirmwareChannelsOut | null; onRefresh: () => void; refreshing: boolean;
}) {
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>
);
}
export default function FirmwarePage({ embedded = false }: { embedded?: boolean } = {}) {
const [list, setList] = useState<Firmware[]>([]);
const [open, setOpen] = useState(false);
const [bulkOpen, setBulkOpen] = useState(false);
const [uploadOpen, setUploadOpen] = useState(false);
const [channels, setChannels] = useState<FirmwareChannelsOut | null>(null);
const [checking, setChecking] = useState(false);
const token = useAuth((s) => s.accessToken);
const reload = () => api.get<Firmware[]>('/firmware').then((r) => setList(r.data));
const reloadChannels = () => api.get<FirmwareChannelsOut>('/firmware/channels')
.then((r) => setChannels(r.data)).catch(() => {});
useEffect(() => { reload(); reloadChannels(); }, []);
const checkUpdates = async () => {
setChecking(true);
try {
await api.post('/firmware/check');
await reloadChannels();
} catch { /* ignore */ }
finally { setChecking(false); }
};
const remove = async (id: number) => {
if (!confirm('Удалить прошивку из репозитория?')) return;
await api.delete(`/firmware/${id}`);
await reload();
};
const download = (f: Firmware) => {
fetch(`/api/v1/firmware/${f.id}/download`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((r) => r.blob())
.then((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = f.name; a.click();
URL.revokeObjectURL(url);
});
};
return (
<div className="space-y-4">
<div className="flex flex-wrap justify-between items-center gap-2">
{!embedded && <h2 className="text-lg font-semibold">Внутренний репозиторий прошивок</h2>}
<div className="flex gap-2 ml-auto">
<button className="btn-ghost" onClick={() => setUploadOpen(true)}>
<Upload size={16} /> Загрузить файл
</button>
<button className="btn-ghost" onClick={() => setBulkOpen(true)}>
<Layers size={16} /> Загрузить по архитектурам
</button>
<button className="btn-primary" onClick={() => setOpen(true)}>
<Plus size={16} /> Загрузить с URL
</button>
</div>
</div>
<ChannelsWidget data={channels} onRefresh={checkUpdates} refreshing={checking} />
<div className="card p-0 overflow-hidden">
<table className="w-full text-[13px]">
<thead className="bg-mk-panel2 text-mk-mute text-[10px] uppercase tracking-wider">
<tr>
<th className="text-left px-3 py-1 w-8">#</th>
<th className="text-left px-3 py-1">Файл</th>
<th className="text-left px-3 py-1">Версия</th>
<th className="text-left px-3 py-1">Архитектура</th>
<th className="text-left px-3 py-1">Канал</th>
<th className="text-left px-3 py-1">Размер</th>
<th className="text-left px-3 py-1">Загружено</th>
<th className="text-right px-3 py-1">Действия</th>
</tr>
</thead>
<tbody>
{list.length === 0 && (
<tr><td colSpan={8} className="px-4 py-6 text-center text-mk-mute">
Нет прошивок. Загрузите по URL или массово по архитектурам.
</td></tr>
)}
{list.map((f, idx) => (
<tr key={f.id} className="border-t border-mk-border hover:bg-mk-panel2/40">
<td className="px-3 py-1 text-mk-mute text-xs">{idx + 1}</td>
<td className="px-3 py-1">
<div className="flex items-center gap-2">
<HardDrive size={13} className="text-mk-mute" />
<span className="truncate">{f.name}</span>
</div>
</td>
<td className="px-3 py-1">{f.version || '—'}</td>
<td className="px-3 py-1">{f.architecture || '—'}</td>
<td className="px-3 py-1">{f.channel || '—'}</td>
<td className="px-3 py-1">{fmtSize(f.size)}</td>
<td className="px-3 py-1 text-mk-mute text-xs">
{new Date(f.created_at).toLocaleString()}
</td>
<td className="px-3 py-1 text-right whitespace-nowrap">
<button className="btn-ghost !py-0.5 !px-1.5" onClick={() => download(f)} title="Скачать">
<Download size={12} />
</button>
<button className="btn-ghost !py-0.5 !px-1.5 ml-1" onClick={() => remove(f.id)} title="Удалить">
<Trash2 size={12} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{open && <ImportFirmwareModal onClose={() => setOpen(false)} onCreated={reload} />}
{uploadOpen && (
<UploadFirmwareModal
arches={channels?.architectures || []}
onClose={() => setUploadOpen(false)}
onDone={reload}
/>
)}
{bulkOpen && (
<BulkImportModal
arches={channels?.architectures || []}
channels={channels}
onClose={() => setBulkOpen(false)}
onDone={reload}
/>
)}
</div>
);
}
function UploadFirmwareModal({ arches, onClose, onDone }: {
arches: string[]; onClose: () => void; onDone: () => void;
}) {
const [file, setFile] = useState<File | null>(null);
const [version, setVersion] = useState('');
const [architecture, setArchitecture] = useState('');
const [channel, setChannel] = useState('stable');
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [msg, setMsg] = useState<string | null>(null);
// Авто-разбор имени файла routeros-<ver>-<arch>.npk
const onPick = (f: File | null) => {
setFile(f);
if (!f) return;
const m = f.name.toLowerCase().match(/^routeros-([\d.]+[a-z0-9.\-]*)-([a-z0-9_]+)\.npk$/);
if (m) {
if (!version) setVersion(m[1]);
if (!architecture) setArchitecture(m[2]);
}
};
const submit = async (e: FormEvent) => {
e.preventDefault();
if (!file) { setErr('Выберите файл'); return; }
setBusy(true); setErr(null); setMsg(null);
const fd = new FormData();
fd.append('file', file);
if (version) fd.append('version', version);
if (architecture) fd.append('architecture', architecture);
if (channel) fd.append('channel', channel);
try {
const r = await api.post<Firmware>('/firmware/upload', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 300000,
});
setMsg(`Загружено: ${r.data.name}` + (r.data.version ? ` (${r.data.version})` : ''));
onDone();
setTimeout(onClose, 800);
} catch (ex: any) {
setErr(ex?.response?.data?.detail ?? String(ex?.message ?? 'Ошибка'));
} finally { setBusy(false); }
};
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="card w-full max-w-md">
<h3 className="text-base font-semibold mb-4">Загрузить прошивку с диска</h3>
<form onSubmit={submit} className="space-y-3">
<div>
<label className="text-xs text-mk-mute">Файл .npk</label>
<input
className="input" type="file" accept=".npk,application/octet-stream" required
onChange={(e) => onPick(e.target.files?.[0] ?? null)}
/>
{file && (
<div className="text-[11px] text-mk-mute mt-1">
{file.name} · {fmtSize(file.size)}
</div>
)}
</div>
<div>
<label className="text-xs text-mk-mute">Версия (необязательно)</label>
<input className="input" type="text" placeholder="например 7.16.1"
value={version} onChange={(e) => setVersion(e.target.value)} />
</div>
<div>
<label className="text-xs text-mk-mute">Архитектура (необязательно)</label>
<input
className="input" type="text" placeholder="например arm64"
list="arch-list"
value={architecture} onChange={(e) => setArchitecture(e.target.value)}
/>
<datalist id="arch-list">
{arches.map((a) => <option key={a} value={a} />)}
</datalist>
</div>
<div>
<label className="text-xs text-mk-mute">Канал</label>
<select className="input" value={channel} onChange={(e) => setChannel(e.target.value)}>
<option value="stable">stable</option>
<option value="long-term">long-term</option>
<option value="testing">testing</option>
<option value="development">development</option>
</select>
</div>
<p className="text-[11px] text-mk-mute">
Лимит: 200 MiB. Дубликаты определяются по sha256 и (версия+архитектура) повторно не сохраняются.
</p>
{err && <div className="text-sm text-mk-err">{err}</div>}
{msg && <div className="text-sm text-mk-ok">{msg}</div>}
<div className="flex gap-2 justify-end pt-2">
<button type="button" className="btn-ghost" onClick={onClose}>Закрыть</button>
<button className="btn-primary" disabled={busy || !file}>
{busy ? 'Загрузка…' : 'Загрузить'}
</button>
</div>
</form>
</div>
</div>
);
}
function ImportFirmwareModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
const [form, setForm] = useState({
url: '', name: '', version: '', architecture: '', channel: 'stable',
});
const [err, setErr] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const submit = async (e: FormEvent) => {
e.preventDefault();
setSaving(true); setErr(null);
const payload: Record<string, unknown> = { url: form.url };
if (form.name) payload.name = form.name;
if (form.version) payload.version = form.version;
if (form.architecture) payload.architecture = form.architecture;
if (form.channel) payload.channel = form.channel;
try {
await api.post('/firmware/import', payload);
onCreated(); onClose();
} catch (ex: any) {
setErr(ex?.response?.data?.detail ?? 'Ошибка');
} finally { setSaving(false); }
};
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="card w-full max-w-md">
<h3 className="text-base font-semibold mb-4">Загрузить прошивку с URL</h3>
<form onSubmit={submit} className="space-y-3">
<div>
<label className="text-xs text-mk-mute">URL .npk</label>
<input
className="input" type="url" required
placeholder="https://download.mikrotik.com/routeros/7.16.1/routeros-7.16.1-arm64.npk"
value={form.url}
onChange={(e) => setForm({ ...form, url: e.target.value })}
/>
</div>
{(['name', 'version', 'architecture'] as const).map((k) => (
<div key={k}>
<label className="text-xs text-mk-mute">{k} (необязательно)</label>
<input
className="input" type="text"
value={form[k]}
onChange={(e) => setForm({ ...form, [k]: e.target.value })}
/>
</div>
))}
<div>
<label className="text-xs text-mk-mute">channel</label>
<select
className="input"
value={form.channel}
onChange={(e) => setForm({ ...form, channel: e.target.value })}
>
<option value="stable">stable</option>
<option value="long-term">long-term</option>
<option value="testing">testing</option>
<option value="development">development</option>
</select>
</div>
{err && <div className="text-sm text-mk-err">{err}</div>}
<div className="flex gap-2 justify-end pt-2">
<button type="button" className="btn-ghost" onClick={onClose}>Отмена</button>
<button className="btn-primary" disabled={saving}>
{saving ? 'Загрузка…' : 'Загрузить'}
</button>
</div>
</form>
</div>
</div>
);
}
function BulkImportModal({ arches, channels, onClose, onDone }: {
arches: string[]; channels: FirmwareChannelsOut | null; onClose: () => void; onDone: () => void;
}) {
const available = channels?.available_channels || ['stable'];
const state = channels?.channels || {};
const [channel, setChannel] = useState(available[0]);
// Версия подставляется из обновления канала, но пользователь может перебить.
const channelVersion = state[channel]?.version || '';
const [version, setVersion] = useState(channelVersion);
const [overridden, setOverridden] = useState(false);
// При смене канала — подставить версию (если юзер её не правил вручную).
useEffect(() => {
if (!overridden) setVersion(channelVersion);
}, [channelVersion, overridden]);
const [picked, setPicked] = useState<Set<string>>(new Set(['arm64', 'mipsbe', 'mmips']));
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<FirmwareBulkOut | null>(null);
const [err, setErr] = useState<string | null>(null);
const toggle = (a: string) => {
const n = new Set(picked);
n.has(a) ? n.delete(a) : n.add(a);
setPicked(n);
};
const submit = async (e: FormEvent) => {
e.preventDefault();
if (!version || picked.size === 0) return;
setBusy(true); setErr(null); setResult(null);
try {
const r = await api.post<FirmwareBulkOut>('/firmware/import-bulk', {
version, channel, architectures: Array.from(picked),
});
setResult(r.data);
onDone();
} catch (ex: any) {
setErr(ex?.response?.data?.detail ?? 'Ошибка');
} finally { setBusy(false); }
};
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="card w-full max-w-xl max-h-[90vh] overflow-auto">
<h3 className="text-base font-semibold mb-4">Массовая загрузка по архитектурам</h3>
<form onSubmit={submit} className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-mk-mute">Канал</label>
<select
className="input"
value={channel}
onChange={(e) => { setChannel(e.target.value); setOverridden(false); }}
>
{available.map((c) => {
const v = state[c]?.version;
return <option key={c} value={c}>{c}{v ? `${v}` : ''}</option>;
})}
</select>
</div>
<div>
<label className="text-xs text-mk-mute">Версия RouterOS {channelVersion && !overridden && <span className="text-mk-mute">(из канала)</span>}</label>
<input
className="input" required placeholder="7.16.1"
value={version}
onChange={(e) => { setVersion(e.target.value); setOverridden(true); }}
/>
{!channelVersion && (
<p className="text-[11px] text-mk-warn mt-1">
Нет данных о версии канала запустите «Проверить обновления».
</p>
)}
</div>
</div>
<div>
<label className="text-xs text-mk-mute">Архитектуры ({picked.size})</label>
<div className="grid grid-cols-3 md:grid-cols-4 gap-1 mt-1">
{arches.map((a) => (
<label key={a} className="flex items-center gap-1.5 text-sm hover:bg-mk-panel2 px-2 py-1 rounded">
<input type="checkbox" checked={picked.has(a)} onChange={() => toggle(a)} />
{a}
</label>
))}
</div>
</div>
<p className="text-[11px] text-mk-mute">
URL формируется как <code>https://download.mikrotik.com/routeros/&lt;version&gt;/routeros-&lt;version&gt;-&lt;arch&gt;.npk</code>.
</p>
{err && <div className="text-sm text-mk-err">{err}</div>}
{result && (
<div className="card !p-2 text-xs space-y-1">
{result.results.map((r) => (
<div key={r.architecture} className="flex items-center gap-2">
{r.ok
? <CheckCircle2 size={12} className="text-mk-ok" />
: <AlertTriangle size={12} className="text-mk-err" />}
<span className="font-mono">{r.architecture}</span>
{r.ok && r.skipped && (
<span className="text-mk-mute">уже в репозитории пропущено</span>
)}
{!r.ok && <span className="text-mk-mute truncate">{r.error}</span>}
</div>
))}
</div>
)}
<div className="flex gap-2 justify-end pt-2">
<button type="button" className="btn-ghost" onClick={onClose}>Закрыть</button>
<button className="btn-primary" disabled={busy || !version || picked.size === 0}>
{busy ? 'Загрузка…' : `Загрузить ${picked.size}`}
</button>
</div>
</form>
</div>
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
import { FormEvent, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Wifi } from 'lucide-react';
import { api } from '@/api/client';
import { useAuth } from '@/store/auth';
export default function Login() {
const navigate = useNavigate();
const setTokens = useAuth((s) => s.setTokens);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const onSubmit = async (e: FormEvent) => {
e.preventDefault();
setErr(null);
setLoading(true);
try {
const { data } = await api.post('/auth/login', { email, password });
setTokens(data.access_token, data.refresh_token, email);
navigate('/dashboard', { replace: true });
} catch (ex: any) {
setErr(ex?.response?.data?.detail ?? 'Ошибка входа');
} finally {
setLoading(false);
}
};
return (
<div className="h-full flex items-center justify-center bg-mk-bg p-6">
<div className="w-full max-w-sm card">
<div className="flex items-center gap-2 mb-6">
<Wifi className="text-mk-accent2" size={28} />
<div>
<div className="text-lg font-semibold">ROSzetta</div>
<div className="text-xs text-mk-mute">Вход в панель управления</div>
</div>
</div>
<form onSubmit={onSubmit} className="space-y-4">
<div>
<label className="block text-xs text-mk-mute mb-1">Логин</label>
<input
className="input"
type="text"
autoComplete="username"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoFocus
/>
</div>
<div>
<label className="block text-xs text-mk-mute mb-1">Пароль</label>
<input
className="input"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
{err && <div className="text-sm text-mk-err">{err}</div>}
<button className="btn-primary w-full" disabled={loading}>
{loading ? 'Входим…' : 'Войти'}
</button>
</form>
</div>
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
import { useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { Bell, Inbox, Send, Sliders } from 'lucide-react';
import AlertsPage from './Alerts';
import TelegramBotPage from './TelegramBot';
import NotifySettingsPage from './NotifySettings';
type TabKey = 'alerts' | 'telegram' | 'settings';
const TABS: { key: TabKey; label: string; icon: any }[] = [
{ key: 'alerts', label: 'Алерты', icon: Bell },
{ key: 'telegram', label: 'Telegram-бот', icon: Send },
{ key: 'settings', label: 'Настройки', icon: Sliders },
];
function parseHash(h: string): TabKey {
const v = h.replace(/^#/, '');
return (v === 'alerts' || v === 'telegram' || v === 'settings') ? v : 'alerts';
}
export default function NotificationCenter() {
const location = useLocation();
const navigate = useNavigate();
const [tab, setTab] = useState<TabKey>(() => parseHash(location.hash));
useEffect(() => {
setTab(parseHash(location.hash));
}, [location.hash]);
const switchTab = (k: TabKey) => {
setTab(k);
navigate({ pathname: location.pathname, hash: `#${k}` }, { replace: true });
};
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Inbox size={16} className="text-mk-accent2" />
<h2 className="text-base font-semibold">Центр уведомлений</h2>
</div>
<div className="flex items-center gap-1 border-b border-mk-border">
{TABS.map((tb) => {
const Icon = tb.icon;
const active = tb.key === tab;
return (
<button
key={tb.key}
onClick={() => switchTab(tb.key)}
className={`inline-flex items-center gap-1.5 px-3 py-2 text-sm border-b-2 -mb-px transition-colors ${
active
? 'border-mk-accent text-mk-text'
: 'border-transparent text-mk-mute hover:text-mk-text'
}`}
>
<Icon size={14} />
{tb.label}
</button>
);
})}
</div>
<div>
{tab === 'alerts' && <AlertsPage />}
{tab === 'telegram' && <TelegramBotPage />}
{tab === 'settings' && <NotifySettingsPage />}
</div>
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
import { useEffect, useState } from 'react';
import { BellOff, Save } from 'lucide-react';
import { AppSettings } from '@/api/client';
import { useSettings } from '@/store/settings';
type NotifyBoolKey = Exclude<keyof AppSettings['notify'], 'style'>;
const NOTIFY_LABELS: Record<NotifyBoolKey, string> = {
device_status: 'Изменение статуса устройства (up/down)',
internet: 'Отсутствие интернета на устройстве',
abnormal_reboot: 'Аномальная перезагрузка устройства',
firmware: 'Появление новой версии RouterOS',
};
export default function NotifySettingsPage() {
const { settings, load, patch } = useSettings();
const [draft, setDraft] = useState<AppSettings['notify'] | null>(null);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
useEffect(() => { load(); }, []);
useEffect(() => { if (settings) setDraft({ ...settings.notify }); }, [settings]);
if (!draft) return <div className="text-mk-mute">Загрузка</div>;
const upd = (k: NotifyBoolKey, v: boolean) => setDraft({ ...draft, [k]: v });
const save = async () => {
setBusy(true); setMsg(null);
try {
await patch({ notify: draft });
setMsg('Сохранено');
} catch (ex: any) {
setMsg(`Ошибка: ${ex?.response?.data?.detail ?? ex.message}`);
} finally { setBusy(false); }
};
return (
<div className="card space-y-3">
<div className="flex items-center gap-2">
<BellOff size={14} className="text-mk-warn" />
<h3 className="text-sm font-semibold">Уведомления о проблемах</h3>
</div>
<p className="text-xs text-mk-mute">
Отключите категории, которые не должны генерировать алерты и попадать в global health.
</p>
<div className="space-y-1.5">
{(Object.keys(NOTIFY_LABELS) as Array<NotifyBoolKey>).map((k) => (
<label key={k} className="flex items-center gap-2 text-sm hover:bg-mk-panel2 px-2 py-1 rounded">
<input
type="checkbox"
checked={draft[k]}
onChange={(e) => upd(k, e.target.checked)}
/>
<span>{NOTIFY_LABELS[k]}</span>
</label>
))}
</div>
<div className="pt-2 border-t border-mk-border">
<div className="text-xs text-mk-mute mb-1.5">Стиль сообщения при полном благополучии:</div>
<div className="flex gap-3">
{(['jokes', 'serious'] as const).map((s) => (
<label key={s} className="flex items-center gap-1.5 text-sm cursor-pointer">
<input
type="radio"
name="notify-style"
checked={draft.style === s}
onChange={() => setDraft({ ...draft, style: s })}
/>
<span>{s === 'jokes' ? 'С шутками' : 'Строго'}</span>
</label>
))}
</div>
</div>
<div className="flex items-center gap-2">
<button className="btn-primary !py-1 !text-xs" onClick={save} disabled={busy}>
<Save size={13} /> {busy ? 'Сохранение…' : 'Сохранить'}
</button>
{msg && <span className="text-xs text-mk-mute">{msg}</span>}
</div>
</div>
);
}
+483
View File
@@ -0,0 +1,483 @@
import { FormEvent, useEffect, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import {
Database, Settings as SettingsIcon, Download, Upload, RefreshCw, Eye, Save,
Globe, Palette, Tag, Activity, Radar, AlertTriangle, User as UserIcon, KeyRound,
} from 'lucide-react';
import { api, AppSettings } from '@/api/client';
import { useAuth } from '@/store/auth';
import { useSettings } from '@/store/settings';
import { useT, LOCALES, THEMES, HEARTBEAT_RANGES, PROBE_INTERVALS } from '@/i18n';
const MENU_LABELS: Record<keyof AppSettings['menu'], string> = {
dashboard: 'Dashboard',
devices: 'Devices',
switches: 'Свичи',
firmware: 'Прошивки',
notif_center: 'Центр уведомлений',
cli: 'Автоматизация (CLI)',
settings: 'Настройки',
};
type TabKey = 'general' | 'probe' | 'user' | 'menu' | 'backup';
function parseHash(h: string): TabKey {
const v = h.replace(/^#/, '');
if (v === 'users' || v === 'password' || v === 'user') return 'user';
if (v === 'menu') return 'menu';
if (v === 'backup') return 'backup';
if (v === 'probe') return 'probe';
// 'config' и любые другие → general
return 'general';
}
const TABS: { key: TabKey; label: string; icon: any }[] = [
{ key: 'general', label: 'Общие', icon: SettingsIcon },
{ key: 'probe', label: 'Опрос', icon: Radar },
{ key: 'user', label: 'Пользователь', icon: UserIcon },
{ key: 'menu', label: 'Меню', icon: Eye },
{ key: 'backup', label: 'Бэкап', icon: Database },
];
export default function SettingsPage() {
const token = useAuth((s) => s.accessToken);
const email = useAuth((s) => s.email);
const { settings, load, patch } = useSettings();
const [busy, setBusy] = useState<string | null>(null);
const [msg, setMsg] = useState<string | null>(null);
const [draft, setDraft] = useState<AppSettings | null>(null);
const restoreInputRef = useRef<HTMLInputElement | null>(null);
const t = useT();
const location = useLocation();
const navigate = useNavigate();
const [tab, setTab] = useState<TabKey>(() => parseHash(location.hash));
useEffect(() => { setTab(parseHash(location.hash)); }, [location.hash]);
const switchTab = (k: TabKey) => {
setTab(k);
navigate({ pathname: location.pathname, hash: `#${k}` }, { replace: true });
};
useEffect(() => { load(); }, []);
useEffect(() => { if (settings) setDraft(structuredClone(settings)); }, [settings]);
const save = async () => {
if (!draft) return;
setBusy('save'); setMsg(null);
try { await patch(draft); setMsg('Настройки сохранены'); }
catch (ex: any) { setMsg(`Ошибка: ${ex?.response?.data?.detail ?? ex.message}`); }
finally { setBusy(null); }
};
const downloadBackup = async (kind: 'config' | 'full') => {
setBusy(kind); setMsg(null);
try {
const resp = await fetch(`/api/v1/controller/backup/${kind}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!resp.ok) throw new Error(await resp.text());
const cd = resp.headers.get('content-disposition') || '';
const m = /filename="([^"]+)"/.exec(cd);
const name = m ? m[1] : `controller-${kind}.tar.gz`;
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = name; a.click();
URL.revokeObjectURL(url);
} catch (ex: any) {
setMsg(`Ошибка: ${ex.message ?? ex}`);
} finally { setBusy(null); }
};
const checkFirmware = async () => {
setBusy('check'); setMsg(null);
try {
const r = await api.post<{ latest_version: string; released_at: string }>('/firmware/check');
setMsg(`Последняя стабильная RouterOS: ${r.data.latest_version} (${new Date(r.data.released_at).toLocaleDateString()})`);
} catch (ex: any) {
setMsg(`Ошибка: ${ex?.response?.data?.detail ?? ex.message}`);
} finally { setBusy(null); }
};
const restoreBackup = async (file: File) => {
const ok = window.confirm(
`Развернуть бэкап «${file.name}»?\n\nВНИМАНИЕ: текущая БД будет полностью заменена. Продолжить?`,
);
if (!ok) return;
setBusy('restore'); setMsg(null);
try {
const fd = new FormData();
fd.append('file', file);
const resp = await fetch('/api/v1/controller/backup/restore', {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: fd,
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data?.detail || resp.statusText);
setMsg(data?.message || 'Бэкап развёрнут. Рекомендуется перезайти в систему.');
load();
} catch (ex: any) {
setMsg(`Ошибка восстановления: ${ex?.message ?? ex}`);
} finally { setBusy(null); }
};
if (!draft) return <div className="text-mk-mute">Загрузка настроек</div>;
const updMenu = (k: keyof AppSettings['menu'], v: boolean) =>
setDraft({ ...draft, menu: { ...draft.menu, [k]: v } });
const ui = draft.ui ?? { instance_name: 'ROSzetta', locale: 'ru', theme: 'mk-dark', heartbeat_hours: 6, probe_interval_minutes: 5 };
const updUi = (k: keyof AppSettings['ui'], v: any) =>
setDraft({ ...draft, ui: { ...ui, [k]: v } });
// На вкладке "Пользователь" своя кнопка сохранения — основная "Сохранить" не нужна.
const showSaveBtn = tab !== 'user';
return (
<div className="space-y-4 max-w-3xl">
<div className="flex items-center gap-2">
<SettingsIcon size={16} />
<h2 className="text-base font-semibold">{t('settings.title')}</h2>
{showSaveBtn && (
<button className="ml-auto btn-primary !py-1 !text-xs" onClick={save} disabled={busy === 'save'}>
<Save size={13} /> {t('common.save')}
</button>
)}
</div>
<div className="flex items-center gap-1 border-b border-mk-border overflow-x-auto">
{TABS.map((tb) => {
const Icon = tb.icon;
const active = tb.key === tab;
return (
<button
key={tb.key}
onClick={() => switchTab(tb.key)}
className={`inline-flex items-center gap-1.5 px-3 py-2 text-sm border-b-2 -mb-px transition-colors whitespace-nowrap ${
active ? 'border-mk-accent text-mk-text' : 'border-transparent text-mk-mute hover:text-mk-text'
}`}
>
<Icon size={14} />
{tb.label}
</button>
);
})}
</div>
{tab === 'general' && (
<>
<div className="card space-y-3">
<div className="flex items-center gap-2">
<Tag size={14} className="text-mk-accent2" />
<h3 className="text-sm font-semibold">{t('settings.identity')}</h3>
</div>
<p className="text-xs text-mk-mute">{t('settings.identity.hint')}</p>
<div>
<label className="text-xs text-mk-mute">{t('settings.instanceName')}</label>
<input
className="input"
type="text"
maxLength={64}
value={ui.instance_name}
onChange={(e) => updUi('instance_name', e.target.value)}
/>
</div>
</div>
<div className="card space-y-3">
<div className="flex items-center gap-2">
<Globe size={14} className="text-mk-accent2" />
<h3 className="text-sm font-semibold">{t('settings.locale')}</h3>
</div>
<div className="flex flex-wrap gap-2">
{LOCALES.map((l) => (
<button
key={l.code}
type="button"
onClick={() => updUi('locale', l.code)}
className={`px-3 py-1.5 rounded-md text-sm border transition-colors ${
ui.locale === l.code
? 'bg-mk-accent/15 border-mk-accent2 text-mk-text'
: 'border-mk-border text-mk-mute hover:bg-mk-panel2'
}`}
>
{l.label}
</button>
))}
</div>
</div>
<div className="card space-y-3">
<div className="flex items-center gap-2">
<Palette size={14} className="text-mk-accent2" />
<h3 className="text-sm font-semibold">{t('settings.theme')}</h3>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{THEMES.map((th) => {
const active = ui.theme === th.id;
return (
<button
key={th.id}
type="button"
onClick={() => updUi('theme', th.id)}
className={`group flex items-center gap-3 p-2 rounded-md border text-left transition-colors ${
active
? 'border-mk-accent2 bg-mk-accent/10'
: 'border-mk-border hover:bg-mk-panel2'
}`}
>
<span className="flex h-8 w-12 rounded overflow-hidden border border-mk-border shrink-0">
<span className="flex-1" style={{ background: th.swatch[0] }} />
<span className="flex-1" style={{ background: th.swatch[1] }} />
<span className="flex-1" style={{ background: th.swatch[2] }} />
</span>
<span className="text-xs">{th.label}</span>
</button>
);
})}
</div>
<p className="text-[11px] text-mk-mute">Тема применяется мгновенно после сохранения.</p>
</div>
</>
)}
{tab === 'probe' && (
<>
<div className="card space-y-3">
<div className="flex items-center gap-2">
<Radar size={14} className="text-mk-accent2" />
<h3 className="text-sm font-semibold">{t('settings.probe')}</h3>
</div>
<p className="text-xs text-mk-mute">{t('settings.probe.hint')}</p>
<div className="flex flex-wrap gap-2">
{PROBE_INTERVALS.map((p) => {
const active = Number(ui.probe_interval_minutes) === p.minutes;
return (
<button
key={p.minutes}
type="button"
onClick={() => updUi('probe_interval_minutes', p.minutes)}
className={`px-3 py-1.5 rounded-md text-sm border transition-colors ${
active
? 'bg-mk-accent/15 border-mk-accent2 text-mk-text'
: 'border-mk-border text-mk-mute hover:bg-mk-panel2'
}`}
>
{p.label}
</button>
);
})}
</div>
</div>
<div className="card space-y-3">
<div className="flex items-center gap-2">
<Activity size={14} className="text-mk-accent2" />
<h3 className="text-sm font-semibold">{t('settings.heartbeat')}</h3>
</div>
<p className="text-xs text-mk-mute">{t('settings.heartbeat.hint')}</p>
<div className="flex flex-wrap gap-2">
{HEARTBEAT_RANGES.map((r) => {
const active = Number(ui.heartbeat_hours) === r.hours;
return (
<button
key={r.hours}
type="button"
onClick={() => updUi('heartbeat_hours', r.hours)}
className={`px-3 py-1.5 rounded-md text-sm border transition-colors ${
active
? 'bg-mk-accent/15 border-mk-accent2 text-mk-text'
: 'border-mk-border text-mk-mute hover:bg-mk-panel2'
}`}
>
{r.label}
</button>
);
})}
</div>
</div>
</>
)}
{tab === 'user' && <UserTab email={email} />}
{tab === 'menu' && (
<div className="card space-y-3">
<div className="flex items-center gap-2">
<Eye size={14} className="text-mk-accent2" />
<h3 className="text-sm font-semibold">{t('settings.menu')}</h3>
</div>
<p className="text-xs text-mk-mute">Скрыть ненужные пункты бокового меню.</p>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{(Object.keys(MENU_LABELS) as Array<keyof AppSettings['menu']>).map((k) => (
<label key={k} className="flex items-center gap-2 text-sm hover:bg-mk-panel2 px-2 py-1 rounded">
<input
type="checkbox"
checked={draft.menu[k]}
onChange={(e) => updMenu(k, e.target.checked)}
disabled={k === 'settings'}
/>
<span className={k === 'settings' ? 'text-mk-mute' : ''}>{MENU_LABELS[k]}</span>
</label>
))}
</div>
<p className="text-[11px] text-mk-mute">Пункт «Настройки» нельзя скрыть.</p>
</div>
)}
{tab === 'backup' && (
<>
<div className="card space-y-3">
<div className="flex items-center gap-2">
<Database size={14} className="text-mk-accent2" />
<h3 className="text-sm font-semibold">Бэкап контроллера</h3>
</div>
<p className="text-xs text-mk-mute">
<b>Полный</b> дамп БД + настройки окружения. <b>Только конфиг</b> без БД.
</p>
<div className="flex flex-wrap gap-2">
<button className="btn-primary !py-1 !text-xs" disabled={busy !== null} onClick={() => downloadBackup('full')}>
<Download size={13} /> Полный (БД + конфиг)
</button>
<button className="btn-ghost !py-1 !text-xs" disabled={busy !== null} onClick={() => downloadBackup('config')}>
<Download size={13} /> Только конфиг
</button>
</div>
<div className="border-t border-mk-border pt-3 mt-2">
<div className="flex items-center gap-2 mb-1">
<Upload size={13} className="text-mk-warn" />
<span className="text-sm font-semibold">Развернуть бэкап</span>
</div>
<p className="text-[11px] text-mk-warn flex items-start gap-1">
<AlertTriangle size={12} className="mt-0.5 shrink-0" />
<span>Деструктивная операция: текущая БД будет полностью заменена.</span>
</p>
<input
ref={restoreInputRef}
type="file"
accept=".tar.gz,.tgz,application/gzip"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) restoreBackup(f);
if (e.target) e.target.value = '';
}}
/>
<button
className="btn-ghost !py-1 !text-xs mt-2 border-mk-warn/50 text-mk-warn hover:bg-mk-warn/10"
disabled={busy !== null}
onClick={() => restoreInputRef.current?.click()}
>
<Upload size={13} /> {busy === 'restore' ? 'Развёртывание…' : 'Выбрать файл бэкапа…'}
</button>
</div>
</div>
<div className="card space-y-3">
<h3 className="text-sm font-semibold">Прошивки</h3>
<p className="text-xs text-mk-mute">Автопроверка раз в сутки. Можно запустить вручную.</p>
<button className="btn-ghost !py-1 !text-xs" disabled={busy !== null} onClick={checkFirmware}>
<RefreshCw size={13} className={busy === 'check' ? 'animate-spin' : ''} /> Проверить сейчас
</button>
</div>
</>
)}
{msg && <div className="card text-sm">{msg}</div>}
</div>
);
}
// ---------- Вкладка «Пользователь» ----------
function UserTab({ email }: { email: string | null }) {
const [current, setCurrent] = useState('');
const [next, setNext] = useState('');
const [confirm, setConfirm] = useState('');
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
const submit = async (e: FormEvent) => {
e.preventDefault();
setMsg(null);
if (next.length < 4) { setMsg({ kind: 'err', text: 'Новый пароль слишком короткий (мин. 4 символа)' }); return; }
if (next !== confirm) { setMsg({ kind: 'err', text: 'Пароли не совпадают' }); return; }
setBusy(true);
try {
await api.post('/auth/change-password', { current, new: next });
setMsg({ kind: 'ok', text: 'Пароль изменён' });
setCurrent(''); setNext(''); setConfirm('');
} catch (ex: any) {
setMsg({ kind: 'err', text: ex?.response?.data?.detail ?? 'Ошибка смены пароля' });
} finally {
setBusy(false);
}
};
return (
<div className="space-y-4">
<div className="card space-y-2">
<div className="flex items-center gap-2">
<UserIcon size={14} className="text-mk-accent2" />
<h3 className="text-sm font-semibold">Текущий пользователь</h3>
</div>
<div className="text-sm">
<span className="text-mk-mute">Логин:</span> <b>{email ?? '—'}</b>
</div>
<p className="text-[11px] text-mk-mute">
Управление списком пользователей пока недоступно. Поддерживается только смена пароля
текущего пользователя.
</p>
</div>
<form onSubmit={submit} className="card space-y-3">
<div className="flex items-center gap-2">
<KeyRound size={14} className="text-mk-accent2" />
<h3 className="text-sm font-semibold">Смена пароля</h3>
</div>
<div>
<label className="text-xs text-mk-mute">Текущий пароль</label>
<input
className="input"
type="password"
autoComplete="current-password"
value={current}
onChange={(e) => setCurrent(e.target.value)}
required
/>
</div>
<div>
<label className="text-xs text-mk-mute">Новый пароль</label>
<input
className="input"
type="password"
autoComplete="new-password"
value={next}
onChange={(e) => setNext(e.target.value)}
required
minLength={4}
/>
</div>
<div>
<label className="text-xs text-mk-mute">Повторите новый пароль</label>
<input
className="input"
type="password"
autoComplete="new-password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
required
minLength={4}
/>
</div>
{msg && (
<div className={`text-sm ${msg.kind === 'ok' ? 'text-mk-ok' : 'text-mk-err'}`}>{msg.text}</div>
)}
<button className="btn-primary !text-xs" disabled={busy}>
{busy ? 'Меняем…' : 'Сменить пароль'}
</button>
</form>
</div>
);
}
+207
View File
@@ -0,0 +1,207 @@
import { FormEvent, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { Plus, Trash2, Pencil, Wifi, WifiOff } from 'lucide-react';
import { api, Device } from '@/api/client';
function StatusDot({ status }: { status: string }) {
const cls =
status === 'up' ? 'bg-mk-ok' :
status === 'down' ? 'bg-mk-err' :
'bg-mk-mute' ;
return <span className={`inline-block w-2 h-2 rounded-full ${cls} flex-shrink-0`} />;
}
export default function SwitchesPage() {
const [list, setList] = useState<Device[]>([]);
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<Device | null>(null);
const reload = () =>
api.get<Device[]>('/devices', { params: { kind: 'switch' } }).then((r) => setList(r.data));
useEffect(() => { reload(); }, []);
const remove = async (id: number) => {
if (!confirm('Удалить свич?')) return;
await api.delete(`/devices/${id}`);
await reload();
};
return (
<div className="space-y-3">
<div className="flex justify-end items-center">
<button className="btn-primary !py-1 !text-xs" onClick={() => setOpen(true)}>
<Plus size={13} /> Добавить
</button>
</div>
<div className="card p-0 overflow-hidden">
<table className="w-full text-[13px]">
<thead className="bg-mk-panel2 text-mk-mute text-[10px] uppercase tracking-wider">
<tr>
<th className="text-left px-2 py-1 w-8">#</th>
<th className="text-left px-2 py-1 w-5"></th>
<th className="text-left px-2 py-1">Имя</th>
<th className="text-left px-2 py-1">Хост</th>
<th className="text-left px-2 py-1">Модель</th>
<th className="text-left px-2 py-1">RouterOS</th>
<th className="text-left px-2 py-1">Internet</th>
<th className="text-left px-2 py-1">Статус</th>
<th className="text-right px-2 py-1 w-20"></th>
</tr>
</thead>
<tbody>
{list.length === 0 && (
<tr><td colSpan={9} className="px-3 py-3 text-center text-mk-mute">Нет свичей</td></tr>
)}
{list.map((d, idx) => (
<tr key={d.id} className="border-t border-mk-border hover:bg-mk-panel2/40">
<td className="px-2 py-0.5 text-mk-mute text-xs">{idx + 1}</td>
<td className="px-2 py-0.5"><StatusDot status={d.status} /></td>
<td className="px-2 py-0.5">
<Link to={`/devices/${d.id}`} className="text-mk-accent2 hover:underline">
{d.identity || d.name}
</Link>
{d.last_error && (
<div className="text-[10px] text-mk-err truncate max-w-[260px]" title={d.last_error}>
{d.last_error}
</div>
)}
</td>
<td className="px-2 py-0.5 text-mk-mute">{d.host}:{d.port}{d.use_tls ? ' (TLS)' : ''}</td>
<td className="px-2 py-0.5 text-mk-mute">{d.model || '—'}</td>
<td className="px-2 py-0.5">{d.ros_version || '—'}</td>
<td className="px-2 py-0.5">
{d.internet_ok === true && <Wifi size={13} className="text-mk-ok" />}
{d.internet_ok === false && <WifiOff size={13} className="text-mk-warn" />}
{d.internet_ok === null && <span className="text-mk-mute"></span>}
</td>
<td className="px-2 py-0.5">
<span className={`text-[10px] px-1.5 py-0.5 ${
d.status === 'up' ? 'badge-up' : d.status === 'down' ? 'badge-down' : 'badge-unk'
}`}>
{d.status.toUpperCase()}
</span>
</td>
<td className="px-2 py-0.5 text-right">
<button className="btn-ghost !py-0.5 !px-1.5" onClick={() => setEditing(d)} title="Редактировать">
<Pencil size={12} />
</button>
<button className="btn-ghost !py-0.5 !px-1.5 ml-1" onClick={() => remove(d.id)} title="Удалить">
<Trash2 size={12} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{open && <SwitchModal onClose={() => setOpen(false)} onSaved={reload} />}
{editing && (
<SwitchModal
device={editing}
onClose={() => setEditing(null)}
onSaved={() => { setEditing(null); reload(); }}
/>
)}
</div>
);
}
function SwitchModal({
device, onClose, onSaved,
}: {
device?: Device;
onClose: () => void;
onSaved: () => void;
}) {
const isEdit = !!device;
const [form, setForm] = useState({
name: device?.name ?? '',
host: device?.host ?? '',
port: device?.port ?? 8729,
use_tls: device?.use_tls ?? true,
username: device?.username ?? 'admin',
password: '',
});
const [err, setErr] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const submit = async (e: FormEvent) => {
e.preventDefault();
setSaving(true); setErr(null);
try {
if (isEdit) {
const payload: Record<string, unknown> = { ...form };
if (!payload.password) delete payload.password;
await api.patch(`/devices/${device!.id}`, payload);
} else {
await api.post('/devices', { ...form, kind: 'switch' });
}
onSaved(); onClose();
} catch (ex: any) {
setErr(ex?.response?.data?.detail ?? 'Ошибка');
} finally { setSaving(false); }
};
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="card w-full max-w-md">
<h3 className="text-base font-semibold mb-4">
{isEdit ? 'Редактировать свич' : 'Новый свич'}
</h3>
<form onSubmit={submit} className="space-y-3">
{(['name', 'host', 'username'] as const).map((k) => (
<div key={k}>
<label className="text-xs text-mk-mute">{k}</label>
<input
className="input"
type="text"
value={(form as any)[k]}
onChange={(e) => setForm({ ...form, [k]: e.target.value })}
required
/>
</div>
))}
<div>
<label className="text-xs text-mk-mute">
password{isEdit ? ' (оставьте пустым — без изменений)' : ''}
</label>
<input
className="input"
type="password"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
required={!isEdit}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-mk-mute">port</label>
<input
className="input" type="number"
value={form.port}
onChange={(e) => setForm({ ...form, port: Number(e.target.value) })}
/>
</div>
<label className="flex items-end gap-2 text-sm pb-2">
<input
type="checkbox" checked={form.use_tls}
onChange={(e) => setForm({ ...form, use_tls: e.target.checked })}
/>
api-ssl
</label>
</div>
{err && <div className="text-sm text-mk-err">{err}</div>}
<div className="flex gap-2 justify-end pt-2">
<button type="button" className="btn-ghost" onClick={onClose}>Отмена</button>
<button className="btn-primary" disabled={saving}>
{saving ? 'Сохранение…' : isEdit ? 'Сохранить' : 'Создать'}
</button>
</div>
</form>
</div>
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
import { useEffect, useState } from 'react';
import { Send, Save } from 'lucide-react';
import { api, AppSettings } from '@/api/client';
import { useSettings } from '@/store/settings';
export default function TelegramBotPage() {
const { settings, load, patch } = useSettings();
const [draft, setDraft] = useState<AppSettings['telegram'] | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [msg, setMsg] = useState<string | null>(null);
useEffect(() => { load(); }, []);
useEffect(() => {
if (settings) setDraft({ ...settings.telegram });
}, [settings]);
if (!draft) return <div className="text-mk-mute">Загрузка</div>;
const upd = (k: keyof AppSettings['telegram'], v: any) =>
setDraft({ ...draft, [k]: v });
const save = async () => {
setBusy('save'); setMsg(null);
try {
await patch({ telegram: draft });
setMsg('Настройки Telegram сохранены');
} catch (ex: any) {
setMsg(`Ошибка: ${ex?.response?.data?.detail ?? ex.message}`);
} finally { setBusy(null); }
};
const test = async () => {
setBusy('tg'); setMsg(null);
try {
await patch({ telegram: draft });
const r = await api.post<{ ok: boolean; message: string }>('/settings/telegram/test');
setMsg(r.data.ok ? 'Тестовое сообщение отправлено ✓' : `Ошибка TG: ${r.data.message}`);
} catch (ex: any) {
setMsg(`Ошибка: ${ex?.response?.data?.detail ?? ex.message}`);
} finally { setBusy(null); }
};
return (
<div className="card space-y-3">
<div className="flex items-center gap-2">
<Send size={14} className="text-mk-accent2" />
<h3 className="text-sm font-semibold">Telegram-бот</h3>
</div>
<p className="text-xs text-mk-mute">
Опциональная отправка алертов в Telegram. Создайте бота через <code>@BotFather</code>,
получите <code>chat_id</code> через <code>@userinfobot</code>.
</p>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox" checked={draft.enabled}
onChange={(e) => upd('enabled', e.target.checked)}
/>
Включить отправку
</label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="text-xs text-mk-mute">Bot token</label>
<input
className="input font-mono text-xs"
type="password"
placeholder="123456:ABC-DEF…"
value={draft.bot_token}
onChange={(e) => upd('bot_token', e.target.value)}
/>
</div>
<div>
<label className="text-xs text-mk-mute">Chat ID</label>
<input
className="input font-mono text-xs"
type="text"
placeholder="123456789 или -100…"
value={draft.chat_id}
onChange={(e) => upd('chat_id', e.target.value)}
/>
</div>
<div>
<label className="text-xs text-mk-mute">Минимальная серьёзность</label>
<select
className="input"
value={draft.min_severity}
onChange={(e) => upd('min_severity', e.target.value)}
>
<option value="info">info</option>
<option value="warning">warning</option>
<option value="error">error</option>
<option value="critical">critical</option>
</select>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<button className="btn-primary !py-1 !text-xs" onClick={save} disabled={busy !== null}>
<Save size={13} /> {busy === 'save' ? 'Сохранение…' : 'Сохранить'}
</button>
<button
className="btn-ghost !py-1 !text-xs"
onClick={test}
disabled={busy !== null || !draft.bot_token}
>
<Send size={13} /> {busy === 'tg' ? 'Отправка…' : 'Сохранить и отправить тест'}
</button>
{msg && <span className="text-xs text-mk-mute">{msg}</span>}
</div>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface AuthState {
accessToken: string | null;
refreshToken: string | null;
email: string | null;
setTokens: (a: string, r: string, email?: string) => void;
logout: () => void;
}
export const useAuth = create<AuthState>()(
persist(
(set) => ({
accessToken: null,
refreshToken: null,
email: null,
setTokens: (a, r, email) => set({ accessToken: a, refreshToken: r, email: email ?? null }),
logout: () => set({ accessToken: null, refreshToken: null, email: null }),
}),
{ name: 'mcc-auth' },
),
);
+35
View File
@@ -0,0 +1,35 @@
import { create } from 'zustand';
import { api, AppSettings } from '@/api/client';
import { applyTheme, applyLocale, applyInstanceName } from '@/utils/theme';
interface SettingsState {
settings: AppSettings | null;
loading: boolean;
load: () => Promise<void>;
patch: (p: Partial<AppSettings> | Record<string, unknown>) => Promise<void>;
}
function applyAll(s: AppSettings | null) {
if (!s?.ui) return;
applyTheme(s.ui.theme);
applyLocale(s.ui.locale);
applyInstanceName(s.ui.instance_name);
}
export const useSettings = create<SettingsState>((set) => ({
settings: null,
loading: false,
load: async () => {
set({ loading: true });
try {
const r = await api.get<AppSettings>('/settings');
set({ settings: r.data });
applyAll(r.data);
} finally { set({ loading: false }); }
},
patch: async (p) => {
const r = await api.put<AppSettings>('/settings', p);
set({ settings: r.data });
applyAll(r.data);
},
}));
+17
View File
@@ -0,0 +1,17 @@
/** 10 шуточных «всё ОК» сообщений для GlobalHealth. */
export const OK_MESSAGES: string[] = [
'Всё чётко, бро. ✨',
'Полный релакс, без паники. 🧘',
'Полёт нормальный, чай заварен. ☕',
'Сервер дышит ровно, спи спокойно. 💤',
'Муха не пролетит, всё ок. 🪰',
'Данные на месте, никуда не сбежали. 💾',
'Ситуация под полным кайфом. 😎',
'Железо холодное, как сердце бывшей. ❄️',
'Ошибки ушли в отпуск навсегда. 🏖️',
'Всё идёт просто замечательно. 👍',
];
export function pickOkMessage(): string {
return OK_MESSAGES[Math.floor(Math.random() * OK_MESSAGES.length)];
}
+14
View File
@@ -0,0 +1,14 @@
// Применяет тему и язык к документу при загрузке/смене настроек.
export function applyTheme(theme: string | undefined) {
const id = theme && typeof theme === 'string' ? theme : 'mk-dark';
document.documentElement.setAttribute('data-theme', id);
}
export function applyLocale(locale: string | undefined) {
const id = locale && typeof locale === 'string' ? locale : 'ru';
document.documentElement.setAttribute('lang', id);
}
export function applyInstanceName(name: string | undefined) {
if (name) document.title = name;
}
+36
View File
@@ -0,0 +1,36 @@
// Простой компаратор версий вида "7.15.3" / "7.15rc4" / "stable"
function tokenize(v: string): number[] {
const m = v.match(/\d+/g);
return m ? m.map((x) => parseInt(x, 10)) : [0];
}
export function compareVersions(a: string, b: string): number {
const A = tokenize(a);
const B = tokenize(b);
const n = Math.max(A.length, B.length);
for (let i = 0; i < n; i++) {
const x = A[i] ?? 0;
const y = B[i] ?? 0;
if (x !== y) return x - y;
}
return 0;
}
export interface FirmwareLike {
version: string | null;
channel: string | null;
}
/** Возвращает максимальную версию из репозитория (только канал stable, либо null). */
export function latestStableVersion(firmware: FirmwareLike[]): string | null {
const versions = firmware
.filter((f) => !!f.version && (!f.channel || f.channel === 'stable'))
.map((f) => f.version as string);
if (versions.length === 0) return null;
return versions.reduce((a, b) => (compareVersions(a, b) >= 0 ? a : b));
}
export function isOutdated(deviceVersion: string | null, latest: string | null): boolean {
if (!deviceVersion || !latest) return false;
return compareVersions(deviceVersion, latest) < 0;
}
+45
View File
@@ -0,0 +1,45 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
// Палитра завязана на CSS-переменные (см. index.css [data-theme=...]).
// Значения переменных — raw "R G B" (без rgb()), чтобы работали opacity-модификаторы Tailwind: bg-unifi-ok/15.
unifi: {
bg: 'rgb(var(--c-bg) / <alpha-value>)',
panel: 'rgb(var(--c-panel) / <alpha-value>)',
panel2: 'rgb(var(--c-panel2) / <alpha-value>)',
border: 'rgb(var(--c-border) / <alpha-value>)',
text: 'rgb(var(--c-text) / <alpha-value>)',
mute: 'rgb(var(--c-mute) / <alpha-value>)',
accent: 'rgb(var(--c-accent) / <alpha-value>)',
accent2: 'rgb(var(--c-accent2) / <alpha-value>)',
ok: 'rgb(var(--c-ok) / <alpha-value>)',
warn: 'rgb(var(--c-warn) / <alpha-value>)',
err: 'rgb(var(--c-err) / <alpha-value>)',
},
// Алиас mk-* → те же CSS-переменные, что и unifi-*.
// Нужен для совместимости со старыми компонентами (CLI.tsx, ChatBot, AboutModal, index.css),
// где ещё используются классы вида text-mk-mute, border-mk-border и т.п.
mk: {
bg: 'rgb(var(--c-bg) / <alpha-value>)',
panel: 'rgb(var(--c-panel) / <alpha-value>)',
panel2: 'rgb(var(--c-panel2) / <alpha-value>)',
border: 'rgb(var(--c-border) / <alpha-value>)',
text: 'rgb(var(--c-text) / <alpha-value>)',
mute: 'rgb(var(--c-mute) / <alpha-value>)',
accent: 'rgb(var(--c-accent) / <alpha-value>)',
accent2: 'rgb(var(--c-accent2) / <alpha-value>)',
ok: 'rgb(var(--c-ok) / <alpha-value>)',
warn: 'rgb(var(--c-warn) / <alpha-value>)',
err: 'rgb(var(--c-err) / <alpha-value>)',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
},
},
},
plugins: [],
};
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": { "@/*": ["src/*"] }
},
"include": ["src"]
}
+39
View File
@@ -0,0 +1,39 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'node:path';
// Плагин: на все ответы dev-сервера ставит Cache-Control: no-store.
// Решает проблему, когда браузер/прокси кэшируют HMR-обновлённые файлы.
const noCache = () => ({
name: 'roszetta-no-cache',
configureServer(server: any) {
server.middlewares.use((_req: any, res: any, next: any) => {
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
next();
});
},
});
export default defineConfig({
plugins: [react(), noCache()],
resolve: {
alias: { '@': path.resolve(__dirname, 'src') },
},
server: {
host: '0.0.0.0',
port: 5173,
watch: {
// Под Docker bind-mount inotify иногда не отрабатывает — fallback на polling.
usePolling: true,
interval: 500,
},
proxy: {
'/api': {
target: process.env.VITE_API_URL || 'http://backend:8000',
changeOrigin: true,
},
},
},
});