import { useState, useEffect } from 'react'; import SshKeyCard from './SshKeyCard'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { RefreshCw, Plus, Trash2, CheckCircle, AlertCircle, Download, Server, Cpu, FileText, } from 'lucide-react'; import { devicesApi } from '../../services/api'; import { LockoutVerdictDialog, lockoutVerdictOf, type LockoutVerdict } from '../ChangeGuardDialog'; import type { Device, IpAddress, Interface } from '../../types'; import clsx from 'clsx'; import { useCanWrite } from '../../hooks/useCanWrite'; import ChangelogModal from '../ChangelogModal'; interface Props { deviceId: number; device: Device; } export default function SystemConfigTab({ deviceId, device }: Props) { const queryClient = useQueryClient(); const canWrite = useCanWrite(); // ─── System config (identity, NTP, DNS) ────────────────────────────────── const { data: sysConfig, isLoading: sysLoading } = useQuery({ queryKey: ['system-config', deviceId], queryFn: () => devicesApi.getSystemConfig(deviceId).then((r) => r.data), }); // ─── Clock ──────────────────────────────────────────────────────────────── const { data: clockData, isLoading: clockLoading } = useQuery({ queryKey: ['device-clock', deviceId], queryFn: () => devicesApi.getClock(deviceId).then((r) => r.data), }); // ─── Combined edit state ────────────────────────────────────────────────── const [editing, setEditing] = useState(false); const [changelogVersion, setChangelogVersion] = useState(null); const [sysForm, setSysForm] = useState({ identity: '', ntp_enabled: true, ntp_primary: '', ntp_secondary: '', dns_servers: '', dns_allow_remote: false, }); const [clockForm, setClockForm] = useState({ date: '', time: '', timezone: '' }); const [saveError, setSaveError] = useState(''); const [saveSuccess, setSaveSuccess] = useState(''); const [saving, setSaving] = useState(false); const startEdit = () => { if (!sysConfig) return; setSysForm({ identity: sysConfig.identity, ntp_enabled: sysConfig.ntp.enabled !== 'no', ntp_primary: sysConfig.ntp['primary-ntp'] || '', ntp_secondary: sysConfig.ntp['secondary-ntp'] || '', dns_servers: sysConfig.dns['servers'] || '', dns_allow_remote: sysConfig.dns['allow-remote-requests'] === 'yes', }); setClockForm({ date: clockData?.date || '', time: clockData?.time || '', timezone: clockData?.timezone || '', }); setSaveError(''); setEditing(true); }; const handleSave = async () => { setSaving(true); setSaveError(''); try { await devicesApi.updateSystemConfig(deviceId, sysForm); await devicesApi.setClock(deviceId, clockForm); queryClient.invalidateQueries({ queryKey: ['system-config', deviceId] }); queryClient.invalidateQueries({ queryKey: ['device-clock', deviceId] }); queryClient.invalidateQueries({ queryKey: ['device', deviceId] }); setEditing(false); setSaveSuccess('System configuration updated'); setTimeout(() => setSaveSuccess(''), 3000); } catch (err: unknown) { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error; setSaveError(msg || 'Failed to save configuration'); } finally { setSaving(false); } }; // ─── IP Addresses ──────────────────────────────────────────────────────── const { data: ipAddresses = [], isLoading: ipLoading, refetch: refetchIp } = useQuery({ queryKey: ['ip-addresses', deviceId], queryFn: () => devicesApi.getIpAddresses(deviceId).then((r) => r.data as IpAddress[]), }); const { data: interfaces = [] } = useQuery({ queryKey: ['interfaces', deviceId], queryFn: () => devicesApi.getInterfaces(deviceId).then((r) => r.data as Interface[]), }); const [newIp, setNewIp] = useState({ address: '', interface: '' }); const [ipError, setIpError] = useState(''); // Predicted lockout (409 + verdict) plus the retry that confirms it. const [lockout, setLockout] = useState<{ verdict: LockoutVerdict; retry: () => void } | null>(null); const addIpMutation = useMutation({ mutationFn: (confirm: boolean) => devicesApi.addIpAddress(deviceId, confirm ? { ...newIp, confirm_lockout: true } : newIp), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ip-addresses', deviceId] }); setNewIp({ address: '', interface: '' }); setIpError(''); setLockout(null); }, onError: (err: unknown) => { const verdict = lockoutVerdictOf(err); if (verdict) { setLockout({ verdict, retry: () => addIpMutation.mutate(true) }); return; } const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error; setIpError(msg || 'Failed to add IP address'); }, }); const removeIpMutation = useMutation({ mutationFn: ({ id, confirm = false }: { id: string; confirm?: boolean }) => devicesApi.removeIpAddress(deviceId, id, confirm), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ip-addresses', deviceId] }); setLockout(null); }, onError: (err: unknown, vars) => { const verdict = lockoutVerdictOf(err); if (verdict) { setLockout({ verdict, retry: () => removeIpMutation.mutate({ id: vars.id, confirm: true }) }); return; } const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error; setIpError(msg || 'Failed to remove IP address'); }, }); // ─── RouterOS update check / install ───────────────────────────────────── const [updateInfo, setUpdateInfo] = useState | null>(null); const [updateChecking, setUpdateChecking] = useState(false); const [updateError, setUpdateError] = useState(''); const [installSuccess, setInstallSuccess] = useState(''); // Pre-populate from stored DB state so the section is useful without a manual check useEffect(() => { if (device.ros_version || device.latest_ros_version || device.firmware_update_available) { setUpdateInfo({ 'installed-version': device.ros_version || '', 'latest-version': device.latest_ros_version || '', 'status': device.firmware_update_available ? 'New version is available' : 'System is up to date', 'channel': '', }); } }, [device.id]); // Firmware state is rendered on many screens (Devices list, Firmware page, // dashboard "Things to handle", Security Center). The backend persists the // new state immediately, so refresh those caches whenever it changes here — // otherwise other pages keep stale "update available" data until their own // 30s refetch fires. const invalidateFirmwareState = () => { queryClient.invalidateQueries({ queryKey: ['devices'] }); queryClient.invalidateQueries({ queryKey: ['device', deviceId] }); queryClient.invalidateQueries({ queryKey: ['fw-overview'] }); queryClient.invalidateQueries({ queryKey: ['ops-insights'] }); queryClient.invalidateQueries({ queryKey: ['security-fleet'] }); }; const checkUpdateMutation = useMutation({ mutationFn: () => devicesApi.checkUpdate(deviceId), onMutate: () => { setUpdateChecking(true); setUpdateError(''); setInstallSuccess(''); }, onSuccess: (res) => { setUpdateInfo(res.data); setUpdateChecking(false); invalidateFirmwareState(); }, onError: (err: unknown) => { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error; setUpdateError(msg || 'Failed to check for updates'); setUpdateChecking(false); }, }); const installUpdateMutation = useMutation({ mutationFn: () => devicesApi.installUpdate(deviceId), // The server now distinguishes "installing" from "there was nothing to // install", because this button used to report the first while doing the // second. Report back whichever actually happened. onSuccess: (res) => { setInstallSuccess(res.data.message); if (res.data.started) { setUpdateInfo(null); } else { setUpdateInfo({ 'installed-version': res.data.installed_version || '', 'latest-version': res.data.installed_version || '', 'status': 'System is up to date', 'channel': '', }); } invalidateFirmwareState(); }, onError: (err: unknown) => { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error; setUpdateError(msg || 'Failed to install update'); }, }); // RouterOS 7 uses 'installed-version'; RouterOS 6 uses 'version' const installedVersion = updateInfo?.['installed-version'] || updateInfo?.['version'] || ''; const latestVersion = updateInfo?.['latest-version'] || ''; const statusSaysUpdate = updateInfo?.['status']?.toLowerCase().includes('new version') || updateInfo?.['status']?.toLowerCase().includes('available'); const hasUpdate = updateInfo && latestVersion && ( (installedVersion && latestVersion !== installedVersion) || (!installedVersion && statusSaysUpdate) ); // ─── RouterBOOT upgrade check / install ────────────────────────────────── const [rbInfo, setRbInfo] = useState<{ currentFirmware: string; upgradeFirmware: string; upgradeAvailable: boolean } | null>(null); const [rbChecking, setRbChecking] = useState(false); const [rbError, setRbError] = useState(''); const [rbInstallSuccess, setRbInstallSuccess] = useState(''); // Pre-populate from stored DB state useEffect(() => { if (device.firmware_version) { setRbInfo({ currentFirmware: device.firmware_version, upgradeFirmware: device.upgrade_firmware_version || '', upgradeAvailable: device.routerboard_upgrade_available || false, }); } }, [device.id]); const checkRouterboardMutation = useMutation({ mutationFn: () => devicesApi.checkRouterboard(deviceId), onMutate: () => { setRbChecking(true); setRbError(''); setRbInstallSuccess(''); }, onSuccess: (res) => { setRbInfo(res.data); setRbChecking(false); invalidateFirmwareState(); }, onError: (err: unknown) => { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error; setRbError(msg || 'Failed to check RouterBOOT'); setRbChecking(false); }, }); const installRouterboardMutation = useMutation({ mutationFn: () => devicesApi.installRouterboard(deviceId), onSuccess: (res) => { setRbInstallSuccess(res.data.message); if (res.data.started) setRbInfo(null); invalidateFirmwareState(); }, onError: (err: unknown) => { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error; setRbError(msg || 'Failed to install RouterBOOT upgrade'); }, }); const isLoading = sysLoading || clockLoading; return (
{changelogVersion && ( setChangelogVersion(null)} /> )} {/* ── System Settings (identity, NTP, DNS, clock) ── */}

