feature(devices,topology,tz): devices bulk-add, duplicate safety, and firmware/timezone fixes
Squash-merge of PR #28 from @denis-ev. All three review blockers addressed: encryption key management documented, string truncation replaced with skip+warn validation, and undefined return on duplicate merge fixed with proper 500. Local test stack verified against cloned prod DB — migrations, new features, and backend all clean.
This commit is contained in:
@@ -1,6 +1,12 @@
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { X, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { devicesApi } from '../../services/api';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { X, CheckCircle, AlertCircle, Loader2, KeyRound } from 'lucide-react';
|
||||
import {
|
||||
devicesApi,
|
||||
credentialPresetsApi,
|
||||
type DuplicateSerialError,
|
||||
} from '../../services/api';
|
||||
import ConfirmDuplicateModal from './ConfirmDuplicateModal';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
@@ -21,36 +27,120 @@ export default function AddDeviceModal({ onClose, onSuccess, prefill }: Props) {
|
||||
device_type: 'router',
|
||||
notes: '',
|
||||
});
|
||||
const [presetId, setPresetId] = useState<number | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [duplicateSerial, setDuplicateSerial] = useState<DuplicateSerialError | null>(null);
|
||||
|
||||
const { data: presets = [] } = useQuery({
|
||||
queryKey: ['credential-presets'],
|
||||
queryFn: () => credentialPresetsApi.list().then((r) => r.data),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const selectedPreset = presetId != null ? presets.find((p) => p.id === presetId) ?? null : null;
|
||||
|
||||
const set = (k: string, v: string) => setForm((f) => ({ ...f, [k]: v }));
|
||||
|
||||
const createPayload = (
|
||||
opts: { combineWithDeviceId?: number; forceReplace?: boolean } = {}
|
||||
) => {
|
||||
const extra = {
|
||||
combine_with_device_id: opts.combineWithDeviceId,
|
||||
force_replace_existing_by_serial: opts.forceReplace,
|
||||
};
|
||||
if (selectedPreset) {
|
||||
return {
|
||||
name: form.name,
|
||||
ip_address: form.ip_address,
|
||||
device_type: form.device_type as import('../../types').DeviceType,
|
||||
notes: form.notes,
|
||||
credential_preset_id: selectedPreset.id,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: form.name,
|
||||
ip_address: form.ip_address,
|
||||
api_port: parseInt(form.api_port, 10),
|
||||
api_username: form.api_username,
|
||||
api_password: form.api_password,
|
||||
ssh_port: parseInt(form.ssh_port, 10),
|
||||
ssh_username: form.ssh_username || undefined,
|
||||
ssh_password: form.ssh_password || undefined,
|
||||
device_type: form.device_type as import('../../types').DeviceType,
|
||||
notes: form.notes,
|
||||
...extra,
|
||||
};
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form.name || !form.ip_address || !form.api_username || !form.api_password) {
|
||||
setError('Name, IP address, username, and password are required');
|
||||
if (!form.name || !form.ip_address) {
|
||||
setError('Name and IP address are required');
|
||||
return;
|
||||
}
|
||||
if (!selectedPreset && (!form.api_username || !form.api_password)) {
|
||||
setError('Either pick a credential preset or enter username and password');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setDuplicateSerial(null);
|
||||
try {
|
||||
await devicesApi.create({
|
||||
...form,
|
||||
api_port: parseInt(form.api_port),
|
||||
ssh_port: parseInt(form.ssh_port),
|
||||
device_type: form.device_type as import('../../types').DeviceType,
|
||||
});
|
||||
await devicesApi.create(createPayload());
|
||||
onSuccess();
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
const status = (err as { response?: { status?: number } })?.response?.status;
|
||||
const data = (err as { response?: { data?: unknown } })?.response?.data;
|
||||
if (
|
||||
status === 409 &&
|
||||
data &&
|
||||
typeof data === 'object' &&
|
||||
(data as { code?: string }).code === 'duplicate_serial'
|
||||
) {
|
||||
setDuplicateSerial(data as DuplicateSerialError);
|
||||
return;
|
||||
}
|
||||
const msg = (data as { error?: string } | undefined)?.error;
|
||||
setError(msg || 'Failed to add device');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCombineDuplicate = async () => {
|
||||
if (!duplicateSerial?.existing_device?.id) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await devicesApi.create(createPayload({ combineWithDeviceId: duplicateSerial.existing_device.id }));
|
||||
onSuccess();
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setError(msg || 'Failed to combine duplicate by serial');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setDuplicateSerial(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReplaceDuplicate = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await devicesApi.create(createPayload({ forceReplace: true }));
|
||||
onSuccess();
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setError(msg || 'Failed to replace duplicate serial details');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setDuplicateSerial(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="card w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
|
||||
@@ -100,82 +190,118 @@ export default function AddDeviceModal({ onClose, onSuccess, prefill }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API credentials */}
|
||||
{/* Credentials: preset or manual */}
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider mb-3">
|
||||
RouterOS API Credentials
|
||||
<h3 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider mb-3 flex items-center gap-1.5">
|
||||
<KeyRound className="w-3.5 h-3.5" />
|
||||
Credentials
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Username *</label>
|
||||
<input
|
||||
className="input"
|
||||
value={form.api_username}
|
||||
onChange={(e) => set('api_username', e.target.value)}
|
||||
placeholder="admin"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">API Port</label>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
value={form.api_port}
|
||||
onChange={(e) => set('api_port', e.target.value)}
|
||||
placeholder="8728"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="label">Password *</label>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={form.api_password}
|
||||
onChange={(e) => set('api_password', e.target.value)}
|
||||
placeholder="RouterOS API password"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SSH credentials (optional) */}
|
||||
<details className="group">
|
||||
<summary className="cursor-pointer text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider select-none">
|
||||
SSH Credentials (optional, for backup/restore)
|
||||
</summary>
|
||||
<div className="mt-3 grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">SSH Username</label>
|
||||
<input
|
||||
{presets.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<label className="label">Use a saved preset</label>
|
||||
<select
|
||||
className="input"
|
||||
value={form.ssh_username}
|
||||
onChange={(e) => set('ssh_username', e.target.value)}
|
||||
placeholder="admin"
|
||||
/>
|
||||
value={presetId ?? ''}
|
||||
onChange={(e) => setPresetId(e.target.value ? parseInt(e.target.value, 10) : null)}
|
||||
>
|
||||
<option value="">— Enter credentials manually —</option>
|
||||
{presets.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} ({p.api_username}
|
||||
{p.ssh_username ? ` · SSH ${p.ssh_username}` : ''}
|
||||
)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedPreset && (
|
||||
<p className="text-xs text-gray-500 dark:text-slate-400 mt-2">
|
||||
Using preset <strong>{selectedPreset.name}</strong>. The server will pull the
|
||||
API username/password
|
||||
{selectedPreset.has_ssh_password && selectedPreset.ssh_username
|
||||
? ' and SSH credentials'
|
||||
: ''}
|
||||
{' '}from this preset.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">SSH Port</label>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
value={form.ssh_port}
|
||||
onChange={(e) => set('ssh_port', e.target.value)}
|
||||
placeholder="22"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="label">SSH Password</label>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={form.ssh_password}
|
||||
onChange={(e) => set('ssh_password', e.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{!selectedPreset && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">RouterOS Username *</label>
|
||||
<input
|
||||
className="input"
|
||||
value={form.api_username}
|
||||
onChange={(e) => set('api_username', e.target.value)}
|
||||
placeholder="admin"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">API Port</label>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
value={form.api_port}
|
||||
onChange={(e) => set('api_port', e.target.value)}
|
||||
placeholder="8728"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="label">RouterOS Password *</label>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={form.api_password}
|
||||
onChange={(e) => set('api_password', e.target.value)}
|
||||
placeholder="RouterOS API password"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SSH credentials (optional) */}
|
||||
<details className="group mt-3">
|
||||
<summary className="cursor-pointer text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider select-none">
|
||||
SSH Credentials (optional, for backup/restore)
|
||||
</summary>
|
||||
<div className="mt-3 grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">SSH Username</label>
|
||||
<input
|
||||
className="input"
|
||||
value={form.ssh_username}
|
||||
onChange={(e) => set('ssh_username', e.target.value)}
|
||||
placeholder="admin"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">SSH Port</label>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
value={form.ssh_port}
|
||||
onChange={(e) => set('ssh_port', e.target.value)}
|
||||
placeholder="22"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="label">SSH Password</label>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={form.ssh_password}
|
||||
onChange={(e) => set('ssh_password', e.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Notes (optional)</label>
|
||||
@@ -219,6 +345,19 @@ export default function AddDeviceModal({ onClose, onSuccess, prefill }: Props) {
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{duplicateSerial && (
|
||||
<ConfirmDuplicateModal
|
||||
duplicate={duplicateSerial}
|
||||
pendingName={form.name}
|
||||
pendingIp={form.ip_address}
|
||||
onCancel={() => {
|
||||
setDuplicateSerial(null);
|
||||
}}
|
||||
onCombine={handleCombineDuplicate}
|
||||
onReplace={handleReplaceDuplicate}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useState } from 'react';
|
||||
import { AlertTriangle, GitMerge, RefreshCcw, X } from 'lucide-react';
|
||||
import type { DuplicateSerialError } from '../../services/api';
|
||||
|
||||
interface Props {
|
||||
duplicate: DuplicateSerialError;
|
||||
pendingName: string;
|
||||
pendingIp: string;
|
||||
onCancel: () => void;
|
||||
onCombine: () => void;
|
||||
onReplace: () => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export default function ConfirmDuplicateModal({
|
||||
duplicate,
|
||||
pendingName,
|
||||
pendingIp,
|
||||
onCancel,
|
||||
onCombine,
|
||||
onReplace,
|
||||
loading,
|
||||
}: Props) {
|
||||
const [confirmCombineStep, setConfirmCombineStep] = useState(false);
|
||||
const existingSerial = (duplicate.existing_device.serial_number || '').trim().toUpperCase();
|
||||
const candidateSerial = (duplicate.candidate.serial_number || '').trim().toUpperCase();
|
||||
const serialsMatch = Boolean(existingSerial && candidateSerial && existingSerial === candidateSerial);
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="card w-full max-w-xl mx-4">
|
||||
<div className="flex items-center justify-between p-5 border-b border-gray-200 dark:border-slate-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-500" />
|
||||
Duplicate serial detected
|
||||
</h3>
|
||||
<button onClick={onCancel} disabled={loading} className="p-1 rounded text-gray-400 hover:text-gray-600 dark:hover:text-slate-300">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<p className="text-sm text-gray-600 dark:text-slate-300">
|
||||
Serial <span className="font-mono">{duplicate.candidate.serial_number}</span> already exists on a managed device.
|
||||
</p>
|
||||
{confirmCombineStep && (
|
||||
<div className="rounded-lg border border-amber-300 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-700 p-3">
|
||||
<p className="text-sm font-medium text-amber-800 dark:text-amber-300 mb-1">
|
||||
Confirm combine
|
||||
</p>
|
||||
<p className="text-xs text-amber-700 dark:text-amber-300/90">
|
||||
Existing serial: <span className="font-mono">{duplicate.existing_device.serial_number}</span>
|
||||
</p>
|
||||
<p className="text-xs text-amber-700 dark:text-amber-300/90">
|
||||
New device serial: <span className="font-mono">{duplicate.candidate.serial_number}</span>
|
||||
</p>
|
||||
<p className="text-xs text-amber-700 dark:text-amber-300/90 mt-2">
|
||||
Only continue if you are certain these represent the same physical device.
|
||||
</p>
|
||||
{!serialsMatch && (
|
||||
<p className="text-xs font-semibold text-red-700 dark:text-red-300 mt-2">
|
||||
Combine is disabled because the serial numbers do not match.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="rounded-lg border border-gray-200 dark:border-slate-700 p-3">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-slate-400">Existing managed device</p>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">{duplicate.existing_device.name}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-slate-400">{duplicate.existing_device.ip_address}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-slate-400 font-mono mt-1">
|
||||
Serial: {duplicate.existing_device.serial_number}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 dark:border-slate-700 p-3">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-slate-400">New device request</p>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">{pendingName}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-slate-400">{pendingIp}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-slate-400 font-mono mt-1">
|
||||
Serial: {duplicate.candidate.serial_number}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 pt-1">
|
||||
<button type="button" onClick={onCancel} disabled={loading} className="btn-secondary">
|
||||
Cancel
|
||||
</button>
|
||||
{confirmCombineStep ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCombine}
|
||||
disabled={loading || !serialsMatch}
|
||||
className="btn-secondary inline-flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<GitMerge className="w-4 h-4" />
|
||||
Confirm combine now
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={() => setConfirmCombineStep(true)} disabled={loading} className="btn-secondary inline-flex items-center gap-2">
|
||||
<GitMerge className="w-4 h-4" />
|
||||
Continue to combine check
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={onReplace} disabled={loading} className="btn-primary inline-flex items-center gap-2">
|
||||
<RefreshCcw className="w-4 h-4" />
|
||||
Replace existing details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { useEffect, useMemo, useState, FormEvent } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { X, CheckCircle, AlertCircle, Loader2, Network } from 'lucide-react';
|
||||
import { devicesApi } from '../../services/api';
|
||||
import type { Device, DeviceType, IpAddress } from '../../types';
|
||||
|
||||
interface Props {
|
||||
device: Device;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
// Strip the CIDR mask from "192.168.88.1/24" → "192.168.88.1" so the value
|
||||
// matches the format stored in `devices.ip_address`.
|
||||
function addrWithoutMask(addr: string): string {
|
||||
if (!addr) return '';
|
||||
const slash = addr.indexOf('/');
|
||||
return slash === -1 ? addr : addr.slice(0, slash);
|
||||
}
|
||||
|
||||
// Hide link-local / localhost / invalid rows from the IP picker.
|
||||
function isSelectableIp(row: IpAddress): boolean {
|
||||
const raw = addrWithoutMask(row.address || '');
|
||||
if (!raw) return false;
|
||||
if (row.disabled === 'true') return false;
|
||||
if (row.invalid === 'true') return false;
|
||||
if (raw === '127.0.0.1' || raw === '::1') return false;
|
||||
if (raw.toLowerCase().startsWith('fe80:')) return false;
|
||||
if (raw.startsWith('169.254.')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export default function EditDeviceModal({ device, onClose, onSuccess }: Props) {
|
||||
const [form, setForm] = useState({
|
||||
name: device.name,
|
||||
ip_address: device.ip_address,
|
||||
api_port: String(device.api_port ?? 8728),
|
||||
api_username: device.api_username ?? 'admin',
|
||||
api_password: '',
|
||||
ssh_port: String(device.ssh_port ?? 22),
|
||||
ssh_username: device.ssh_username ?? '',
|
||||
ssh_password: '',
|
||||
device_type: device.device_type,
|
||||
notes: device.notes ?? '',
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const set = (k: string, v: string) => setForm((f) => ({ ...f, [k]: v }));
|
||||
|
||||
// Live IPs from the router itself — useful so you can change the
|
||||
// management IP to something the device actually owns instead of guessing.
|
||||
const { data: ipRows, isLoading: ipsLoading, error: ipsError } = useQuery({
|
||||
queryKey: ['device-ips', device.id],
|
||||
queryFn: () => devicesApi.getIpAddresses(device.id).then((r) => r.data),
|
||||
enabled: device.status === 'online',
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const candidateIps = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
const out: { address: string; cidr: string; iface: string }[] = [];
|
||||
for (const row of (ipRows ?? []) as IpAddress[]) {
|
||||
if (!isSelectableIp(row)) continue;
|
||||
const bare = addrWithoutMask(row.address);
|
||||
if (seen.has(bare)) continue;
|
||||
seen.add(bare);
|
||||
out.push({ address: bare, cidr: row.address, iface: row.interface });
|
||||
}
|
||||
// Always include the current IP so it can be reselected
|
||||
if (device.ip_address && !seen.has(device.ip_address)) {
|
||||
out.unshift({ address: device.ip_address, cidr: device.ip_address, iface: '(current)' });
|
||||
}
|
||||
return out;
|
||||
}, [ipRows, device.ip_address]);
|
||||
|
||||
const ipWasDetected = candidateIps.some((c) => c.address === form.ip_address);
|
||||
|
||||
useEffect(() => { setError(''); }, [form.ip_address, form.api_port, form.api_username, form.api_password]);
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form.name || !form.ip_address || !form.api_username) {
|
||||
setError('Name, IP address, and username are required');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
name: form.name,
|
||||
ip_address: form.ip_address,
|
||||
api_port: parseInt(form.api_port, 10) || 8728,
|
||||
api_username: form.api_username,
|
||||
ssh_port: parseInt(form.ssh_port, 10) || 22,
|
||||
ssh_username: form.ssh_username || null,
|
||||
device_type: form.device_type as DeviceType,
|
||||
notes: form.notes,
|
||||
};
|
||||
if (form.api_password) payload.api_password = form.api_password;
|
||||
if (form.ssh_password) payload.ssh_password = form.ssh_password;
|
||||
await devicesApi.update(device.id, payload);
|
||||
onSuccess();
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setError(msg || 'Failed to update device');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="card w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between p-5 border-b border-gray-200 dark:border-slate-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Edit Device
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded text-gray-400 hover:text-gray-600 dark:hover:text-slate-300"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-5 space-y-4">
|
||||
{/* Basic info */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="col-span-2">
|
||||
<label className="label">Device Name *</label>
|
||||
<input
|
||||
className="input"
|
||||
value={form.name}
|
||||
onChange={(e) => set('name', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<label className="label flex items-center justify-between">
|
||||
<span>Management IP *</span>
|
||||
{device.status !== 'online' && (
|
||||
<span className="text-[10px] font-normal text-gray-400 dark:text-slate-500 normal-case tracking-normal">
|
||||
Device offline — IP list unavailable, enter manually
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
value={form.ip_address}
|
||||
onChange={(e) => set('ip_address', e.target.value)}
|
||||
placeholder="192.168.1.1"
|
||||
/>
|
||||
{device.status === 'online' && (
|
||||
<div className="mt-2 border border-gray-200 dark:border-slate-700 rounded-lg overflow-hidden">
|
||||
<div className="px-3 py-1.5 bg-gray-50 dark:bg-slate-700/50 text-[11px] uppercase tracking-wide font-semibold text-gray-500 dark:text-slate-400 flex items-center gap-1.5">
|
||||
<Network className="w-3 h-3" />
|
||||
IPs configured on this device
|
||||
</div>
|
||||
{ipsLoading ? (
|
||||
<div className="px-3 py-3 text-xs text-gray-400 flex items-center gap-2">
|
||||
<Loader2 className="w-3 h-3 animate-spin" /> Fetching from router…
|
||||
</div>
|
||||
) : ipsError ? (
|
||||
<div className="px-3 py-3 text-xs text-red-500">
|
||||
Could not fetch IP list. You can still enter one manually.
|
||||
</div>
|
||||
) : candidateIps.length === 0 ? (
|
||||
<div className="px-3 py-3 text-xs text-gray-400">
|
||||
No usable IP addresses returned by the router.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="max-h-40 overflow-y-auto divide-y divide-gray-100 dark:divide-slate-700">
|
||||
{candidateIps.map((c) => {
|
||||
const selected = form.ip_address === c.address;
|
||||
return (
|
||||
<li key={`${c.address}-${c.iface}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => set('ip_address', c.address)}
|
||||
className={
|
||||
'w-full flex items-center justify-between px-3 py-1.5 text-xs text-left transition-colors ' +
|
||||
(selected
|
||||
? 'bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-slate-700/50 text-gray-700 dark:text-slate-300')
|
||||
}
|
||||
>
|
||||
<span className="font-mono">{c.cidr}</span>
|
||||
<span className="text-gray-400 dark:text-slate-500">{c.iface}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{!ipsLoading && !ipsError && !ipWasDetected && form.ip_address && (
|
||||
<div className="px-3 py-2 text-[11px] text-amber-600 dark:text-amber-400 border-t border-gray-100 dark:border-slate-700">
|
||||
Entered IP isn't in the router's address list — double-check before saving.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<label className="label">Device Type</label>
|
||||
<select
|
||||
className="input"
|
||||
value={form.device_type}
|
||||
onChange={(e) => set('device_type', e.target.value)}
|
||||
>
|
||||
<option value="router">Router</option>
|
||||
<option value="switch">Switch</option>
|
||||
<option value="wireless_ap">Wireless AP</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API credentials */}
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider mb-3">
|
||||
RouterOS API Credentials
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Username *</label>
|
||||
<input
|
||||
className="input"
|
||||
value={form.api_username}
|
||||
onChange={(e) => set('api_username', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">API Port</label>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
value={form.api_port}
|
||||
onChange={(e) => set('api_port', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="label">New Password (leave blank to keep existing)</label>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={form.api_password}
|
||||
onChange={(e) => set('api_password', e.target.value)}
|
||||
autoComplete="new-password"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SSH credentials (optional) */}
|
||||
<details className="group">
|
||||
<summary className="cursor-pointer text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider select-none">
|
||||
SSH Credentials (optional, for backup/restore)
|
||||
</summary>
|
||||
<div className="mt-3 grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">SSH Username</label>
|
||||
<input
|
||||
className="input"
|
||||
value={form.ssh_username}
|
||||
onChange={(e) => set('ssh_username', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">SSH Port</label>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
value={form.ssh_port}
|
||||
onChange={(e) => set('ssh_port', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="label">New SSH Password (leave blank to keep existing)</label>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={form.ssh_password}
|
||||
onChange={(e) => set('ssh_password', e.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div>
|
||||
<label className="label">Notes</label>
|
||||
<textarea
|
||||
className="input"
|
||||
rows={2}
|
||||
value={form.notes}
|
||||
onChange={(e) => set('notes', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<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">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<Loader2 className="w-4 h-4 text-blue-500 animate-spin flex-shrink-0" />
|
||||
<p className="text-sm text-blue-600 dark:text-blue-400">
|
||||
Verifying connection with new settings…
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button type="button" onClick={onClose} className="btn-secondary">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={loading} className="btn-primary flex items-center gap-2">
|
||||
{loading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
)}
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { X, Loader2, CheckCircle, AlertCircle, KeyRound } from 'lucide-react';
|
||||
import {
|
||||
credentialPresetsApi,
|
||||
devicesApi,
|
||||
type DiscoveredDevice,
|
||||
type DuplicateSerialError,
|
||||
} from '../../services/api';
|
||||
import type { DeviceType } from '../../types';
|
||||
|
||||
interface Props {
|
||||
discoveredDevices: DiscoveredDevice[];
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
type ResultItem = { ip: string; identity: string; ok: boolean; message: string };
|
||||
|
||||
function isDuplicateSerialError(v: unknown): v is DuplicateSerialError {
|
||||
if (!v || typeof v !== 'object') return false;
|
||||
const obj = v as { error?: unknown; code?: unknown; existing_device?: unknown };
|
||||
return obj.error === 'duplicate_serial' && obj.code === 'duplicate_serial' && !!obj.existing_device;
|
||||
}
|
||||
export default function TryAllDiscoveredModal({ discoveredDevices, onClose, onSuccess }: Props) {
|
||||
const [mode, setMode] = useState<'preset' | 'manual'>('preset');
|
||||
const [presetId, setPresetId] = useState<number | null>(null);
|
||||
const [manual, setManual] = useState({
|
||||
api_username: 'admin',
|
||||
api_password: '',
|
||||
api_port: '8728',
|
||||
ssh_username: '',
|
||||
ssh_password: '',
|
||||
ssh_port: '22',
|
||||
device_type: 'router' as DeviceType,
|
||||
});
|
||||
const [running, setRunning] = useState(false);
|
||||
const [results, setResults] = useState<ResultItem[] | null>(null);
|
||||
const [cancelled, setCancelled] = useState(false);
|
||||
const [progress, setProgress] = useState<{ current: number; total: number; currentName: string }>({
|
||||
current: 0,
|
||||
total: 0,
|
||||
currentName: '',
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
const cancelRef = useRef(false);
|
||||
|
||||
const { data: presets = [] } = useQuery({
|
||||
queryKey: ['credential-presets'],
|
||||
queryFn: () => credentialPresetsApi.list().then((r) => r.data),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const targets = useMemo(
|
||||
() => discoveredDevices.filter((d) => d.address && d.address.trim().length > 0),
|
||||
[discoveredDevices]
|
||||
);
|
||||
|
||||
const handleRun = async () => {
|
||||
setError('');
|
||||
setCancelled(false);
|
||||
cancelRef.current = false;
|
||||
setResults(null);
|
||||
if (!targets.length) {
|
||||
setError('No discovered devices with a usable IP address.');
|
||||
return;
|
||||
}
|
||||
if (mode === 'preset' && !presetId) {
|
||||
setError('Choose a credential preset or switch to manual.');
|
||||
return;
|
||||
}
|
||||
if (mode === 'manual' && (!manual.api_username || !manual.api_password)) {
|
||||
setError('Manual mode requires API username and password.');
|
||||
return;
|
||||
}
|
||||
|
||||
setRunning(true);
|
||||
setProgress({ current: 0, total: targets.length, currentName: '' });
|
||||
const out: ResultItem[] = [];
|
||||
for (let i = 0; i < targets.length; i++) {
|
||||
if (cancelRef.current) break;
|
||||
const d = targets[i];
|
||||
const name = d.identity || d.address;
|
||||
setProgress({ current: i + 1, total: targets.length, currentName: name });
|
||||
const payload =
|
||||
mode === 'preset'
|
||||
? {
|
||||
name,
|
||||
ip_address: d.address,
|
||||
credential_preset_id: presetId!,
|
||||
}
|
||||
: {
|
||||
name,
|
||||
ip_address: d.address,
|
||||
api_username: manual.api_username,
|
||||
api_password: manual.api_password,
|
||||
api_port: parseInt(manual.api_port, 10) || 8728,
|
||||
ssh_username: manual.ssh_username || undefined,
|
||||
ssh_password: manual.ssh_password || undefined,
|
||||
ssh_port: parseInt(manual.ssh_port, 10) || 22,
|
||||
device_type: manual.device_type,
|
||||
};
|
||||
try {
|
||||
await devicesApi.create(payload);
|
||||
out.push({ ip: d.address, identity: name, ok: true, message: 'Added' });
|
||||
setResults([...out]);
|
||||
} catch (err: unknown) {
|
||||
const data = (err as { response?: { data?: unknown } })?.response?.data;
|
||||
|
||||
const msg = isDuplicateSerialError(data)
|
||||
? `Duplicate serial mismatch: candidate ${data.candidate.serial_number}, existing ${data.existing_device.serial_number}. Review manually before combining.`
|
||||
: ((data as { message?: string; error?: string } | undefined)?.message ||
|
||||
(typeof (data as { error?: unknown } | undefined)?.error === 'string'
|
||||
? String((data as { error?: string }).error)
|
||||
: '') ||
|
||||
'Failed');
|
||||
out.push({ ip: d.address, identity: name, ok: false, message: msg });
|
||||
setResults([...out]);
|
||||
}
|
||||
}
|
||||
setResults(out);
|
||||
setRunning(false);
|
||||
setProgress((p) => ({ ...p, currentName: '' }));
|
||||
if (cancelRef.current) setCancelled(true);
|
||||
};
|
||||
|
||||
const okCount = results?.filter((r) => r.ok).length ?? 0;
|
||||
const failCount = results?.filter((r) => !r.ok).length ?? 0;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="card w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between p-5 border-b border-gray-200 dark:border-slate-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Try All Discovered Devices</h2>
|
||||
<button onClick={onClose} className="p-1 rounded text-gray-400 hover:text-gray-600 dark:hover:text-slate-300">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-5 space-y-4">
|
||||
<p className="text-sm text-gray-600 dark:text-slate-300">
|
||||
Attempt to add <strong>{targets.length}</strong> discovered device(s) in one run.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('preset')}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm border ${mode === 'preset' ? 'bg-blue-600 text-white border-blue-600' : 'text-gray-600 dark:text-slate-300 border-gray-300 dark:border-slate-600'}`}
|
||||
>
|
||||
Use Preset
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('manual')}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm border ${mode === 'manual' ? 'bg-blue-600 text-white border-blue-600' : 'text-gray-600 dark:text-slate-300 border-gray-300 dark:border-slate-600'}`}
|
||||
>
|
||||
Enter Manually
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === 'preset' ? (
|
||||
<div>
|
||||
<label className="label flex items-center gap-1.5"><KeyRound className="w-3.5 h-3.5" />Credential Preset</label>
|
||||
<select className="input" value={presetId ?? ''} onChange={(e) => setPresetId(e.target.value ? parseInt(e.target.value, 10) : null)}>
|
||||
<option value="">Select preset...</option>
|
||||
{presets.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} ({p.api_username}{p.ssh_username ? ` · SSH ${p.ssh_username}` : ''})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">API Username *</label>
|
||||
<input className="input" value={manual.api_username} onChange={(e) => setManual((m) => ({ ...m, api_username: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">API Port</label>
|
||||
<input className="input" type="number" value={manual.api_port} onChange={(e) => setManual((m) => ({ ...m, api_port: e.target.value }))} />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="label">API Password *</label>
|
||||
<input className="input" type="password" value={manual.api_password} onChange={(e) => setManual((m) => ({ ...m, api_password: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">SSH Username</label>
|
||||
<input className="input" value={manual.ssh_username} onChange={(e) => setManual((m) => ({ ...m, ssh_username: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">SSH Port</label>
|
||||
<input className="input" type="number" value={manual.ssh_port} onChange={(e) => setManual((m) => ({ ...m, ssh_port: e.target.value }))} />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="label">SSH Password</label>
|
||||
<input className="input" type="password" value={manual.ssh_password} onChange={(e) => setManual((m) => ({ ...m, ssh_password: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<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">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{running && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-gray-700 dark:text-slate-300">
|
||||
Trying {progress.current}/{progress.total}
|
||||
{progress.currentName && (
|
||||
<>
|
||||
: <span className="font-medium">{progress.currentName}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full h-2 bg-gray-200 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 transition-all"
|
||||
style={{ width: `${progress.total ? Math.round((progress.current / progress.total) * 100) : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { cancelRef.current = true; }}
|
||||
className="btn-secondary text-xs py-1 px-2"
|
||||
>
|
||||
Stop / Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-gray-700 dark:text-slate-300">
|
||||
{running ? 'Live results:' : cancelled ? 'Cancelled:' : 'Done:'}{' '}
|
||||
<span className="text-green-600 dark:text-green-400">{okCount} succeeded</span>,{' '}
|
||||
<span className="text-red-600 dark:text-red-400">{failCount} failed</span>
|
||||
</div>
|
||||
<div className="max-h-56 overflow-y-auto border border-gray-200 dark:border-slate-700 rounded-lg">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-gray-50 dark:bg-slate-700/50 sticky top-0">
|
||||
<tr>
|
||||
<th className="table-header px-3 py-2 text-left">Device</th>
|
||||
<th className="table-header px-3 py-2 text-left">IP</th>
|
||||
<th className="table-header px-3 py-2 text-left">Result</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700">
|
||||
{results.map((r) => (
|
||||
<tr key={`${r.ip}-${r.identity}`}>
|
||||
<td className="px-3 py-2 text-gray-700 dark:text-slate-300">{r.identity}</td>
|
||||
<td className="px-3 py-2 font-mono text-gray-500 dark:text-slate-400">{r.ip}</td>
|
||||
<td className="px-3 py-2">
|
||||
{r.ok ? (
|
||||
<span className="inline-flex items-center gap-1 text-green-600 dark:text-green-400">
|
||||
<CheckCircle className="w-3.5 h-3.5" /> {r.message}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-red-600 dark:text-red-400">
|
||||
<AlertCircle className="w-3.5 h-3.5" /> {r.message}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button type="button" onClick={onClose} className="btn-secondary">Close</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRun}
|
||||
disabled={running}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
{running ? <Loader2 className="w-4 h-4 animate-spin" /> : <CheckCircle className="w-4 h-4" />}
|
||||
{running ? 'Trying...' : `Try All (${targets.length})`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Plus, Pencil, Trash2, KeyRound, X, AlertCircle, CheckCircle, Loader2,
|
||||
} from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
credentialPresetsApi,
|
||||
type CredentialPreset,
|
||||
type CredentialPresetInput,
|
||||
} from '../../services/api';
|
||||
|
||||
interface FormState {
|
||||
name: string;
|
||||
api_username: string;
|
||||
api_password: string;
|
||||
api_port: string;
|
||||
ssh_username: string;
|
||||
ssh_password: string;
|
||||
ssh_port: string;
|
||||
notes: string;
|
||||
clear_ssh_password: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: FormState = {
|
||||
name: '',
|
||||
api_username: 'admin',
|
||||
api_password: '',
|
||||
api_port: '8728',
|
||||
ssh_username: '',
|
||||
ssh_password: '',
|
||||
ssh_port: '22',
|
||||
notes: '',
|
||||
clear_ssh_password: false,
|
||||
};
|
||||
|
||||
function toInput(f: FormState, isEdit: boolean): CredentialPresetInput {
|
||||
const input: CredentialPresetInput = {
|
||||
name: f.name || undefined,
|
||||
api_username: f.api_username || undefined,
|
||||
api_port: f.api_port ? parseInt(f.api_port, 10) : null,
|
||||
ssh_username: f.ssh_username || null,
|
||||
ssh_port: f.ssh_port ? parseInt(f.ssh_port, 10) : null,
|
||||
notes: f.notes || null,
|
||||
};
|
||||
if (f.api_password) input.api_password = f.api_password;
|
||||
if (f.ssh_password) input.ssh_password = f.ssh_password;
|
||||
else if (isEdit && f.clear_ssh_password) input.clear_ssh_password = true;
|
||||
return input;
|
||||
}
|
||||
|
||||
export default function CredentialPresetsSettings({ isAdmin }: { isAdmin: boolean }) {
|
||||
const qc = useQueryClient();
|
||||
const { data: presets = [], isLoading } = useQuery({
|
||||
queryKey: ['credential-presets'],
|
||||
queryFn: () => credentialPresetsApi.list().then((r) => r.data),
|
||||
});
|
||||
|
||||
const [modal, setModal] = useState<null | { mode: 'add' } | { mode: 'edit'; id: number }>(null);
|
||||
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const set = <K extends keyof FormState>(k: K, v: FormState[K]) =>
|
||||
setForm((s) => ({ ...s, [k]: v }));
|
||||
|
||||
const openAdd = () => {
|
||||
setForm(EMPTY_FORM);
|
||||
setError('');
|
||||
setModal({ mode: 'add' });
|
||||
};
|
||||
|
||||
const openEdit = (p: CredentialPreset) => {
|
||||
setForm({
|
||||
name: p.name,
|
||||
api_username: p.api_username,
|
||||
api_password: '',
|
||||
api_port: p.api_port != null ? String(p.api_port) : '',
|
||||
ssh_username: p.ssh_username ?? '',
|
||||
ssh_password: '',
|
||||
ssh_port: p.ssh_port != null ? String(p.ssh_port) : '',
|
||||
notes: p.notes ?? '',
|
||||
clear_ssh_password: false,
|
||||
});
|
||||
setError('');
|
||||
setModal({ mode: 'edit', id: p.id });
|
||||
};
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!modal) return;
|
||||
if (modal.mode === 'add') {
|
||||
if (!form.name || !form.api_username || !form.api_password) {
|
||||
throw new Error('Name, API username and password are required');
|
||||
}
|
||||
await credentialPresetsApi.create(toInput(form, false));
|
||||
} else {
|
||||
await credentialPresetsApi.update(modal.id, toInput(form, true));
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['credential-presets'] });
|
||||
setModal(null);
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
(err as Error)?.message ?? 'Failed to save preset';
|
||||
setError(msg);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => credentialPresetsApi.delete(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['credential-presets'] }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="card p-5">
|
||||
<div className="flex items-start justify-between gap-4 mb-1">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<KeyRound className="w-4 h-4 text-blue-500" />
|
||||
Device Credential Presets
|
||||
</h3>
|
||||
<p className="text-xs text-gray-400 dark:text-slate-500 mt-1">
|
||||
Reusable RouterOS API & SSH credential sets. When adding a
|
||||
discovered device you can pick a preset instead of retyping the
|
||||
same username and password. Passwords are encrypted at rest and
|
||||
never returned to the browser.
|
||||
</p>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<button
|
||||
className="btn-primary text-xs flex items-center gap-1 flex-shrink-0"
|
||||
onClick={openAdd}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> Add Preset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="p-6 text-sm text-gray-400 flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Loading presets…
|
||||
</div>
|
||||
) : presets.length === 0 ? (
|
||||
<div className="p-6 text-sm text-gray-400 dark:text-slate-500 text-center">
|
||||
No credential presets yet.
|
||||
{isAdmin && ' Click "Add Preset" to create your first one.'}
|
||||
</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.5 text-left">Name</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">API</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">SSH</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Notes</th>
|
||||
<th className="table-header px-4 py-2.5 w-24" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{presets.map((p) => (
|
||||
<tr key={p.id} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">
|
||||
{p.name}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs text-gray-500 dark:text-slate-400">
|
||||
<div className="font-mono">{p.api_username}</div>
|
||||
<div className="text-gray-400 dark:text-slate-500">
|
||||
port {p.api_port ?? 8728}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs text-gray-500 dark:text-slate-400">
|
||||
{p.ssh_username ? (
|
||||
<>
|
||||
<div className="font-mono">{p.ssh_username}</div>
|
||||
<div className="text-gray-400 dark:text-slate-500">
|
||||
port {p.ssh_port ?? 22}
|
||||
{p.has_ssh_password ? '' : ' · no password'}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-gray-300 dark:text-slate-600">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs text-gray-500 dark:text-slate-400 max-w-xs truncate">
|
||||
{p.notes || <span className="text-gray-300 dark:text-slate-600">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{isAdmin && (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => openEdit(p)}
|
||||
className="p-1.5 rounded text-gray-400 hover:text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors"
|
||||
title="Edit preset"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Delete credential preset "${p.name}"? Devices already using these credentials keep working — presets are not linked after use.`)) {
|
||||
deleteMutation.mutate(p.id);
|
||||
}
|
||||
}}
|
||||
className="p-1.5 rounded text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||
title="Delete preset"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{modal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="card w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between p-5 border-b border-gray-200 dark:border-slate-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{modal.mode === 'add' ? 'Add Credential Preset' : 'Edit Credential Preset'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setModal(null)}
|
||||
className="p-1 rounded text-gray-400 hover:text-gray-600 dark:hover:text-slate-300"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
<label className="label">Preset Name *</label>
|
||||
<input
|
||||
className="input"
|
||||
value={form.name}
|
||||
onChange={(e) => set('name', e.target.value)}
|
||||
placeholder="e.g. Default MikroTik (admin)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider mb-3">
|
||||
RouterOS API
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Username *</label>
|
||||
<input
|
||||
className="input"
|
||||
value={form.api_username}
|
||||
onChange={(e) => set('api_username', e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">API Port</label>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
value={form.api_port}
|
||||
onChange={(e) => set('api_port', e.target.value)}
|
||||
placeholder="8728"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="label">
|
||||
Password {modal.mode === 'edit' && '(leave blank to keep current)'}
|
||||
{modal.mode === 'add' && ' *'}
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={form.api_password}
|
||||
onChange={(e) => set('api_password', e.target.value)}
|
||||
autoComplete="new-password"
|
||||
placeholder={modal.mode === 'edit' ? '••••••••' : ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider mb-3">
|
||||
SSH (optional, for backups)
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Username</label>
|
||||
<input
|
||||
className="input"
|
||||
value={form.ssh_username}
|
||||
onChange={(e) => set('ssh_username', e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">SSH Port</label>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
value={form.ssh_port}
|
||||
onChange={(e) => set('ssh_port', e.target.value)}
|
||||
placeholder="22"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="label">
|
||||
Password {modal.mode === 'edit' && '(leave blank to keep current)'}
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={form.ssh_password}
|
||||
onChange={(e) => set('ssh_password', e.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
{modal.mode === 'edit' && (
|
||||
<label className="flex items-center gap-1.5 mt-2 text-xs text-gray-500 dark:text-slate-400 select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
checked={form.clear_ssh_password}
|
||||
disabled={!!form.ssh_password}
|
||||
onChange={(e) => set('clear_ssh_password', e.target.checked)}
|
||||
/>
|
||||
Remove saved SSH password
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Notes</label>
|
||||
<textarea
|
||||
className="input"
|
||||
rows={2}
|
||||
value={form.notes}
|
||||
onChange={(e) => set('notes', e.target.value)}
|
||||
placeholder="Optional — e.g. which sites these credentials apply to"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<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">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-1">
|
||||
<button type="button" onClick={() => setModal(null)} className="btn-secondary">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={saveMutation.isPending}
|
||||
className={clsx('btn-primary flex items-center gap-2')}
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
)}
|
||||
{modal.mode === 'add' ? 'Create Preset' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user