diff --git a/README.md b/README.md index 0f3c44e..5ec360a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A self-hosted, full-stack network management platform for MikroTik devices. Monitor, configure, and manage your entire MikroTik infrastructure — routers, switches, and wireless access points — from a single web interface. -![Version](https://img.shields.io/badge/version-0.10.9_Beta-blue) +![Version](https://img.shields.io/badge/version-0.11.0_Beta-blue) ![License](https://img.shields.io/badge/license-AGPLv3-blue) ![Docker](https://img.shields.io/badge/docker-compose-2496ED?logo=docker&logoColor=white) ![TypeScript](https://img.shields.io/badge/TypeScript-5.3-3178C6?logo=typescript&logoColor=white) @@ -108,6 +108,7 @@ A self-hosted, full-stack network management platform for MikroTik devices. Moni - Per-device notes, rack location, and physical address with map support - Device credential encryption at rest - **Bulk device add** — "Try All" discovered devices runs as a server-side background job (survives browser tab close) with live progress and cancel support +- CPU load and historical sparkline displayed correctly for all device types, including hardware switches that report 0% CPU via ASIC offloading ### Routers - Routing table viewer @@ -119,6 +120,7 @@ A self-hosted, full-stack network management platform for MikroTik devices. Moni - VLAN management (create, edit, delete VLANs) - Per-port configuration and VLAN membership - Switch overview with port status +- **Copy VLANs from another switch** — 3-step wizard that copies VLAN IDs and names from any other managed switch onto the current device, with manual per-VLAN port assignment (tagged/untagged) using a click-to-cycle interface chip grid, conflict detection with per-VLAN skip/overwrite control, and a review summary before any changes are applied ### Wireless - Per-AP SSID management — create, edit, enable/disable, delete wireless interfaces diff --git a/backend/package.json b/backend/package.json index c776cf4..294900d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-backend", - "version": "0.10.9-beta", + "version": "0.11.0-beta", "description": "Mikrotik Network Management Platform - Backend API", "main": "dist/index.js", "scripts": { diff --git a/backend/src/routes/devices.ts b/backend/src/routes/devices.ts index f1aff17..e34aa37 100644 --- a/backend/src/routes/devices.ts +++ b/backend/src/routes/devices.ts @@ -1364,6 +1364,55 @@ router.delete('/:id/vlans/:vlanDbId', requireWrite, async (req: Request, res: Re } }); +// POST /api/devices/:id/vlans/copy (bulk copy VLANs from another switch) +router.post('/:id/vlans/copy', requireWrite, async (req: Request, res: Response) => { + const deviceRow = await queryOne( + `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, + [req.params.id] + ); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + + const { operations } = req.body as { + operations: Array<{ + action: 'add' | 'update'; + vlan_id: number; + bridge: string; + tagged_ports: string[]; + untagged_ports: string[]; + }>; + }; + + if (!Array.isArray(operations) || operations.length === 0) { + return res.status(400).json({ error: 'operations array is required and must not be empty' }); + } + + const collector = new DeviceCollector(deviceRow); + const results: Array<{ vlan_id: number; action: string; success: boolean; error?: string }> = []; + + try { + await collector.connect(); + + for (const op of operations) { + try { + if (op.action === 'add') { + await collector.addBridgeVlan(op.bridge, op.vlan_id, op.tagged_ports, op.untagged_ports); + } else { + await collector.updateBridgeVlan(op.bridge, op.vlan_id, op.tagged_ports, op.untagged_ports); + } + results.push({ vlan_id: op.vlan_id, action: op.action, success: true }); + } catch (err) { + results.push({ vlan_id: op.vlan_id, action: op.action, success: false, error: err instanceof Error ? err.message : String(err) }); + } + } + + await collector.collectVlans(); + const vlans = await query(`SELECT * FROM vlans WHERE device_id = $1 ORDER BY vlan_id ASC`, [req.params.id]); + return res.json({ results, vlans }); + } finally { + collector.disconnect(); + } +}); + // ─── Bond (LAG / LACP) routes ──────────────────────────────────────────────── // POST /api/devices/:id/bonds diff --git a/frontend/package.json b/frontend/package.json index 439e154..2c964e8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-frontend", - "version": "0.10.9-beta", + "version": "0.11.0-beta", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/components/device-detail/CopyVlanModal.tsx b/frontend/src/components/device-detail/CopyVlanModal.tsx new file mode 100644 index 0000000..4ed1daa --- /dev/null +++ b/frontend/src/components/device-detail/CopyVlanModal.tsx @@ -0,0 +1,911 @@ +import { useState, useMemo, useEffect } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { X, ChevronRight, Copy, AlertTriangle, Check, RefreshCw, Info } from 'lucide-react'; +import { devicesApi } from '../../services/api'; +import type { Device, Vlan, SwitchPort } from '../../types'; +import clsx from 'clsx'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +type Step = 1 | 2 | 3; +type PortAssignment = 'none' | 'tagged' | 'untagged'; +type ConflictChoice = 'skip' | 'overwrite'; +type OpStatus = 'add' | 'identical' | 'conflict'; + +interface AnalyzedOp { + sourceVlan: Vlan; + status: OpStatus; + targetBridge: string; + bridgeExistsOnTarget: boolean; + effectiveTagged: string[]; + effectiveUntagged: string[]; + existingVlan?: Vlan; + conflictChoice: ConflictChoice; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function arraysEqualAsSet(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false; + const sa = new Set(a); + return b.every((x) => sa.has(x)); +} + +function naturalSort(a: string, b: string): number { + return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }); +} + +// ─── Sub-components ─────────────────────────────────────────────────────────── + +function StepIndicator({ step }: { step: Step }) { + const labels = ['Select VLANs', 'Assign Ports', 'Confirm']; + return ( +
+ {labels.map((label, i) => ( +
+ + i + 1 + ? 'bg-blue-500 text-white' + : step === i + 1 + ? 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-400' + : 'bg-gray-100 text-gray-400 dark:bg-slate-700 dark:text-slate-500' + )}> + {step > i + 1 ? '✓' : i + 1} + + {label} + + {i < 2 && ( + + )} +
+ ))} +
+ ); +} + +function StatusBadge({ op }: { op: AnalyzedOp }) { + if (op.status === 'add') { + return ( + + NEW + + ); + } + if (op.status === 'identical') { + return ( + + IDENTICAL + + ); + } + return op.conflictChoice === 'overwrite' ? ( + + OVERWRITE + + ) : ( + + SKIP + + ); +} + +// Interactive port chip — cycles None → Tagged → Untagged → None on click +function PortChip({ + name, + assignment, + onClick, +}: { + name: string; + assignment: PortAssignment; + onClick: () => void; +}) { + return ( + + ); +} + +// ─── Main component ─────────────────────────────────────────────────────────── + +export default function CopyVlanModal({ + deviceId, + deviceName, + onClose, +}: { + deviceId: number; + deviceName: string; + onClose: () => void; +}) { + const queryClient = useQueryClient(); + + // ── Step state ── + const [step, setStep] = useState(1); + + // ── Step 1 state ── + const [sourceDeviceId, setSourceDeviceId] = useState(null); + const [selectedVlanIds, setSelectedVlanIds] = useState>(new Set()); + + // ── Step 2 user assignments ── + // portAssignments[vlan_id][portName] = 'tagged' | 'untagged' | 'none' + const [portAssignments, setPortAssignments] = useState>>({}); + const [bridgeOverrides, setBridgeOverrides] = useState>({}); + const [conflictChoices, setConflictChoices] = useState>({}); + + // ── Step 3 results ── + const [applyResults, setApplyResults] = useState< + { vlan_id: number; action: string; success: boolean; error?: string }[] | null + >(null); + + // ── Data fetching ────────────────────────────────────────────────────────── + + const { data: allDevices = [] } = useQuery({ + queryKey: ['devices'], + queryFn: () => devicesApi.list().then((r) => r.data), + }); + const switchDevices = (allDevices as Device[]).filter( + (d) => d.device_type === 'switch' && d.id !== deviceId + ); + + const { data: sourceVlans = [], isLoading: loadingSource } = useQuery({ + queryKey: ['vlans', sourceDeviceId], + queryFn: () => devicesApi.getVlans(sourceDeviceId!).then((r) => r.data), + enabled: sourceDeviceId != null, + }); + + const { data: targetVlans = [] } = useQuery({ + queryKey: ['vlans', deviceId], + queryFn: () => devicesApi.getVlans(deviceId).then((r) => r.data), + }); + + const { data: targetPortsData } = useQuery({ + queryKey: ['ports', deviceId], + queryFn: () => devicesApi.getPorts(deviceId).then((r) => r.data), + }); + const targetPorts: SwitchPort[] = targetPortsData?.ports ?? []; + + const targetBridgeNames = useMemo( + () => targetPorts.filter((p) => p.type === 'bridge').map((p) => p.name).sort(naturalSort), + [targetPorts] + ); + + // Ports available for assignment: everything except VLAN-type interfaces + const assignablePorts = useMemo( + () => + targetPorts + .filter((p) => p.type !== 'vlan') + .map((p) => p.name) + .sort(naturalSort), + [targetPorts] + ); + + // ── Side-effects ─────────────────────────────────────────────────────────── + + useEffect(() => { + setSelectedVlanIds(new Set()); + setBridgeOverrides({}); + setConflictChoices({}); + setPortAssignments({}); + }, [sourceDeviceId]); + + // Auto-select all except VLAN 1 when source VLANs load + useEffect(() => { + if ((sourceVlans as Vlan[]).length > 0) { + setSelectedVlanIds( + new Set((sourceVlans as Vlan[]).filter((v) => v.vlan_id !== 1).map((v) => v.vlan_id)) + ); + } + }, [sourceVlans]); + + // ── Port assignment helpers ──────────────────────────────────────────────── + + const cyclePort = (vlanId: number, portName: string) => { + setPortAssignments((prev) => { + const current: PortAssignment = prev[vlanId]?.[portName] ?? 'none'; + const next: PortAssignment = + current === 'none' ? 'tagged' : current === 'tagged' ? 'untagged' : 'none'; + return { ...prev, [vlanId]: { ...(prev[vlanId] ?? {}), [portName]: next } }; + }); + }; + + const setPortDirect = (vlanId: number, portName: string, value: PortAssignment) => { + setPortAssignments((prev) => ({ + ...prev, + [vlanId]: { ...(prev[vlanId] ?? {}), [portName]: value }, + })); + }; + + const getAssignment = (vlanId: number, portName: string): PortAssignment => + portAssignments[vlanId]?.[portName] ?? 'none'; + + const getEffective = (vlanId: number) => { + const entries = Object.entries(portAssignments[vlanId] ?? {}); + return { + tagged: entries.filter(([, v]) => v === 'tagged').map(([k]) => k).sort(naturalSort), + untagged: entries.filter(([, v]) => v === 'untagged').map(([k]) => k).sort(naturalSort), + }; + }; + + // ── Analysis (memoized) ──────────────────────────────────────────────────── + + const analyzedOps = useMemo((): AnalyzedOp[] => { + return (sourceVlans as Vlan[]) + .filter((sv) => selectedVlanIds.has(sv.vlan_id)) + .map((sv) => { + const sourceBridge = sv.bridge ?? ''; + const targetBridge = + bridgeOverrides[sv.vlan_id] ?? + (targetBridgeNames.includes(sourceBridge) + ? sourceBridge + : targetBridgeNames[0] ?? sourceBridge); + const bridgeExistsOnTarget = targetBridgeNames.includes(targetBridge); + + const { tagged: effectiveTagged, untagged: effectiveUntagged } = getEffective(sv.vlan_id); + + const existingVlan = (targetVlans as Vlan[]).find((v) => v.vlan_id === sv.vlan_id); + let status: OpStatus; + if (!existingVlan) { + status = 'add'; + } else { + const isIdentical = + existingVlan.bridge === targetBridge && + arraysEqualAsSet(existingVlan.tagged_ports ?? [], effectiveTagged) && + arraysEqualAsSet(existingVlan.untagged_ports ?? [], effectiveUntagged); + status = isIdentical ? 'identical' : 'conflict'; + } + + return { + sourceVlan: sv, + status, + targetBridge, + bridgeExistsOnTarget, + effectiveTagged, + effectiveUntagged, + existingVlan, + conflictChoice: conflictChoices[sv.vlan_id] ?? 'skip', + }; + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sourceVlans, selectedVlanIds, targetVlans, targetBridgeNames, bridgeOverrides, conflictChoices, portAssignments]); + + const toApply = analyzedOps.filter( + (op) => op.status === 'add' || (op.status === 'conflict' && op.conflictChoice === 'overwrite') + ); + + const hasVlan1Selected = selectedVlanIds.has(1); + + // ── Mutation ─────────────────────────────────────────────────────────────── + + const applyMutation = useMutation({ + mutationFn: () => + devicesApi.copyVlans( + deviceId, + toApply.map((op) => ({ + action: op.status === 'add' ? 'add' : 'update', + vlan_id: op.sourceVlan.vlan_id, + bridge: op.targetBridge, + tagged_ports: op.effectiveTagged, + untagged_ports: op.effectiveUntagged, + })) + ), + onSuccess: (res) => { + setApplyResults(res.data.results); + queryClient.invalidateQueries({ queryKey: ['vlans', deviceId] }); + }, + }); + + // ── Toggle helpers ───────────────────────────────────────────────────────── + + const toggleVlan = (vlanId: number) => + setSelectedVlanIds((prev) => { + const next = new Set(prev); + next.has(vlanId) ? next.delete(vlanId) : next.add(vlanId); + return next; + }); + + const selectAll = () => + setSelectedVlanIds(new Set((sourceVlans as Vlan[]).map((v) => v.vlan_id))); + + const selectNone = () => setSelectedVlanIds(new Set()); + + // ── Step 1 ───────────────────────────────────────────────────────────────── + + const renderStep1 = () => ( +
+
+ + + {switchDevices.length === 0 && ( +

+ No other switches found. Add another switch device first. +

+ )} +
+ + {sourceDeviceId != null && ( +
+
+ + {loadingSource + ? 'Loading VLANs…' + : `VLANs on ${switchDevices.find((d) => d.id === sourceDeviceId)?.name ?? 'source'}`} + + {!loadingSource && (sourceVlans as Vlan[]).length > 0 && ( +
+ + · + +
+ )} +
+ + {loadingSource && ( +
+ Loading… +
+ )} + + {!loadingSource && (sourceVlans as Vlan[]).length === 0 && ( +

No VLANs found on this device.

+ )} + + {!loadingSource && (sourceVlans as Vlan[]).length > 0 && ( +
+ {(sourceVlans as Vlan[]).map((vlan) => { + const isVlan1 = vlan.vlan_id === 1; + const checked = selectedVlanIds.has(vlan.vlan_id); + return ( + + ); + })} +
+ )} + + {hasVlan1Selected && ( +
+ + + VLAN 1 is selected. This is often the default management VLAN. + Take care when assigning ports to avoid losing device reachability. + +
+ )} +
+ )} +
+ ); + + // ── Step 2 ───────────────────────────────────────────────────────────────── + + const renderStep2 = () => ( +
+
+ + + For each VLAN, choose which interfaces on {deviceName} should participate and how. + Click a port to cycle: None + {' → '} + Tagged + {' → '} + Untagged + {' → '} + None. + You can also leave all ports unassigned and configure them later. + +
+ + {assignablePorts.length === 0 && ( +
+ + + No interfaces detected on this device yet. Run a sync first so ports can be listed here. + You can still proceed and configure ports manually in the VLAN editor afterward. + +
+ )} + + {analyzedOps.map((op) => { + const { tagged: effTagged, untagged: effUntagged } = getEffective(op.sourceVlan.vlan_id); + const isSkipped = op.status === 'identical' || (op.status === 'conflict' && op.conflictChoice === 'skip'); + + return ( +
+ {/* Header */} +
+
+ + VLAN {op.sourceVlan.vlan_id} + + {op.sourceVlan.name && ( + {op.sourceVlan.name} + )} +
+ +
+ + {op.status === 'identical' && ( +

+ Your port assignment matches what's already on this device — nothing to change. +

+ )} + + {/* Conflict notice + resolution */} + {op.status === 'conflict' && ( +
+
+ Already on this device + {' — '} + tagged: {op.existingVlan?.tagged_ports?.join(', ') || '—'} + {' · '} + untagged: {op.existingVlan?.untagged_ports?.join(', ') || '—'} + {op.existingVlan?.bridge && ` · ${op.existingVlan.bridge}`} +
+
+ Resolution: + + +
+
+ )} + + {/* Bridge picker */} +
+ Bridge + {targetBridgeNames.length > 0 ? ( + + ) : ( + + setBridgeOverrides((prev) => ({ + ...prev, + [op.sourceVlan.vlan_id]: e.target.value, + })) + } + /> + )} + {op.bridgeExistsOnTarget ? ( + + ) : ( + + not found on target + + )} +
+ + {/* Port assignment */} +
+
+ Interfaces + {assignablePorts.length > 0 && ( + + )} +
+ + {assignablePorts.length === 0 ? ( +

+ No interfaces detected — sync the device first. +

+ ) : ( +
+ {assignablePorts.map((portName) => ( + cyclePort(op.sourceVlan.vlan_id, portName)} + /> + ))} +
+ )} + + {/* Live summary of current assignment */} + {(effTagged.length > 0 || effUntagged.length > 0) && ( +
+ {effTagged.length > 0 && ( + + Tagged:{' '} + {effTagged.join(', ')} + + )} + {effUntagged.length > 0 && ( + + Untagged:{' '} + {effUntagged.join(', ')} + + )} +
+ )} + {effTagged.length === 0 && effUntagged.length === 0 && assignablePorts.length > 0 && ( +

+ No ports assigned — VLAN will be created with no port memberships. +

+ )} +
+
+ ); + })} +
+ ); + + // ── Step 3 ───────────────────────────────────────────────────────────────── + + const renderStep3 = () => { + if (applyResults) { + const succeeded = applyResults.filter((r) => r.success); + const failed = applyResults.filter((r) => !r.success); + return ( +
+ {succeeded.length > 0 && ( +
+

+ {succeeded.length} VLAN{succeeded.length !== 1 ? 's' : ''} applied successfully +

+ {succeeded.map((r) => ( +
+ + VLAN {r.vlan_id} — {r.action === 'add' ? 'added' : 'updated'} +
+ ))} +
+ )} + {failed.length > 0 && ( +
+

+ {failed.length} VLAN{failed.length !== 1 ? 's' : ''} failed +

+ {failed.map((r) => ( +
+ VLAN {r.vlan_id} + {r.error && {r.error}} +
+ ))} +
+ )} +
+ ); + } + + const skipped = analyzedOps.filter( + (op) => + op.status === 'identical' || + (op.status === 'conflict' && op.conflictChoice === 'skip') + ); + + return ( +
+

+ {toApply.length > 0 ? ( + <> + {toApply.length} VLAN{toApply.length !== 1 ? 's' : ''} will be applied to{' '} + {deviceName}. + {skipped.length > 0 && ( + <> {skipped.length} will be skipped. + )} + + ) : ( + <>No VLANs will be applied — all selected VLANs are either identical or set to skip. + )} +

+ +
+ {analyzedOps.map((op) => { + const willApply = + op.status === 'add' || + (op.status === 'conflict' && op.conflictChoice === 'overwrite'); + return ( +
+
+
+
+ + VLAN {op.sourceVlan.vlan_id} + + {op.sourceVlan.name && ( + + {op.sourceVlan.name} + + )} + + {op.targetBridge} +
+ + {willApply && ( +
+ {op.effectiveTagged.map((p) => ( + + {p} T + + ))} + {op.effectiveUntagged.map((p) => ( + + {p} U + + ))} + {op.effectiveTagged.length === 0 && op.effectiveUntagged.length === 0 && ( + no port assignments + )} +
+ )} + + {op.status === 'conflict' && op.conflictChoice === 'skip' && ( +

Conflict — you chose to skip

+ )} + {op.status === 'identical' && ( +

Already configured identically — skipped

+ )} +
+
+ ); + })} +
+ + {applyMutation.isError && ( +
+ Failed to apply:{' '} + {(applyMutation.error as { message?: string })?.message ?? 'Unknown error'} +
+ )} +
+ ); + }; + + // ── Footer ───────────────────────────────────────────────────────────────── + + const renderFooter = () => { + if (step === 1) { + return ( + <> + + + + ); + } + if (step === 2) { + return ( + <> + + + + ); + } + // step === 3 + if (applyResults) { + return ( +
+ +
+ ); + } + return ( + <> + + + + ); + }; + + // ── Render ───────────────────────────────────────────────────────────────── + + return ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+ {/* Header */} +
+
+ +

