Files
MIKROTIK-RMM/frontend/src/services/api.ts
T
2GT-RichandClaude Opus 5 37887d38c2 feat: RouterBOOT in staged rollouts, and snoozeable insights (v0.23.10-beta)
Closes #113, #102.

#113 — RouterBOOT as a follow-on rollout step
  Firmware rollouts upgraded RouterOS and stopped, leaving the bootloader to be
  done by hand on every device — the exact task staged rollouts exist to remove.
  The data was already collected and the collector already had the two methods; the
  orchestrator simply never called them.

  An optional "Then RouterBOOT" step now runs per device after the RouterOS upgrade
  has been verified, rides out the second reboot, and confirms the firmware version
  actually moved.

  Ordering is deliberate: the bootloader ships inside the RouterOS package, so
  upgrading it first would apply the old one. It runs only once the OS half is
  confirmed good, and off by default, because it is a second flash and a second
  reboot per device.

  A device with nothing pending is a success rather than a failure — most of a fleet
  is already current, and treating "nothing to do" as an error would halt rollouts
  for no reason. When RouterBOOT does fail after a successful RouterOS upgrade the
  error says so explicitly, rather than reporting the device as simply failed and
  sending someone to re-run an upgrade it already has.

#102 — snooze an item in "Things to handle"
  Insights are recomputed from live state on every request and have no id, so a
  dismissal is keyed on a fingerprint of category, target and title. That survives a
  reworded title without swallowing a different device's version of the same issue.

  Dismissals expire, defaulting to 24 hours. "Handled" and "not now" look identical
  to the code, and an item that is genuinely resolved stops being generated anyway —
  so a permanent dismissal could only ever hide something still true. Expired rows
  are cleared on read, so a lapse needs no scheduled job, and the panel reports how
  many items are currently hidden rather than quietly shrinking.

Verified against the fleet: snoozing took the list from 8 items to 7 with
dismissedCount 1, and removing the dismissal restored it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 20:42:16 -07:00

1324 lines
64 KiB
TypeScript

