Initial commit baby!

This commit is contained in:
Rich Teslow
2026-04-13 18:15:41 -07:00
parent 10e6227931
commit 6b0fc9fdf1
99 changed files with 34064 additions and 3 deletions
@@ -0,0 +1,552 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
RefreshCw, Plus, Trash2, CheckCircle, AlertCircle, Download, Server,
} from 'lucide-react';
import { devicesApi } from '../../services/api';
import type { IpAddress, Interface } from '../../types';
import clsx from 'clsx';
import { useCanWrite } from '../../hooks/useCanWrite';
interface Props {
deviceId: number;
}
export default function SystemConfigTab({ deviceId }: 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 [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('');
const addIpMutation = useMutation({
mutationFn: () => devicesApi.addIpAddress(deviceId, newIp),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ip-addresses', deviceId] });
setNewIp({ address: '', interface: '' });
setIpError('');
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
setIpError(msg || 'Failed to add IP address');
},
});
const removeIpMutation = useMutation({
mutationFn: (id: string) => devicesApi.removeIpAddress(deviceId, id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['ip-addresses', deviceId] }),
});
// ─── Firmware check / install ─────────────────────────────────────────────
const [updateInfo, setUpdateInfo] = useState<Record<string, string> | null>(null);
const [updateChecking, setUpdateChecking] = useState(false);
const [updateError, setUpdateError] = useState('');
const [installSuccess, setInstallSuccess] = useState('');
const checkUpdateMutation = useMutation({
mutationFn: () => devicesApi.checkUpdate(deviceId),
onMutate: () => { setUpdateChecking(true); setUpdateError(''); setUpdateInfo(null); setInstallSuccess(''); },
onSuccess: (res) => { setUpdateInfo(res.data); setUpdateChecking(false); },
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),
onSuccess: () => {
setUpdateInfo(null);
setInstallSuccess('Update installation initiated. The device will reboot shortly.');
},
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)
);
const isLoading = sysLoading || clockLoading;
return (
<div className="space-y-6">
{/* ── System Settings (identity, NTP, DNS, clock) ── */}
<div className="card p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Server className="w-4 h-4 text-blue-500" />
System Settings
</h3>
{canWrite && !editing && (
<button
onClick={startEdit}
disabled={isLoading || !sysConfig}
className="btn-secondary text-xs py-1.5"
>
Edit
</button>
)}
</div>
{isLoading ? (
<div className="flex items-center gap-2 text-sm text-gray-400">
<RefreshCw className="w-4 h-4 animate-spin" /> Loading...
</div>
) : !sysConfig ? (
<div className="text-sm text-gray-400">Could not load system configuration</div>
) : editing ? (
<div className="space-y-5">
{/* Identity */}
<div>
<label className="label">Device Identity (Hostname)</label>
<input
className="input"
value={sysForm.identity}
onChange={(e) => setSysForm((f) => ({ ...f, identity: e.target.value }))}
placeholder="router"
/>
</div>
{/* Date / Time / Timezone */}
<div>
<h4 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider mb-2">
Date &amp; Time
</h4>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="label">Date</label>
<input
type="date"
className="input"
value={clockForm.date}
onChange={(e) => setClockForm((f) => ({ ...f, date: e.target.value }))}
/>
</div>
<div>
<label className="label">Time</label>
<input
type="time"
className="input"
value={clockForm.time}
onChange={(e) => setClockForm((f) => ({ ...f, time: e.target.value }))}
/>
</div>
<div>
<label className="label">Timezone</label>
<input
className="input"
value={clockForm.timezone}
onChange={(e) => setClockForm((f) => ({ ...f, timezone: e.target.value }))}
placeholder="America/New_York"
/>
<p className="text-xs text-gray-400 mt-1">IANA name (e.g. UTC, Europe/London)</p>
</div>
</div>
</div>
{/* NTP */}
<div>
<h4 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider mb-2">
NTP Client
</h4>
<div className="space-y-2">
<div className="flex items-center gap-2">
<input
type="checkbox"
id="ntp_enabled"
checked={sysForm.ntp_enabled}
onChange={(e) => setSysForm((f) => ({ ...f, ntp_enabled: e.target.checked }))}
className="w-4 h-4"
/>
<label htmlFor="ntp_enabled" className="text-sm text-gray-700 dark:text-slate-300">
NTP enabled
</label>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="label">Primary NTP server</label>
<input
className="input"
value={sysForm.ntp_primary}
onChange={(e) => setSysForm((f) => ({ ...f, ntp_primary: e.target.value }))}
placeholder="0.pool.ntp.org"
/>
</div>
<div>
<label className="label">Secondary NTP server</label>
<input
className="input"
value={sysForm.ntp_secondary}
onChange={(e) => setSysForm((f) => ({ ...f, ntp_secondary: e.target.value }))}
placeholder="1.pool.ntp.org"
/>
</div>
</div>
</div>
</div>
{/* DNS */}
<div>
<h4 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider mb-2">
DNS
</h4>
<div className="space-y-2">
<div>
<label className="label">DNS Servers (comma-separated)</label>
<input
className="input"
value={sysForm.dns_servers}
onChange={(e) => setSysForm((f) => ({ ...f, dns_servers: e.target.value }))}
placeholder="8.8.8.8,8.8.4.4"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="dns_remote"
checked={sysForm.dns_allow_remote}
onChange={(e) => setSysForm((f) => ({ ...f, dns_allow_remote: e.target.checked }))}
className="w-4 h-4"
/>
<label htmlFor="dns_remote" className="text-sm text-gray-700 dark:text-slate-300">
Allow remote DNS requests (act as DNS server)
</label>
</div>
</div>
</div>
{saveError && (
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-600 dark:text-red-400">{saveError}</p>
</div>
)}
<div className="flex items-center gap-3">
<button
onClick={handleSave}
disabled={saving}
className="btn-primary flex items-center gap-2"
>
{saving && <RefreshCw className="w-4 h-4 animate-spin" />}
Save Changes
</button>
<button onClick={() => setEditing(false)} className="btn-secondary">
Cancel
</button>
</div>
</div>
) : (
<div className="grid grid-cols-2 gap-y-3 gap-x-8 text-sm">
{[
['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]) => (
<div key={k} className="flex justify-between border-b border-gray-100 dark:border-slate-700 pb-2">
<span className="text-gray-500 dark:text-slate-400">{k}</span>
<span className="font-medium text-gray-900 dark:text-white text-right">{v}</span>
</div>
))}
</div>
)}
{saveSuccess && (
<div className="flex items-center gap-2 mt-3 text-sm text-green-600">
<CheckCircle className="w-4 h-4" /> {saveSuccess}
</div>
)}
</div>
{/* ── IP Addresses ── */}
<div className="card overflow-hidden">
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 dark:border-slate-700">
<h3 className="font-semibold text-gray-900 dark:text-white">IP Addresses</h3>
<button
onClick={() => refetchIp()}
className="p-1.5 text-gray-400 hover:text-gray-600 transition-colors"
title="Refresh"
>
<RefreshCw className={clsx('w-3.5 h-3.5', ipLoading && 'animate-spin')} />
</button>
</div>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 dark:border-slate-700">
<th className="table-header px-4 py-2 text-left">Address / Prefix</th>
<th className="table-header px-4 py-2 text-left">Network</th>
<th className="table-header px-4 py-2 text-left">Interface</th>
<th className="table-header px-4 py-2 text-left">Status</th>
<th className="table-header px-4 py-2 w-12" />
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
{ipLoading ? (
<tr><td colSpan={5} className="px-4 py-6 text-center text-gray-400">Loading...</td></tr>
) : (ipAddresses as IpAddress[]).length === 0 ? (
<tr><td colSpan={5} className="px-4 py-6 text-center text-gray-400">No IP addresses</td></tr>
) : (
(ipAddresses as IpAddress[]).map((ip) => (
<tr key={ip['.id']} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
<td className="px-4 py-2 font-mono text-gray-900 dark:text-white">{ip.address}</td>
<td className="px-4 py-2 font-mono text-xs text-gray-500 dark:text-slate-400">{ip.network || '—'}</td>
<td className="px-4 py-2 text-gray-600 dark:text-slate-300">{ip.interface}</td>
<td className="px-4 py-2">
<span className={clsx(
'text-xs font-medium',
ip.disabled === 'true' ? 'text-gray-400' : 'text-green-600 dark:text-green-400'
)}>
{ip.dynamic === 'true' ? 'dynamic' : ip.disabled === 'true' ? 'disabled' : 'active'}
</span>
</td>
<td className="px-4 py-2">
{canWrite && ip.dynamic !== 'true' && (
<button
onClick={() => {
if (confirm(`Remove ${ip.address} from ${ip.interface}?`)) {
removeIpMutation.mutate(ip['.id']);
}
}}
className="p-1 rounded text-gray-400 hover:text-red-500 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</td>
</tr>
))
)}
</tbody>
</table>
{canWrite && <div className="p-4 border-t border-gray-100 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/50">
<div className="flex items-end gap-3">
<div className="flex-1">
<label className="label">Address / Prefix (e.g. 192.168.1.1/24)</label>
<input
className="input font-mono"
value={newIp.address}
onChange={(e) => setNewIp((f) => ({ ...f, address: e.target.value }))}
placeholder="192.168.1.1/24"
/>
</div>
<div className="w-44">
<label className="label">Interface</label>
<select
className="input"
value={newIp.interface}
onChange={(e) => setNewIp((f) => ({ ...f, interface: e.target.value }))}
>
<option value="">Select...</option>
{(interfaces as Interface[]).map((i) => (
<option key={i.name} value={i.name}>{i.name}</option>
))}
</select>
</div>
<button
onClick={() => addIpMutation.mutate()}
disabled={!newIp.address || !newIp.interface || addIpMutation.isPending}
className="btn-primary flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Add
</button>
</div>
{ipError && (
<div className="flex items-center gap-2 mt-2 text-sm text-red-500">
<AlertCircle className="w-4 h-4" /> {ipError}
</div>
)}
</div>}
</div>
{/* ── Firmware Updates ── */}
<div className="card p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Download className="w-4 h-4 text-blue-500" />
Firmware Updates
</h3>
<button
onClick={() => checkUpdateMutation.mutate()}
disabled={updateChecking}
className="btn-secondary flex items-center gap-2 text-sm"
>
<RefreshCw className={clsx('w-4 h-4', updateChecking && 'animate-spin')} />
{updateChecking ? 'Checking...' : 'Check for Updates'}
</button>
</div>
{updateError && (
<div className="flex items-center gap-2 text-sm text-red-500 mb-3">
<AlertCircle className="w-4 h-4" /> {updateError}
</div>
)}
{installSuccess && (
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400 mb-3">
<CheckCircle className="w-4 h-4" /> {installSuccess}
</div>
)}
{updateInfo && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-4 text-sm">
{[
['Status', updateInfo['status'] || '—'],
['Installed Version', installedVersion || '—'],
['Latest Version', latestVersion || '—'],
['Channel', updateInfo['channel'] || '—'],
].map(([k, v]) => (
<div key={k} className="flex justify-between border-b border-gray-100 dark:border-slate-700 pb-2">
<span className="text-gray-500 dark:text-slate-400">{k}</span>
<span className={clsx(
'font-medium text-right',
k === 'Status' && hasUpdate ? 'text-orange-500' : 'text-gray-900 dark:text-white'
)}>
{v}
</span>
</div>
))}
</div>
{hasUpdate ? (
<div className="flex items-center gap-3 p-3 bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg">
<AlertCircle className="w-4 h-4 text-orange-500 flex-shrink-0" />
<div className="flex-1 text-sm">
<p className="font-medium text-orange-700 dark:text-orange-400">
Update available: {latestVersion}
</p>
<p className="text-orange-600 dark:text-orange-500 text-xs mt-0.5">
The device will reboot after installing.
</p>
</div>
{canWrite && (
<button
onClick={() => {
if (confirm(`Install RouterOS ${latestVersion} and reboot the device?`)) {
installUpdateMutation.mutate();
}
}}
disabled={installUpdateMutation.isPending}
className="btn-primary flex items-center gap-2 text-sm flex-shrink-0"
>
{installUpdateMutation.isPending
? <RefreshCw className="w-4 h-4 animate-spin" />
: <Download className="w-4 h-4" />}
Install &amp; Reboot
</button>
)}
</div>
) : (
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
<CheckCircle className="w-4 h-4" />
RouterOS is up to date
</div>
)}
</div>
)}
{!updateInfo && !updateChecking && !installSuccess && (
<p className="text-sm text-gray-400 dark:text-slate-500">
Click "Check for Updates" to query the MikroTik update server.
</p>
)}
</div>
</div>
);
}