System Settings

{canWrite && !editing && ( )}
{isLoading ? (
Loading...
) : !sysConfig ? (
Could not load system configuration
) : editing ? (
{/* Identity */}
setSysForm((f) => ({ ...f, identity: e.target.value }))} placeholder="router" />
{/* Date / Time / Timezone */}

Date & Time

setClockForm((f) => ({ ...f, date: e.target.value }))} />
setClockForm((f) => ({ ...f, time: e.target.value }))} />
setClockForm((f) => ({ ...f, timezone: e.target.value }))} placeholder="America/New_York" />

IANA name (e.g. UTC, Europe/London)

{/* NTP */}

NTP Client

setSysForm((f) => ({ ...f, ntp_enabled: e.target.checked }))} className="w-4 h-4" />
setSysForm((f) => ({ ...f, ntp_primary: e.target.value }))} placeholder="0.pool.ntp.org" />
setSysForm((f) => ({ ...f, ntp_secondary: e.target.value }))} placeholder="1.pool.ntp.org" />
{/* DNS */}

DNS

setSysForm((f) => ({ ...f, dns_servers: e.target.value }))} placeholder="8.8.8.8,8.8.4.4" />
setSysForm((f) => ({ ...f, dns_allow_remote: e.target.checked }))} className="w-4 h-4" />
{saveError && (

{saveError}

)}
) : (
{[ ['Identity', sysConfig.identity || '—'], ['Date', clockData?.date || '—'], ['Time', clockData?.time || '—'], ['Timezone', clockData?.timezone || '—'], ['NTP Enabled', sysConfig.ntp.enabled !== 'no' ? 'Yes' : 'No'], ['Primary NTP', sysConfig.ntp['primary-ntp'] || '—'], ['Secondary NTP', sysConfig.ntp['secondary-ntp'] || '—'], ['DNS Servers', sysConfig.dns.servers || '—'], ['Allow Remote DNS', sysConfig.dns['allow-remote-requests'] === 'yes' ? 'Yes' : 'No'], ].map(([k, v]) => (
{k} {v}
))}
)} {saveSuccess && (
{saveSuccess}
)}
{/* ── IP Addresses ── */}