import axios, { AxiosError } from 'axios';
import { useAuthStore } from '../store/authStore';
import type {
Device,
Interface,
Vlan,
Client,
DeviceEvent,
Backup,
MetricsSummary,
DeviceAvailability,
TimeSeriesPoint,
TrafficPoint,
ResourcePoint,
SwitchPort,
SystemConfig,
IpAddress,
PortVlanConfig,
PortMonitorData,
ConfigSnapshotMeta,
ConfigSnapshotFull,
ConfigDiffResponse,
ConfigCaptureResult,
UserRole,
} from '../types';
const api = axios.create({
baseURL: '/api',
timeout: 30000,
});
api.interceptors.request.use((config) => {
const token = useAuthStore.getState().token;
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
api.interceptors.response.use(
(r) => r,
(error: AxiosError) => {
if (error.response?.status === 401) {
useAuthStore.getState().logout();
}
return Promise.reject(error);
}
);
// ─── Auth ─────────────────────────────────────────────────────────────────────
export const authApi = {
login: (username: string, password: string) =>
api.post<{ token?: string; user?: import('../types').User; requires_totp?: boolean; totp_token?: string }>(
'/auth/login',
{ username, password }
),
totpVerify: (totp_token: string, code: string) =>
api.post<{ token: string; user: import('../types').User }>('/auth/totp/verify', { totp_token, code }),
totpSetup: (password: string) =>
api.post<{ secret: string; uri: string; qr: string }>('/auth/totp/setup', { password }),
totpConfirm: (code: string) =>
api.post<{ ok: boolean }>('/auth/totp/confirm', { code }),
totpDisable: (password: string) =>
api.post<{ ok: boolean }>('/auth/totp/disable', { password }),
totpStatus: () =>
api.get<{ totp_enabled: boolean }>('/auth/totp/status'),
me: () => api.get('/auth/me'),
changePassword: (currentPassword: string, newPassword: string) =>
api.put('/auth/password', { currentPassword, newPassword }),
securityStatus: () =>
api.get<{ warnings: string[] }>('/auth/security-status'),
oidcStatus: () =>
api.get<{ enabled: boolean; button_label: string }>('/auth/oidc/status'),
};
export interface OidcConfigView {
enabled: boolean;
issuer_url: string;
client_id: string;
has_secret: boolean;
scopes: string;
username_claim: string;
email_claim: string;
groups_claim: string;
group_role_map: Record<string, UserRole>;
default_role: UserRole;
auto_provision: boolean;
link_by_verified_email: boolean;
allowed_email_domains: string[];
button_label: string;
public_base_url: string;
redirect_uri: string;
}
export const oidcApi = {
getConfig: () => api.get<OidcConfigView>('/auth/oidc/config'),
updateConfig: (data: Partial<OidcConfigView> & { client_secret?: string }) =>
api.put<OidcConfigView>('/auth/oidc/config', data),
test: (issuer_url?: string) =>
api.post<{ ok: boolean; issuer?: string; claims_supported?: string[]; error?: string }>(
'/auth/oidc/test',
{ issuer_url }
),
};
// ─── Devices ──────────────────────────────────────────────────────────────────
export interface DiscoveredDevice {
identity: string;
address: string;
mac_address: string;
platform: string;
discovered_at: string;
seen_by: string;
// When the neighbor matches a device that's already managed (by interface
// MAC, primary IP, or system identity) the backend fills these in so the
// UI can hide the row as a duplicate.
duplicate_of_device_id: number | null;
duplicate_of_device_name: string | null;
}
export interface DuplicateSerialDeviceInfo {
id: number;
name: string;
ip_address: string;
serial_number: string;
model?: string;
ros_version?: string;
}
export interface DuplicateSerialError {
error: string;
code: 'duplicate_serial';
existing_device: DuplicateSerialDeviceInfo;
candidate: {
serial_number: string;
identity: string;
model?: string;
ros_version?: string;
ip_address: string;
};
}
export interface BulkAddDeviceItem {
name?: string;
ip_address?: string;
device_type?: string;
notes?: string;
credential_preset_id?: number;
api_username?: string;
api_password?: string;
api_port?: number;
ssh_username?: string;
ssh_password?: string;
ssh_port?: number;
}
export interface BulkAddJobResponse {
job_id: string;
total: number;
}
export interface BulkAddJobStatus {
job_id: string;
status?: string;
total?: number;
processed?: number;
current_name?: string | null;
results?: { ip: string; identity: string; ok: boolean; message: string }[];
error?: string;
[key: string]: unknown;
}
export interface SecurityCheck {
id: string;
severity: 'high' | 'medium' | 'low' | 'ok';
title: string;
detail: string;
serviceId?: string;
/** Operator judged this inapplicable — still shown, but excluded from the score. */
suppressed?: boolean;
suppressed_scope?: 'device' | 'fleet';
suppressed_reason?: string | null;
}
export interface SecuritySuppression {
id: number;
check_id: string;
device_id: number | null;
device_name: string | null;
reason: string | null;
created_by: string | null;
created_at: string;
}
/**
* A configuration RouterOS accepted but that does not work. Produced by the
* standing Config Health audit, which encodes MikroTik's documented Layer2
* misconfigurations plus the latent lockout cases.
*/
export interface ConfigFinding {
rule: string;
severity: 'critical' | 'warning' | 'info';
title: string;
detail: string;
remediation: string | null;
doc_url: string | null;
objects: string[];
first_seen: string | null;
last_seen: string | null;
}
export interface ConfigHealthResponse {
findings: ConfigFinding[];
checked_at: string | null;
}
export const devicesApi = {
list: () => api.get<Device[]>('/devices'),
discovered: () => api.get<DiscoveredDevice[]>('/devices/discovered'),
get: (id: number) => api.get<Device>(`/devices/${id}`),
bulkAddEnqueue: (items: BulkAddDeviceItem[]) =>
api.post<BulkAddJobResponse>('/devices/bulk-add/jobs', { items }),
bulkAddStatus: (jobId: string) =>
api.get<BulkAddJobStatus>(`/devices/bulk-add/jobs/${encodeURIComponent(jobId)}`),
bulkAddCancel: (jobId: string) =>
api.post<{ message: string }>(`/devices/bulk-add/jobs/${encodeURIComponent(jobId)}/cancel`),
create: (
data: (Partial<Device> & {
api_password?: string;
ssh_password?: string;
credential_preset_id?: number;
force_replace_existing_by_serial?: boolean;
combine_with_device_id?: number;
})
) => api.post<Device>('/devices', data),
update: (
id: number,
data: Partial<Device> & { api_password?: string; ssh_password?: string; credential_preset_id?: number | null }
) => api.put<Device>(`/devices/${id}`, data),
delete: (id: number) => api.delete(`/devices/${id}`),
sync: (id: number) => api.post(`/devices/${id}/sync`, undefined, { timeout: 60_000 }),
test: (id: number) => api.post<{ success: boolean; identity?: string; error?: string }>(`/devices/${id}/test`),
getInterfaces: (id: number) => api.get<Interface[]>(`/devices/${id}/interfaces`),
updateInterface: (id: number, name: string, data: Partial<Interface>) =>
api.put(`/devices/${id}/interfaces/${encodeURIComponent(name)}`, data),
getVlans: (id: number) => api.get<Vlan[]>(`/devices/${id}/vlans`),
getPorts: (id: number) =>
api.get<{ ports: SwitchPort[]; vlans: Vlan[] }>(`/devices/${id}/ports`),
getRouting: (id: number) => api.get<Record<string, string>[]>(`/devices/${id}/routing`),
getFirewall: (id: number) => api.get<Record<string, string>[]>(`/devices/${id}/firewall`),
addFirewallRule: (id: number, data: Record<string, unknown>) =>
api.post<Record<string, string>[]>(`/devices/${id}/firewall`, data),
updateFirewallRule: (id: number, ruleId: string, data: Record<string, unknown>) =>
api.put<Record<string, string>[]>(`/devices/${id}/firewall/${encodeURIComponent(ruleId)}`, data),
deleteFirewallRule: (id: number, ruleId: string) =>
api.delete(`/devices/${id}/firewall/${encodeURIComponent(ruleId)}`),
getResources: (id: number) => api.get<Record<string, string>>(`/devices/${id}/resources`),
configurePortVlan: (id: number, name: string, data: PortVlanConfig & { confirm_lockout?: boolean }) =>
api.put(`/devices/${id}/ports/${encodeURIComponent(name)}/vlan`, data),
getSystemConfig: (id: number) => api.get<SystemConfig>(`/devices/${id}/system-config`),
updateSystemConfig: (id: number, data: Partial<{
identity: string;
ntp_enabled: boolean;
ntp_primary: string;
ntp_secondary: string;
dns_servers: string;
dns_allow_remote: boolean;
}>) => api.put(`/devices/${id}/system-config`, data),
getIpAddresses: (id: number) => api.get<IpAddress[]>(`/devices/${id}/ip-addresses`),
addIpAddress: (id: number, data: { address: string; interface: string; confirm_lockout?: boolean }) =>
api.post<IpAddress[]>(`/devices/${id}/ip-addresses`, data),
removeIpAddress: (id: number, addrId: string, confirmLockout = false) =>
api.delete(`/devices/${id}/ip-addresses/${encodeURIComponent(addrId)}`,
confirmLockout ? { data: { confirm_lockout: true } } : undefined),
checkUpdate: (id: number) =>
api.post<Record<string, string>>(`/devices/${id}/check-update`),
checkRouterboard: (id: number) =>
api.post<{ currentFirmware: string; upgradeFirmware: string; upgradeAvailable: boolean }>(`/devices/${id}/check-routerboard`),
installRouterboard: (id: number) => api.post(`/devices/${id}/install-routerboard`),
getHardware: (id: number) =>
api.get<{ health: Record<string, string>[]; disks: Record<string, string>[] }>(`/devices/${id}/hardware`),
installUpdate: (id: number) => api.post(`/devices/${id}/install-update`),
getClock: (id: number) => api.get<{ date: string; time: string; timezone: string }>(`/devices/${id}/clock`),
setClock: (id: number, data: { date?: string; time?: string; timezone?: string }) =>
api.put(`/devices/${id}/clock`, data),
addRoute: (id: number, data: { dst_address: string; gateway: string; distance?: number; comment?: string; confirm_lockout?: boolean }) =>
api.post(`/devices/${id}/routing`, data),
deleteRoute: (id: number, routeId: string, confirmLockout = false) =>
api.delete(`/devices/${id}/routing/${encodeURIComponent(routeId)}`,
confirmLockout ? { data: { confirm_lockout: true } } : undefined),
// confirm_lockout overrides a predicted-lockout refusal (HTTP 409) after the user
// has seen and accepted the verdict.
addVlan: (id: number, data: { bridge: string; vlan_id: number; tagged_ports?: string[]; untagged_ports?: string[]; confirm_lockout?: boolean }) =>
api.post(`/devices/${id}/vlans`, data),
updateVlan: (id: number, vlanDbId: number, data: { tagged_ports: string[]; untagged_ports: string[]; confirm_lockout?: boolean }) =>
api.put(`/devices/${id}/vlans/${vlanDbId}`, data),
deleteVlan: (id: number, vlanDbId: number, confirmLockout = false) =>
api.delete(`/devices/${id}/vlans/${vlanDbId}`, confirmLockout ? { data: { confirm_lockout: true } } : undefined),
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<PortMonitorData>(`/devices/${id}/ports/${encodeURIComponent(name)}/monitor`),
getPortClients: (id: number, name: string, all = false) =>
api.get<import('../types').PortClientsResult>(`/devices/${id}/ports/${encodeURIComponent(name)}/clients`, { params: { all: all || undefined } }),
createBond: (id: number, data: {
name: string; mode: string; slaves: string[];
lacp_rate?: string; transmit_hash_policy?: string; mtu?: number; min_links?: number;
confirm_lockout?: boolean;
}) => api.post(`/devices/${id}/bonds`, data),
updateBond: (id: number, bondName: string, data: {
mode: string; slaves: string[];
lacp_rate?: string; transmit_hash_policy?: string; mtu?: number; min_links?: number;
}) => api.put(`/devices/${id}/bonds/${encodeURIComponent(bondName)}`, data),
deleteBond: (id: number, bondName: string, confirmLockout = false) =>
api.delete(`/devices/${id}/bonds/${encodeURIComponent(bondName)}`,
confirmLockout ? { data: { confirm_lockout: true } } : undefined),
setBridgeVlanFiltering: (id: number, bridgeName: string, enabled: boolean, confirmLockout = false) =>
api.put(`/devices/${id}/bridge/${encodeURIComponent(bridgeName)}/vlan-filtering`,
{ enabled, ...(confirmLockout ? { confirm_lockout: true } : {}) }),
getNat: (id: number) => api.get<Record<string, string>[]>(`/devices/${id}/nat`),
addNatRule: (id: number, data: Record<string, unknown>) =>
api.post<Record<string, string>[]>(`/devices/${id}/nat`, data),
updateNatRule: (id: number, ruleId: string, data: Record<string, unknown>) =>
api.put<Record<string, string>[]>(`/devices/${id}/nat/${encodeURIComponent(ruleId)}`, data),
deleteNatRule: (id: number, ruleId: string) =>
api.delete(`/devices/${id}/nat/${encodeURIComponent(ruleId)}`),
// Firewall: reorder + counters (order is decisive in RouterOS)
moveFirewallRule: (id: number, ruleId: string, destination?: string) =>
api.post<Record<string, string>[]>(`/devices/${id}/firewall/move`, { id: ruleId, destination }),
resetFirewallCounters: (id: number) =>
api.post<Record<string, string>[]>(`/devices/${id}/firewall/reset-counters`),
moveNatRule: (id: number, ruleId: string, destination?: string) =>
api.post<Record<string, string>[]>(`/devices/${id}/nat/move`, { id: ruleId, destination }),
// Firewall address lists (reusable address objects)
getAddressLists: (id: number) => api.get<Record<string, string>[]>(`/devices/${id}/address-lists`),
addAddressListEntry: (id: number, data: { list: string; address: string; comment?: string; timeout?: string }) =>
api.post<Record<string, string>[]>(`/devices/${id}/address-lists`, data),
updateAddressListEntry: (id: number, entryId: string, data: Record<string, unknown>) =>
api.put<Record<string, string>[]>(`/devices/${id}/address-lists/${encodeURIComponent(entryId)}`, data),
removeAddressListEntry: (id: number, entryId: string) =>
api.delete(`/devices/${id}/address-lists/${encodeURIComponent(entryId)}`),
// Active connections (read-only)
getConnections: (id: number, limit = 500) =>
api.get<{ total: number; connections: Record<string, string>[]; tracking: Record<string, string> }>(`/devices/${id}/connections`, { params: { limit } }),
// Simple queues (bandwidth control)
getQueues: (id: number) => api.get<Record<string, string>[]>(`/devices/${id}/queues`),
addQueue: (id: number, data: Record<string, unknown>) =>
api.post<Record<string, string>[]>(`/devices/${id}/queues`, data),
updateQueue: (id: number, queueId: string, data: Record<string, unknown>) =>
api.put<Record<string, string>[]>(`/devices/${id}/queues/${encodeURIComponent(queueId)}`, data),
removeQueue: (id: number, queueId: string) =>
api.delete(`/devices/${id}/queues/${encodeURIComponent(queueId)}`),
// IP services + security posture
getServices: (id: number) => api.get<Record<string, string>[]>(`/devices/${id}/services`),
setServiceDisabled: (id: number, serviceId: string, disabled: boolean, confirmLockout = false) =>
api.put<Record<string, string>[]>(`/devices/${id}/services/${encodeURIComponent(serviceId)}`,
{ disabled, ...(confirmLockout ? { confirm_lockout: true } : {}) }),
listSecuritySuppressions: () =>
api.get<SecuritySuppression[]>('/devices/security/suppressions'),
suppressSecurityCheck: (check_id: string, device_id: number | null, reason?: string) =>
api.post('/devices/security/suppressions', { check_id, device_id, reason }),
unsuppressSecurityCheck: (id: number) =>
api.delete(`/devices/security/suppressions/${id}`),
getSecurityPosture: (id: number) =>
api.get<{ score: number; checks: SecurityCheck[] }>(`/devices/${id}/security-posture`),
// Config Health — cached findings load instantly; the scan re-reads the device.
getConfigHealth: (id: number) =>
api.get<ConfigHealthResponse>(`/devices/${id}/config-health`),
scanConfigHealth: (id: number) =>
api.post<ConfigHealthResponse>(`/devices/${id}/config-health/scan`),
patchLocation: (id: number, data: {
location_address?: string | null;
location_lat?: number | null;
location_lng?: number | null;
rack_name?: string | null;
rack_slot?: string | null;
notes?: string | null;
}) => api.patch<Device>(`/devices/${id}/location`, data),
};
// ─── Credential Presets ──────────────────────────────────────────────────────
export interface CredentialPreset {
id: number;
name: string;
api_username: string;
api_port: number | null;
ssh_username: string | null;
ssh_port: number | null;
notes: string | null;
/** When false, only admins may select this preset in Add Device flows. */
allow_operator_use: boolean;
has_api_password: boolean;
has_ssh_password: boolean;
created_at: string;
updated_at: string;
}
export interface CredentialPresetInput {
name?: string;
api_username?: string;
api_password?: string;
api_port?: number | null;
ssh_username?: string | null;
ssh_password?: string | null;
ssh_port?: number | null;
notes?: string | null;
clear_ssh_password?: boolean;
allow_operator_use?: boolean;
}
export const credentialPresetsApi = {
list: () => api.get<CredentialPreset[]>('/credential-presets'),
create: (data: CredentialPresetInput) =>
api.post<CredentialPreset>('/credential-presets', data),
update: (id: number, data: CredentialPresetInput) =>
api.put<CredentialPreset>(`/credential-presets/${id}`, data),
delete: (id: number) => api.delete(`/credential-presets/${id}`),
};
// ─── Clients ──────────────────────────────────────────────────────────────────
export interface ClientDetail extends Client {
device_type?: string;
ssid?: string;
vlan_name?: string;
upstream_device_id?: number;
upstream_interface?: string;
upstream_device_name?: string;
upstream_device_type?: string;
upstream_device_ip?: string;
comment?: string;
}
export interface PresencePoint { time: string; online: number; }
export interface TrafficPoint2 { time: string; tx_bytes: number; rx_bytes: number; }
export interface SignalPoint { time: string; signal_strength: number; }
export const clientsApi = {
list: (params?: {
deviceId?: number;
active?: boolean;
search?: string;
client_type?: string;
vlan_id?: number;
/** Inclusive lower / exclusive upper dBm bound, matching the density buckets. */
signal_min?: number;
signal_max?: number;
limit?: number;
offset?: number;
sort?: string;
dir?: 'asc' | 'desc';
}) =>
api.get<{ clients: Client[]; total: number }>('/clients', { params }),
get: (mac: string) => api.get<ClientDetail>(`/clients/${mac}`),
getPresence: (mac: string, range = '24h') =>
api.get<PresencePoint[]>(`/clients/${mac}/presence`, { params: { range } }),
getTraffic: (mac: string, range = '24h') =>
api.get<TrafficPoint2[]>(`/clients/${mac}/traffic`, { params: { range } }),
getSignal: (mac: string, range = '24h') =>
api.get<SignalPoint[]>(`/clients/${mac}/signal`, { params: { range } }),
wol: (mac: string) => api.post<{ success: boolean; message: string }>(`/clients/${mac}/wol`),
updateNotes: (mac: string, notes: string) =>
api.put<Client>(`/clients/${mac}/notes`, { notes }),
updateHostname: (mac: string, hostname: string) =>
api.put<Client>(`/clients/${mac}/hostname`, { hostname }),
updateCategory: (mac: string, category: string | null) =>
api.put<Client>(`/clients/${encodeURIComponent(mac)}/category`, { category }),
purgeStale: () => api.post<{ message: string; count: number }>('/clients/purge'),
};
// ─── Device Network Tools ─────────────────────────────────────────────────────
// Long timeout (120s) since traceroute/ip-scan can run for a while
const TOOL_TIMEOUT = 120_000;
export const deviceToolsApi = {
ping: (id: number, body: { address: string; count?: number; interface?: string }) =>
api.post<Record<string, string>[]>(`/devices/${id}/tools/ping`, body, { timeout: TOOL_TIMEOUT }),
traceroute: (id: number, body: { address: string }) =>
api.post<Record<string, string>[]>(`/devices/${id}/tools/traceroute`, body, { timeout: TOOL_TIMEOUT }),
ipScan: (id: number, body: { addressRange: string; interface: string; rdns?: boolean }) =>
api.post<Record<string, string>[]>(`/devices/${id}/tools/ip-scan`, body, { timeout: TOOL_TIMEOUT }),
wol: (id: number, body: { mac: string; interface: string }) =>
api.post<{ success: boolean; message: string }>(`/devices/${id}/tools/wol`, body, { timeout: TOOL_TIMEOUT }),
capture: (id: number, body: { interface?: string; filter_ip?: string; duration?: number }) =>
api.post(`/devices/${id}/tools/capture`, body, { timeout: 120_000, responseType: 'blob' }),
btest: (id: number, body: { target_device_id?: number; address?: string; direction?: string; duration?: number; protocol?: string }) =>
api.post<{ tx_mbps: number; rx_mbps: number; direction: string; protocol: string; duration: number; target_ip: string; raw: Record<string, string> | null }>(
`/devices/${id}/tools/btest`, body, { timeout: 75_000 }
),
};
// ─── System ───────────────────────────────────────────────────────────────────
export const systemApi = {
versionCheck: () =>
api.get<{ current: string; latest: string | null; update_available: boolean }>('/system/version-check'),
};
// ─── Events ───────────────────────────────────────────────────────────────────
export const eventsApi = {
topics: () => api.get<{ topic: string; count: number }[]>('/events/topics'),
list: (params?: {
deviceId?: number;
severity?: string;
topic?: string;
search?: string;
since?: string;
limit?: number;
offset?: number;
}) =>
api.get<{ events: DeviceEvent[]; total: number; criticalCount: number }>('/events', { params }),
clear: (deviceId?: number) => api.delete('/events', { params: deviceId ? { deviceId } : {} }),
};
// ─── Backups ──────────────────────────────────────────────────────────────────
// ─── Guest WiFi (Hotspot) ───────────────────────────────────────────────────────
type HS = Record<string, string>;
export interface GuestWifiOverview {
servers: HS[];
profiles: HS[];
userProfiles: HS[];
userCount: number;
activeCount: number;
interfaces: { name: string; type: string }[];
}
export const guestWifiApi = {
overview: (deviceId: number) =>
api.get<GuestWifiOverview>('/guest-wifi/overview', { params: { deviceId }, timeout: 60_000 }),
setup: (deviceId: number, data: {
name: string; gatewayCidr: string; poolRange: string; dnsName?: string; rateLimit?: string;
interfaceName?: string; ssid?: { ssid: string; passphrase?: string }; vlanId?: number; masquerade?: boolean;
}) =>
api.post<{
server: string; profile: string; userProfile: string; pool: string;
targetInterface: string; ssidInterfaces: string[]; vlanInterface?: string; warnings: string[];
}>('/guest-wifi/setup', { deviceId, ...data }, { timeout: 120_000 }),
users: (deviceId: number) => api.get<HS[]>('/guest-wifi/users', { params: { deviceId } }),
createVouchers: (deviceId: number, data: { count: number; durationHours?: number; dataCapMB?: number; userProfile?: string }) =>
api.post<{ created: number; codes: string[] }>('/guest-wifi/vouchers', { deviceId, ...data }, { timeout: 120_000 }),
deleteUser: (deviceId: number, id: string) =>
api.delete(`/guest-wifi/users/${encodeURIComponent(id)}`, { params: { deviceId } }),
active: (deviceId: number) => api.get<HS[]>('/guest-wifi/active', { params: { deviceId } }),
disconnect: (deviceId: number, id: string) =>
api.delete(`/guest-wifi/active/${encodeURIComponent(id)}`, { params: { deviceId } }),
walledGarden: (deviceId: number) => api.get<HS[]>('/guest-wifi/walled-garden', { params: { deviceId } }),
addWalledGarden: (deviceId: number, dstHost: string, comment?: string) =>
api.post('/guest-wifi/walled-garden', { deviceId, dstHost, comment }),
removeWalledGarden: (deviceId: number, id: string) =>
api.delete(`/guest-wifi/walled-garden/${encodeURIComponent(id)}`, { params: { deviceId } }),
setServerDisabled: (deviceId: number, id: string, disabled: boolean) =>
api.put(`/guest-wifi/servers/${encodeURIComponent(id)}`, { deviceId, disabled }),
removeServer: (deviceId: number, id: string) =>
api.delete(`/guest-wifi/servers/${encodeURIComponent(id)}`, { params: { deviceId } }),
};
// ─── Platform & automation ──────────────────────────────────────────────────────
export interface ApiToken {
id: number; name: string; prefix: string; scope: 'read' | 'write';
created_by: string | null; last_used_at: string | null; expires_at: string | null; created_at: string;
}
export interface Webhook {
id: number; name: string; url: string; has_secret: boolean;
events: string[]; enabled: boolean; last_status: number | null; last_fired_at: string | null; created_at: string;
}
export interface ReportSchedule {
id: number; name: string; frequency: 'daily' | 'weekly' | 'monthly';
recipients: string; enabled: boolean; last_sent_at: string | null; next_run_at: string; created_at: string;
}
export const automationApi = {
// tokens (admin only)
listTokens: () => api.get<ApiToken[]>('/automation/tokens'),
createToken: (data: { name: string; scope: 'read' | 'write'; expires_days?: number }) =>
api.post<{ id: number; token: string }>('/automation/tokens', data),
deleteToken: (id: number) => api.delete(`/automation/tokens/${id}`),
// webhooks
listWebhooks: () => api.get<{ webhooks: Webhook[]; availableEvents: string[] }>('/automation/webhooks'),
createWebhook: (data: { name: string; url: string; secret?: string; events: string[] }) =>
api.post<{ id: number }>('/automation/webhooks', data),
updateWebhook: (id: number, data: Partial<{ name: string; url: string; secret: string; events: string[]; enabled: boolean }>) =>
api.put(`/automation/webhooks/${id}`, data),
deleteWebhook: (id: number) => api.delete(`/automation/webhooks/${id}`),
testWebhook: (id: number) => api.post<{ ok: boolean; status: number }>(`/automation/webhooks/${id}/test`),
// reports
listReports: () => api.get<ReportSchedule[]>('/automation/reports'),
createReport: (data: { name: string; frequency: string; recipients: string }) =>
api.post<{ id: number }>('/automation/reports', data),
updateReport: (id: number, data: { enabled?: boolean }) => api.put(`/automation/reports/${id}`, data),
deleteReport: (id: number) => api.delete(`/automation/reports/${id}`),
sendReportNow: (id: number) => api.post(`/automation/reports/${id}/send-now`, undefined, { timeout: 60_000 }),
};
// ─── Firmware orchestration ─────────────────────────────────────────────────────
export interface FirmwareDeviceRow {
id: number; name: string; device_type: string; status: string; model: string | null;
ros_version: string | null; latest_ros_version: string | null;
firmware_update_available: boolean;
firmware_version: string | null; upgrade_firmware_version: string | null;
routerboard_upgrade_available: boolean;
}
export interface FirmwareRollout {
id: number; name: string; status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
halt_on_failure: boolean; pre_backup: boolean; routerboot_after: boolean;
scheduled_at: string | null; started_at: string | null; finished_at: string | null; created_at: string;
device_count?: number; success_count?: number; failed_count?: number;
}
export interface FirmwareRolloutDevice {
id: number; rollout_id: number; device_id: number; wave: number;
status: 'pending' | 'backing_up' | 'upgrading' | 'rebooting' | 'verifying' | 'success' | 'failed' | 'skipped';
from_version: string | null; to_version: string | null; error: string | null;
started_at: string | null; finished_at: string | null;
device_name: string; device_type: string; model: string | null;
}
export const firmwareApi = {
overview: () =>
api.get<{ devices: FirmwareDeviceRow[]; latestRolloutId: number | null; runningRolloutId: number | null }>('/firmware/overview'),
checkAll: () =>
api.post<{ results: { name: string; ok: boolean; installed?: string; latest?: string; available?: boolean; error?: string }[] }>(
'/firmware/check-all', undefined, { timeout: 180_000 }),
createRollout: (data: {
name: string; halt_on_failure: boolean; pre_backup: boolean; routerboot_after?: boolean;
scheduled_at?: string | null; start?: boolean;
devices: { device_id: number; wave: number }[];
}) => api.post<{ id: number }>('/firmware/rollouts', data),
listRollouts: () => api.get<FirmwareRollout[]>('/firmware/rollouts'),
getRollout: (id: number) =>
api.get<FirmwareRollout & { devices: FirmwareRolloutDevice[] }>(`/firmware/rollouts/${id}`),
startRollout: (id: number) => api.post(`/firmware/rollouts/${id}/start`),
cancelRollout: (id: number) => api.post(`/firmware/rollouts/${id}/cancel`),
changelog: (version: string) =>
api.get<{ version: string; url: string; text: string }>(`/firmware/changelog/${encodeURIComponent(version)}`),
};
// ─── Operations ────────────────────────────────────────────────────────────────
export interface OpsAttentionItem {
sev: 'error' | 'warn' | 'info';
category: string;
title: string;
body: string;
action: string;
path: string;
/** Stable identity used to dismiss an item that is otherwise recomputed. */
fingerprint?: string;
}
export interface OpsCapacityRow { id: number; name: string; cpu: number; mem_pct: number }
export interface OpsActivityItem {
at: string;
kind: 'config' | 'audit' | 'alert';
sev?: string;
title: string;
sub: string;
}
export interface OpsInsights {
attention: OpsAttentionItem[];
/** How many items are currently hidden by a dismissal. */
dismissedCount?: number;
capacity: OpsCapacityRow[];
activity: OpsActivityItem[];
}
export interface OpsActionResult { total: number; results: { name: string; ok: boolean; error?: string }[] }
export const operationsApi = {
insights: () => api.get<OpsInsights>('/operations/insights'),
dismissInsight: (fingerprint: string, hours: number, category?: string, title?: string) =>
api.post('/operations/insights/dismiss', { fingerprint, hours, category, title }),
listDismissed: () =>
api.get<{ fingerprint: string; category: string | null; title: string | null; expires_at: string }[]>(
'/operations/insights/dismissed'),
undismissInsight: (fingerprint: string) =>
api.delete(`/operations/insights/dismiss/${encodeURIComponent(fingerprint)}`),
backupAll: () => api.post<OpsActionResult>('/operations/backup-all', undefined, { timeout: 180_000 }),
syncAll: () => api.post<OpsActionResult>('/operations/sync-all', undefined, { timeout: 180_000 }),
};
export const backupsApi = {
list: (deviceId?: number) =>
api.get<Backup[]>('/backups', { params: deviceId ? { deviceId } : {} }),
create: (deviceId: number, notes?: string) =>
api.post<Backup>('/backups', { deviceId, notes }),
download: (id: number) =>
api.get(`/backups/${id}/download`, { responseType: 'blob' }),
restore: (id: number) => api.post(`/backups/${id}/restore`),
delete: (id: number) => api.delete(`/backups/${id}`),
};
// ─── Config History ────────────────────────────────────────────────────────────
export const configHistoryApi = {
list: (deviceId: number) =>
api.get<ConfigSnapshotMeta[]>(`/config-history/${deviceId}`),
get: (deviceId: number, id: number) =>
api.get<ConfigSnapshotFull>(`/config-history/${deviceId}/${id}`),
diff: (deviceId: number, fromId: number, toId: number) =>
api.get<ConfigDiffResponse>(`/config-history/${deviceId}/${fromId}/diff/${toId}`),
capture: (deviceId: number) =>
api.post<ConfigCaptureResult>(`/config-history/${deviceId}/capture`),
rollback: (deviceId: number, id: number) =>
api.post<{ message: string }>(`/config-history/${deviceId}/${id}/rollback`),
delete: (deviceId: number, id: number) =>
api.delete(`/config-history/${deviceId}/${id}`),
};
// ─── Metrics ─────────────────────────────────────────────────────────────────
export const metricsApi = {
summary: () => api.get<MetricsSummary>('/metrics/summary'),
clientsOverTime: (range = '24h') =>
api.get<TimeSeriesPoint[]>('/metrics/clients-over-time', { params: { range } }),
topClients: (limit = 10) =>
api.get<(Client & { total_bytes: number })[]>('/metrics/top-clients', { params: { limit } }),
interfaceTraffic: (deviceId: number, iface: string, range = '1h') =>
api.get<TrafficPoint[]>(`/metrics/interface/${deviceId}/${encodeURIComponent(iface)}`, {
params: { range },
}),
interfacePackets: (deviceId: number, iface: string, range = '1h') =>
api.get<TrafficPoint[]>(`/metrics/interface/${deviceId}/${encodeURIComponent(iface)}/packets`, {
params: { range },
}),
deviceResources: (deviceId: number, range = '24h') =>
api.get<ResourcePoint[]>(`/metrics/device/${deviceId}/resources`, { params: { range } }),
deviceAvailability: (deviceId: number, range = '30d') =>
api.get<DeviceAvailability>(`/metrics/device/${deviceId}/availability`, { params: { range } }),
devicePoe: (deviceId: number) =>
api.get<{ ports: { port: string; watts: number; current_ma: number; voltage_v: number }[]; totalWatts: number; history: { time: string; port: string; watts: number }[] }>(`/metrics/device/${deviceId}/poe`),
};
// ─── Topology ────────────────────────────────────────────────────────────────
export const topologyApi = {
get: () =>
api.get<{
devices: Device[];
links: import('../types').TopologyLink[];
externalNodes: import('../types').ExternalTopologyNode[];
segConns: { src: string; dst: string; port: string }[];
manualLinkIds: { id: number; from_device_id: number; to_device_id: number }[];
}>('/topology'),
discover: () => api.post('/topology/discover'),
createManualLink: (from_device_id: number, to_device_id: number, label?: string) =>
api.post<{ id: number; from_device_id: number; to_device_id: number; label: string | null }>(
'/topology/manual-links', { from_device_id, to_device_id, label }
),
deleteManualLink: (id: number) => api.delete(`/topology/manual-links/${id}`),
};
// ─── Search ──────────────────────────────────────────────────────────────────
export const searchApi = {
search: (q: string) =>
api.get<{
devices: {
id: number; name: string; ip_address: string;
model?: string; device_type: string; status: string;
}[];
clients: {
mac_address: string; hostname?: string; ip_address?: string;
device_id: number; device_name?: string; active: boolean;
}[];
events: {
id: number; message: string; severity: string;
event_time: string; topic?: string; device_name?: string;
}[];
}>('/search', { params: { q } }),
};
// ─── Routers ──────────────────────────────────────────────────────────────────
export const routersApi = {
overview: () => api.get<Record<string, unknown>[]>('/devices/routers/overview'),
getLldpStatus: () => api.get<{
id: number; name: string; ip_address: string;
enabled: boolean | null; protocol: string | null; error?: string;
}[]>('/routers/lldp'),
setLldp: (enabled: boolean) => api.put<{
applied: number; total: number;
results: { id: number; name: string; success: boolean; error?: string }[];
}>('/routers/lldp', { enabled }),
getSnmpStatus: () => api.get<{
id: number; name: string; ip_address: string;
enabled: boolean | null; community_name?: string; version?: string;
auth_protocol?: string; priv_protocol?: string;
contact?: string; location?: string; trap_target?: string;
error?: string;
}[]>('/routers/snmp'),
setSnmp: (config: {
enabled: boolean; community_name: string; version: 'v1' | 'v2c' | 'v3';
contact?: string; location?: string; trap_target?: string;
auth_protocol?: string; auth_password?: string;
priv_protocol?: string; priv_password?: string;
}) => api.put<{
applied: number; total: number;
results: { id: number; name: string; success: boolean; error?: string }[];
}>('/routers/snmp', config),
};
// ─── Routing Protocols (per-device) ───────────────────────────────────────────
Object.assign(devicesApi, {
getOspf: (id: number) => api.get<Record<string, unknown>>(`/devices/${id}/routing/ospf`),
addOspfInstance: (id: number, data: Record<string, unknown>) =>
api.post(`/devices/${id}/routing/ospf/instance`, data),
removeOspfInstance: (id: number, itemId: string) =>
api.delete(`/devices/${id}/routing/ospf/instance/${encodeURIComponent(itemId)}`),
addOspfArea: (id: number, data: Record<string, unknown>) =>
api.post(`/devices/${id}/routing/ospf/area`, data),
removeOspfArea: (id: number, itemId: string) =>
api.delete(`/devices/${id}/routing/ospf/area/${encodeURIComponent(itemId)}`),
getBgp: (id: number) => api.get<Record<string, unknown>>(`/devices/${id}/routing/bgp`),
addBgpConnection: (id: number, data: Record<string, unknown>) =>
api.post(`/devices/${id}/routing/bgp/connection`, data),
removeBgpConnection: (id: number, itemId: string) =>
api.delete(`/devices/${id}/routing/bgp/connection/${encodeURIComponent(itemId)}`),
getRoutingTables: (id: number) =>
api.get<Record<string, string>[]>(`/devices/${id}/routing/tables`),
addRoutingTable: (id: number, data: { name: string; fib?: boolean }) =>
api.post(`/devices/${id}/routing/tables`, data),
removeRoutingTable: (id: number, itemId: string) =>
api.delete(`/devices/${id}/routing/tables/${encodeURIComponent(itemId)}`),
getRouteFilters: (id: number) =>
api.get<Record<string, unknown>>(`/devices/${id}/routing/filters`),
addFilterRule: (id: number, data: Record<string, unknown>) =>
api.post(`/devices/${id}/routing/filters/rule`, data),
updateFilterRule: (id: number, itemId: string, data: Record<string, unknown>) =>
api.put(`/devices/${id}/routing/filters/rule/${encodeURIComponent(itemId)}`, data),
removeFilterRule: (id: number, itemId: string) =>
api.delete(`/devices/${id}/routing/filters/rule/${encodeURIComponent(itemId)}`),
getRouterIds: (id: number) =>
api.get<Record<string, string>[]>(`/devices/${id}/routing/router-id`),
});
// ─── Switches ─────────────────────────────────────────────────────────────────
export const switchesApi = {
list: () => api.get<Record<string, unknown>[]>('/switches'),
getLldpStatus: () => api.get<{
id: number; name: string; ip_address: string;
enabled: boolean | null; protocol: string | null; error?: string;
}[]>('/switches/lldp'),
setLldp: (enabled: boolean) => api.put<{
applied: number; total: number;
results: { id: number; name: string; success: boolean; error?: string }[];
}>('/switches/lldp', { enabled }),
getSnmpStatus: () => api.get<{
id: number; name: string; ip_address: string;
enabled: boolean | null; community_name?: string; version?: string;
auth_protocol?: string; priv_protocol?: string;
contact?: string; location?: string; trap_target?: string;
error?: string;
}[]>('/switches/snmp'),
setSnmp: (config: {
enabled: boolean; community_name: string; version: 'v1' | 'v2c' | 'v3';
contact?: string; location?: string; trap_target?: string;
auth_protocol?: string; auth_password?: string;
priv_protocol?: string; priv_password?: string;
}) => api.put<{
applied: number; total: number;
results: { id: number; name: string; success: boolean; error?: string }[];
}>('/switches/snmp', config),
};
// ─── Alerts ──────────────────────────────────────────────────────────────────
export interface AlertRule {
event_type: string;
enabled: boolean;
threshold: number | null;
cooldown_min: number;
updated_at: string;
}
export interface AlertChannel {
id: number;
name: string;
type: 'email' | 'slack' | 'discord' | 'telegram' | 'ntfy';
enabled: boolean;
config: Record<string, unknown>;
created_at: string;
updated_at: string;
}
export interface AlertHistoryEntry {
id: number;
event_type: string;
device_id: number | null;
device_name: string | null;
message: string;
channels_notified: string[];
sent_at: string;
}
export const alertsApi = {
getRules: () => api.get<AlertRule[]>('/alerts/rules'),
updateRule: (type: string, data: Partial<Pick<AlertRule, 'enabled' | 'threshold' | 'cooldown_min'>>) =>
api.put<AlertRule>(`/alerts/rules/${encodeURIComponent(type)}`, data),
getChannels: () => api.get<AlertChannel[]>('/alerts/channels'),
createChannel: (data: { name: string; type: AlertChannel['type']; enabled?: boolean; config?: Record<string, unknown> }) =>
api.post<AlertChannel>('/alerts/channels', data),
updateChannel: (id: number, data: Partial<Pick<AlertChannel, 'name' | 'enabled' | 'config'>>) =>
api.put<AlertChannel>(`/alerts/channels/${id}`, data),
deleteChannel: (id: number) => api.delete(`/alerts/channels/${id}`),
testChannel: (id: number) => api.post<{ message: string }>(`/alerts/channels/${id}/test`),
getHistory: (limit = 50) => api.get<AlertHistoryEntry[]>('/alerts/history', { params: { limit } }),
};
// ─── Settings ────────────────────────────────────────────────────────────────
export const settingsApi = {
get: () => api.get<Record<string, unknown>>('/settings'),
update: (data: Record<string, unknown>) => api.put('/settings', data),
getUsers: () => api.get('/settings/users'),
createUser: (data: { username: string; password: string; role?: string }) =>
api.post('/settings/users', data),
updateUser: (id: number, data: { role?: string; password?: string }) =>
api.put(`/settings/users/${id}`, data),
deleteUser: (id: number) => api.delete(`/settings/users/${id}`),
};
export interface CertInfo {
exists: boolean;
subject?: string;
subject_full?: string;
issuer?: string;
issuer_full?: string;
serial_number?: string;
valid_from?: string;
valid_to?: string;
days_remaining?: number;
is_self_signed?: boolean;
san?: string | null;
}
export const certApi = {
get: () => api.get<CertInfo>('/cert'),
regenerate: () => api.post<CertInfo & { message: string }>('/cert/regenerate'),
upload: (certificate: string, private_key: string) =>
api.post<CertInfo & { message: string }>('/cert/upload', { certificate, private_key }),
};
// ─── Wireless ─────────────────────────────────────────────────────────────────
/**
* A CAPsMAN controller and the access points it provisions. Read-only: the
* configuration lives on the controller and pushing an edit would reach every bound
* AP at once, so that is deliberately not exposed yet (issue #94).
*/
export interface CapsmanRadio {
radio_mac: string;
interface_name: string | null;
local: boolean;
hw_type: string | null;
/** Operating channel from /interface/wifi/monitor, not the supported-channel list. */
current_channel: string | null;
state: string | null;
/** SSID the controller provisioned onto this radio; the CAP itself does not know it. */
ssid: string | null;
registered_peers: number | null;
authorized_peers: number | null;
tx_power: number | null;
remote_cap_name: string | null;
matched_device_id: number | null;
matched_device_name: string | null;
matched_device_status: string | null;
}
export interface CapsmanAccessPoint {
device_id: number | null;
device_name: string;
radios: CapsmanRadio[];
/** Clients as the controller sees them; a CAP's own table is empty under central forwarding. */
client_count: number;
}
export interface CapsmanConfiguration {
name: string;
ssid: string | null;
mode: string | null;
band: string | null;
security: string | null;
authentication_types: string | null;
/** Provisioning rules that apply this configuration. */
rules: CapsmanProvisioning[];
}
export interface CapsmanProvisioning {
ros_id: string;
action: string | null;
master_configuration: string | null;
slave_configurations: string | null;
radio_mac: string | null;
comment: string | null;
disabled: boolean;
}
export interface CapsmanSsid {
device_id: number;
device_name: string;
name: string;
/** Radio this interface sits on, used to join live channel and client counts. */
radio_mac: string | null;
ssid: string | null;
band: string | null;
frequency: number | null;
disabled: boolean;
running: boolean;
registered_clients: number | null;
}
export interface CapsmanController {
id: number;
name: string;
ip_address: string;
model: string | null;
status: string;
wifi_role: string | null;
client_count: number;
access_points: CapsmanAccessPoint[];
configurations: CapsmanConfiguration[];
provisioning: CapsmanProvisioning[];
ssids: CapsmanSsid[];
}
export const wirelessApi = {
// Section overview
list: () => api.get('/wireless'),
// CAPsMAN controllers with the APs they provision (read-only)
capsman: () => api.get<{ controllers: CapsmanController[] }>('/wireless/capsman'),
// Multi-AP SSID deployment
bulkCreateInterfaces: (data: { apIds: number[] } & Record<string, unknown>) =>
api.post('/wireless/ssid/bulk', data),
// Per-AP: wireless interfaces (SSIDs)
getInterfaces: (apId: number) => api.get(`/wireless/${apId}/interfaces`),
createInterface: (apId: number, data: Record<string, unknown>) =>
api.post(`/wireless/${apId}/interfaces`, data),
updateInterface: (apId: number, name: string, data: Record<string, unknown>) =>
api.put(`/wireless/${apId}/interfaces/${encodeURIComponent(name)}`, data),
deleteInterface: (apId: number, name: string) =>
api.delete(`/wireless/${apId}/interfaces/${encodeURIComponent(name)}`),
// Per-AP: security profiles
getSecurityProfiles: (apId: number) => api.get(`/wireless/${apId}/security-profiles`),
createSecurityProfile: (apId: number, data: Record<string, unknown>) =>
api.post(`/wireless/${apId}/security-profiles`, data),
updateSecurityProfile: (apId: number, name: string, data: Record<string, unknown>) =>
api.put(`/wireless/${apId}/security-profiles/${encodeURIComponent(name)}`, data),
deleteSecurityProfile: (apId: number, name: string) =>
api.delete(`/wireless/${apId}/security-profiles/${encodeURIComponent(name)}`),
// Hardware radio capabilities (wifi package)
getRadios: (apId: number) => api.get(`/wireless/${apId}/radios`),
// Bridge list + port memberships
getBridges: (apId: number) => api.get(`/wireless/${apId}/bridges`),
// Radio monitoring
getRegistrationTable: (apId: number) => api.get(`/wireless/${apId}/registration-table`),
getMonitor: (apId: number, iface: string) =>
api.get(`/wireless/${apId}/monitor/${encodeURIComponent(iface)}`),
scan: (apId: number, iface: string) =>
api.get(`/wireless/${apId}/scan/${encodeURIComponent(iface)}`),
// Cached wireless data (for device detail Radios tab)
getCachedInterfaces: (deviceId: number) => api.get(`/devices/${deviceId}/wireless`),
getMetrics: (deviceId: number, iface?: string, range?: string) =>
api.get(`/devices/${deviceId}/wireless/metrics`, { params: { iface, range } }),
// Spectral scan
runSpectralScan: (apId: number, iface: string) =>
api.post(`/wireless/${apId}/spectral-scan/${encodeURIComponent(iface)}`),
getSpectralHistory: (apId: number, iface: string, limit = 5) =>
api.get(`/wireless/${apId}/spectral-history/${encodeURIComponent(iface)}`, { params: { limit } }),
// AP scan
runAPScan: (apId: number) => api.post(`/wireless/${apId}/ap-scan`),
getAPScanHistory: (apId: number, limit = 5) =>
api.get(`/wireless/${apId}/ap-scan-history`, { params: { limit } }),
// Rogue / neighbor AP detection (classified from stored AP scans)
getRogueAps: () =>
api.get<import('../types').RogueApReport>('/wireless/rf/rogue'),
// RF Health (fleet-wide, or scoped to one AP via deviceId)
getChannelUsage: (deviceId?: number) =>
api.get<import('../types').RfChannelUsage>('/wireless/rf/channels', { params: { deviceId } }),
getRfCapabilities: () =>
api.get<{ packages: Record<string, number>; legacyRadios: number; txRetriesAvailable: boolean }>(
'/wireless/rf/capabilities'),
getClientSignals: (deviceId?: number) =>
api.get<import('../types').RfSignalRow[]>('/wireless/rf/signals', { params: { deviceId } }),
getTxQuality: (deviceId?: number, range = '6h') =>
api.get<import('../types').RfTxQualityRow[]>('/wireless/rf/tx-quality', { params: { deviceId, range } }),
getConnectivity: (deviceId?: number, range = '24h') =>
api.get<import('../types').RfConnectivity>('/wireless/rf/connectivity', { params: { deviceId, range } }),
};
// ─── Network Services ─────────────────────────────────────────────────────────
type NS = Record<string, string>;
export const networkServicesApi = {
// Overview
overview: () =>
api.get<Record<string, unknown>[]>('/network-services/overview', { timeout: 60_000 }),
// ── DHCP ──────────────────────────────────────────────────────────────────
getDhcp: (deviceId: number) =>
api.get<{ ipv4: NS[]; ipv6: NS[]; pools_v4: NS[]; pools_v6: NS[]; interfaces: NS[] }>(
'/network-services/dhcp', { params: { deviceId } }
),
addDhcpServer: (deviceId: number, body: NS & { protocol: 'ipv4' | 'ipv6' }) =>
api.post<NS[]>('/network-services/dhcp/server', body, { params: { deviceId } }),
updateDhcpServer: (deviceId: number, id: string, body: NS & { protocol: 'ipv4' | 'ipv6' }) =>
api.put<NS[]>(`/network-services/dhcp/server/${encodeURIComponent(id)}`, body, { params: { deviceId } }),
deleteDhcpServer: (deviceId: number, id: string, protocol: 'ipv4' | 'ipv6') =>
api.delete(`/network-services/dhcp/server/${encodeURIComponent(id)}`, { params: { deviceId, protocol } }),
toggleDhcpServer: (deviceId: number, serverId: string, disabled: boolean, protocol: 'ipv4' | 'ipv6') =>
api.put('/network-services/dhcp/server', { serverId, disabled, protocol }, { params: { deviceId } }),
addDhcpPool: (deviceId: number, body: NS & { protocol: 'ipv4' | 'ipv6' }) =>
api.post<NS[]>('/network-services/dhcp/pool', body, { params: { deviceId } }),
deleteDhcpPool: (deviceId: number, id: string, protocol: 'ipv4' | 'ipv6') =>
api.delete(`/network-services/dhcp/pool/${encodeURIComponent(id)}`, { params: { deviceId, protocol } }),
getLeases: (deviceId: number, protocol: 'ipv4' | 'ipv6') =>
api.get<NS[]>('/network-services/dhcp/leases', { params: { deviceId, protocol } }),
addStaticLease: (deviceId: number, body: NS & { protocol: 'ipv4' | 'ipv6' }) =>
api.post('/network-services/dhcp/static-lease', body, { params: { deviceId } }),
deleteStaticLease: (deviceId: number, id: string, protocol: 'ipv4' | 'ipv6') =>
api.delete(`/network-services/dhcp/static-lease/${encodeURIComponent(id)}`, { params: { deviceId, protocol } }),
// ── DNS ───────────────────────────────────────────────────────────────────
getDns: (deviceId: number) =>
api.get<{ settings: NS; statics: NS[] }>('/network-services/dns', { params: { deviceId } }),
setDns: (deviceId: number, settings: {
servers?: string; allow_remote_requests?: boolean;
max_udp_packet_size?: string; cache_size?: string; cache_max_ttl?: string;
}) =>
api.put<NS>('/network-services/dns', settings, { params: { deviceId } }),
flushDns: (deviceId: number) =>
api.post('/network-services/dns/flush', {}, { params: { deviceId } }),
addDnsStatic: (deviceId: number, body: NS) =>
api.post<NS[]>('/network-services/dns/static', body, { params: { deviceId } }),
updateDnsStatic: (deviceId: number, id: string, body: NS) =>
api.put<NS[]>(`/network-services/dns/static/${encodeURIComponent(id)}`, body, { params: { deviceId } }),
deleteDnsStatic: (deviceId: number, id: string) =>
api.delete(`/network-services/dns/static/${encodeURIComponent(id)}`, { params: { deviceId } }),
// ── NTP ───────────────────────────────────────────────────────────────────
getNtp: (deviceId: number) =>
api.get<{ server: NS; client: NS }>('/network-services/ntp', { params: { deviceId } }),
setNtp: (deviceId: number, settings: {
server_enabled?: boolean; server_broadcast?: boolean; server_manycast?: boolean;
client_enabled?: boolean; client_mode?: string; client_servers?: string;
}) =>
api.put<{ server: NS; client: NS }>('/network-services/ntp', settings, { params: { deviceId } }),
// ── Syslog ────────────────────────────────────────────────────────────────
getSyslog: (deviceId: number) =>
api.get<{ actions: NS[]; rules: NS[] }>('/network-services/syslog', { params: { deviceId } }),
addSyslogAction: (deviceId: number, body: NS) =>
api.post<NS[]>('/network-services/syslog/action', body, { params: { deviceId } }),
updateSyslogAction: (deviceId: number, id: string, body: NS) =>
api.put<NS[]>(`/network-services/syslog/action/${encodeURIComponent(id)}`, body, { params: { deviceId } }),
deleteSyslogAction: (deviceId: number, id: string) =>
api.delete(`/network-services/syslog/action/${encodeURIComponent(id)}`, { params: { deviceId } }),
addSyslogRule: (deviceId: number, body: NS) =>
api.post<NS[]>('/network-services/syslog/rule', body, { params: { deviceId } }),
updateSyslogRule: (deviceId: number, id: string, body: NS) =>
api.put<NS[]>(`/network-services/syslog/rule/${encodeURIComponent(id)}`, body, { params: { deviceId } }),
deleteSyslogRule: (deviceId: number, id: string) =>
api.delete(`/network-services/syslog/rule/${encodeURIComponent(id)}`, { params: { deviceId } }),
toggleSyslogRule: (deviceId: number, ruleId: string, disabled: boolean) =>
api.put('/network-services/syslog/rule/toggle', { ruleId, disabled }, { params: { deviceId } }),
// ── WireGuard ─────────────────────────────────────────────────────────────
getWireGuard: (deviceId: number) =>
api.get<{ interfaces: NS[]; peers: NS[] }>('/network-services/wireguard', { params: { deviceId } }),
addWireGuardInterface: (deviceId: number, body: NS) =>
api.post<NS[]>('/network-services/wireguard', body, { params: { deviceId } }),
updateWireGuardInterface: (deviceId: number, id: string, body: NS) =>
api.put<NS[]>(`/network-services/wireguard/${encodeURIComponent(id)}`, body, { params: { deviceId } }),
deleteWireGuardInterface: (deviceId: number, id: string) =>
api.delete(`/network-services/wireguard/${encodeURIComponent(id)}`, { params: { deviceId } }),
toggleWireGuard: (deviceId: number, interfaceId: string, disabled: boolean) =>
api.put('/network-services/wireguard/toggle', { interfaceId, disabled }, { params: { deviceId } }),
addWireGuardPeer: (deviceId: number, body: NS) =>
api.post<NS[]>('/network-services/wireguard/peer', body, { params: { deviceId } }),
updateWireGuardPeer: (deviceId: number, id: string, body: NS) =>
api.put<NS[]>(`/network-services/wireguard/peer/${encodeURIComponent(id)}`, body, { params: { deviceId } }),
deleteWireGuardPeer: (deviceId: number, id: string) =>
api.delete(`/network-services/wireguard/peer/${encodeURIComponent(id)}`, { params: { deviceId } }),
// ── NetFlow ───────────────────────────────────────────────────────────────
netflowFleet: () =>
api.get<{
collector: { address: string; port: number; version: string; listening: boolean };
devices: NetflowDeviceState[];
}>('/network-services/netflow/fleet', { timeout: 60_000 }),
getNetflow: (deviceId: number) =>
api.get<{ settings: NS | null; targets: NS[]; target_matches_collector: boolean }>(
'/network-services/netflow', { params: { deviceId } }
),
setNetflow: (deviceId: number, enabled: boolean) =>
api.put<{ settings: NS | null; targets: NS[]; target_matches_collector: boolean }>(
'/network-services/netflow', { enabled }, { params: { deviceId } }
),
};
export interface NetflowDeviceState {
id: number;
name: string;
ip_address: string;
enabled: boolean | null;
interfaces: string;
targets: { id: string; dst_address: string; port: number; version: string; disabled: boolean }[];
target_matches_collector: boolean;
flows_received: number;
last_flow_at: string | null;
error?: string;
}
// ─── Traffic Analytics (NetFlow) ──────────────────────────────────────────────
export interface ClientTrafficPoint { time: string; upload: number; download: number; uploadPackets?: number; downloadPackets?: number }
export interface TrafficTopClient {
mac: string;
upload_bytes: number;
download_bytes: number;
total_bytes: number;
hostname: string | null;
custom_name: string | null;
vendor: string | null;
ip_address: string | null;
}
export interface TrafficApp { app: string; bytes: number; packets?: number }
export interface TrafficCollectorStats {
listening: boolean;
port: number;
packetsReceived: number;
flowsDecoded: number;
flowsAttributed: number;
packetsFromUnknownExporter: number;
recordsWithoutTemplate: number;
exporters: { deviceId: number; deviceName: string; packets: number; flows: number; lastSeen: string | null }[];
}
export const trafficApi = {
status: () => api.get<TrafficCollectorStats>('/traffic/status'),
timeseries: (range = '24h') =>
api.get<ClientTrafficPoint[]>('/traffic/timeseries', { params: { range } }),
topClients: (range = '24h', limit = 10) =>
api.get<TrafficTopClient[]>('/traffic/top-clients', { params: { range, limit } }),
apps: (range = '24h', mac?: string) =>
api.get<TrafficApp[]>('/traffic/apps', { params: { range, mac } }),
client: (mac: string, range = '24h') =>
api.get<{ series: ClientTrafficPoint[]; apps: TrafficApp[] }>(
`/traffic/client/${encodeURIComponent(mac)}`, { params: { range } }
),
};
// ─── Maintenance Windows ──────────────────────────────────────────────────────
export interface MaintenanceWindow {
id: number;
name: string;
device_ids: number[];
start_at: string;
end_at: string;
recurring_cron: string | null;
active: boolean;
created_at: string;
}
export const maintenanceApi = {
list: () => api.get<MaintenanceWindow[]>('/maintenance-windows'),
create: (data: Omit<MaintenanceWindow, 'id' | 'created_at'>) =>
api.post<MaintenanceWindow>('/maintenance-windows', data),
update: (id: number, data: Partial<MaintenanceWindow>) =>
api.put<MaintenanceWindow>(`/maintenance-windows/${id}`, data),
delete: (id: number) => api.delete(`/maintenance-windows/${id}`),
};
// ─── Tags ────────────────────────────────────────────────────────────────────
export const tagsApi = {
list: () => api.get<import('../types').Tag[]>('/tags'),
create: (name: string, color: string) => api.post<import('../types').Tag>('/tags', { name, color }),
update: (id: number, data: { name?: string; color?: string }) => api.put<import('../types').Tag>(`/tags/${id}`, data),
delete: (id: number) => api.delete(`/tags/${id}`),
assignDevices: (tagId: number, deviceIds: number[], action: 'add' | 'remove') =>
api.post(`/tags/${tagId}/devices`, { deviceIds, action }),
deviceTags: (deviceId: number) => api.get<import('../types').Tag[]>(`/tags/device/${deviceId}`),
};
// ─── Audit Log ───────────────────────────────────────────────────────────────
export const auditLogApi = {
list: (params: {
page?: number; limit?: number; userId?: number; entityType?: string;
search?: string; from?: string; to?: string;
} = {}) =>
api.get<{
rows: {
id: number; user_id: number | null; username: string | null;
method: string; path: string; entity_type: string | null;
entity_id: number | null; summary: string; ip_address: string | null;
status_code: number | null; created_at: string;
}[];
total: number; page: number; limit: number;
}>('/audit-log', { params }),
};
export interface ConfigTemplate {
id: number;
name: string;
description: string | null;
applies_to_type: string | null;
template_json: {
dns_servers?: string[];
ntp_servers?: string[];
syslog_host?: string;
};
created_at: string;
updated_at: string;
}
export const configTemplatesApi = {
list: () => api.get<ConfigTemplate[]>('/config-templates'),
get: (id: number) => api.get<ConfigTemplate>(`/config-templates/${id}`),
create: (data: Partial<ConfigTemplate>) => api.post<ConfigTemplate>('/config-templates', data),
update: (id: number, data: Partial<ConfigTemplate>) => api.put<ConfigTemplate>(`/config-templates/${id}`, data),
delete: (id: number) => api.delete(`/config-templates/${id}`),
apply: (id: number, device_ids: number[]) =>
api.post<{ results: { device_id: number; device_name: string; ok: boolean; error?: string }[] }>(
`/config-templates/${id}/apply`,
{ device_ids }
),
};
export default api;