+ Copy VLANs from Another Switch +

+
+ +
+ + + + {/* Scrollable body */} +
+ {step === 1 && renderStep1()} + {step === 2 && renderStep2()} + {step === 3 && renderStep3()} +
+ + {/* Footer */} +
+ {renderFooter()} +
+
+
+ ); +} diff --git a/frontend/src/components/device-detail/VlansTab.tsx b/frontend/src/components/device-detail/VlansTab.tsx index ebd8648..e6cd860 100644 --- a/frontend/src/components/device-detail/VlansTab.tsx +++ b/frontend/src/components/device-detail/VlansTab.tsx @@ -1,17 +1,23 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Plus, Trash2, AlertCircle, RefreshCw, Pencil, Check, X, Network } from 'lucide-react'; +import { Plus, Trash2, AlertCircle, RefreshCw, Pencil, Check, X, Network, Copy } from 'lucide-react'; import { devicesApi } from '../../services/api'; import type { Vlan, SwitchPort } from '../../types'; import clsx from 'clsx'; import { useCanWrite } from '../../hooks/useCanWrite'; +import CopyVlanModal from './CopyVlanModal'; interface PortForm { tagged_ports: string; untagged_ports: string; } -export default function VlansTab({ deviceId, onGoToPorts }: { deviceId: number; onGoToPorts?: (bridgeName: string) => void }) { +export default function VlansTab({ deviceId, deviceName, deviceType, onGoToPorts }: { + deviceId: number; + deviceName?: string; + deviceType?: string; + onGoToPorts?: (bridgeName: string) => void; +}) { const queryClient = useQueryClient(); const canWrite = useCanWrite(); @@ -32,6 +38,9 @@ export default function VlansTab({ deviceId, onGoToPorts }: { deviceId: number; (p) => p.type === 'bridge' && p.config_json?.['vlan-filtering'] !== 'yes' && p.config_json?.['vlan-filtering'] !== 'true' ); + // ── Copy VLAN modal ── + const [showCopyModal, setShowCopyModal] = useState(false); + // ── Add form ── const [showAdd, setShowAdd] = useState(false); const [addForm, setAddForm] = useState({ vlan_id: '', bridge: '', tagged_ports: '', untagged_ports: '' }); @@ -158,6 +167,16 @@ export default function VlansTab({ deviceId, onGoToPorts }: { deviceId: number; Refresh + {canWrite && deviceType === 'switch' && ( + + )} {canWrite && (
)} + {showCopyModal && ( + { + setShowCopyModal(false); + queryClient.invalidateQueries({ queryKey: ['vlans', deviceId] }); + }} + /> + )} + {/* VLANs table */}
{(vlans as Vlan[]).length === 0 ? ( diff --git a/frontend/src/pages/DeviceDetailPage.tsx b/frontend/src/pages/DeviceDetailPage.tsx index fd57762..8c112b9 100644 --- a/frontend/src/pages/DeviceDetailPage.tsx +++ b/frontend/src/pages/DeviceDetailPage.tsx @@ -306,6 +306,8 @@ export default function DeviceDetailPage() { {activeTab === 'vlans' && ( { setAutoOpenBridge(bridgeName); setActiveTab('ports'); }} /> )} diff --git a/frontend/src/pages/DevicesPage.tsx b/frontend/src/pages/DevicesPage.tsx index ed2b3e9..b3488d8 100644 --- a/frontend/src/pages/DevicesPage.tsx +++ b/frontend/src/pages/DevicesPage.tsx @@ -165,7 +165,7 @@ export default function DevicesPage() { () => Object.fromEntries( devices.map((d, i) => { const pts = cpuHistoryResults[i]?.data ?? []; - return [d.id, pts.map((p: { cpu_load?: number }) => p.cpu_load ?? 0).filter((v: number) => v > 0)]; + return [d.id, pts.filter((p: { cpu_load?: number }) => p.cpu_load != null).map((p: { cpu_load?: number }) => p.cpu_load as number)]; }) ) as Record, [devices, cpuHistoryResults], diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index d4c807a..a670f15 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -197,6 +197,8 @@ export const devicesApi = { updateVlan: (id: number, vlanDbId: number, data: { tagged_ports: string[]; untagged_ports: string[] }) => api.put(`/devices/${id}/vlans/${vlanDbId}`, data), deleteVlan: (id: number, vlanDbId: number) => api.delete(`/devices/${id}/vlans/${vlanDbId}`), + copyVlans: (id: number, operations: Array<{ action: 'add' | 'update'; vlan_id: number; bridge: string; tagged_ports: string[]; untagged_ports: string[] }>) => + api.post<{ results: Array<{ vlan_id: number; action: string; success: boolean; error?: string }>; vlans: Vlan[] }>(`/devices/${id}/vlans/copy`, { operations }), reboot: (id: number) => api.post<{ message: string }>(`/devices/${id}/reboot`), getPortMonitor: (id: number, name: string) => api.get(`/devices/${id}/ports/${encodeURIComponent(name)}/monitor`), diff --git a/frontend/src/version.ts b/frontend/src/version.ts index c6e95af..584faeb 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = 'v0.10.9 Beta'; +export const APP_VERSION = 'v0.11.0 Beta';