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