IP Addresses

{ipLoading ? ( ) : (ipAddresses as IpAddress[]).length === 0 ? ( ) : ( (ipAddresses as IpAddress[]).map((ip) => ( )) )}
Address / Prefix Network Interface Status
Loading...
No IP addresses
{ip.address} {ip.network || '—'} {ip.interface} {ip.dynamic === 'true' ? 'dynamic' : ip.disabled === 'true' ? 'disabled' : 'active'} {canWrite && ip.dynamic !== 'true' && ( )}
{canWrite &&
setNewIp((f) => ({ ...f, address: e.target.value }))} placeholder="192.168.1.1/24" />
{ipError && (
{ipError}
)}
}
{/* ── Software & Firmware Updates ── */}

Software & Firmware Updates

{/* Order-of-operations notice when both are pending */} {hasUpdate && rbInfo?.upgradeAvailable && (

Update order matters: Install the RouterOS update first and let the device reboot, then apply the RouterBOOT upgrade. The new bootloader is bundled inside the RouterOS package.

)}
{/* ── RouterOS ── */}

RouterOS

{updateError && (
{updateError}
)} {installSuccess && (
{installSuccess}
)} {updateInfo ? (
{[ ['Status', updateInfo['status'] || '—'], ['Installed Version', installedVersion || '—'], ['Latest Version', latestVersion || '—'], ['Channel', updateInfo['channel'] || '—'], ].map(([k, v]) => (
{k} {v}
))}
{hasUpdate ? (

Update available: {latestVersion}

The device will reboot after installing.

{canWrite && ( )}
) : (
RouterOS is up to date
)}
) : !updateChecking && !installSuccess && (

Click "Check for Updates" to query the MikroTik update server.

)}
{/* ── RouterBOOT ── */}

RouterBOOT Firmware

{rbError && (
{rbError}
)} {rbInstallSuccess && (
{rbInstallSuccess}
)} {rbInfo ? ( rbInfo.currentFirmware ? (
{[ ['Current Bootloader', rbInfo.currentFirmware || '—'], ['Available Upgrade', rbInfo.upgradeFirmware || '—'], ].map(([k, v]) => (
{k} {v}
))}
{rbInfo.upgradeAvailable ? (

Upgrade available: {rbInfo.upgradeFirmware}

{hasUpdate ? 'Install RouterOS first, then apply this upgrade.' : 'The device will reboot after upgrading.'}

{canWrite && ( )}
) : (
RouterBOOT is up to date
)}
) : (

RouterBOOT is not available on this device (CHR or virtual instance).

) ) : !rbChecking && !rbInstallSuccess && (

Click "Check RouterBOOT" to read the bootloader firmware status from the device.

)}
{lockout && ( setLockout(null)} /> )}
); }