Files
the-other-dude/frontend/src/lib/api.ts
Jason Staack 7dc941e467 fix(ui): fix credential profile field names and list response unwrapping
- credentialProfilesApi.list now unwraps {profiles: [...]} wrapper
- CredentialProfilesPage handles both array and wrapped responses
- Renamed privacy_protocol → priv_protocol to match backend schema
- Renamed privacy_passphrase → priv_passphrase to match backend
- Renamed security_name → username (SNMPv3 uses username field)
- Removed security_name from CredentialProfileCreate type

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 07:50:35 -05:00

1879 lines
52 KiB
TypeScript

import axios, { type AxiosInstance, type InternalAxiosRequestConfig } from 'axios'
const BASE_URL = import.meta.env.VITE_API_URL ?? ''
// Singleton to track in-flight refresh to prevent multiple simultaneous refresh calls
let refreshPromise: Promise<void> | null = null
function createApiClient(): AxiosInstance {
const client = axios.create({
baseURL: BASE_URL,
withCredentials: true, // Send httpOnly cookies automatically
headers: {
'Content-Type': 'application/json',
},
})
// Response interceptor: handle 401 by attempting token refresh
client.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true
// Don't try to refresh if this IS the refresh request or login request
const url = originalRequest.url ?? ''
if (url.includes('/auth/refresh') || url.includes('/auth/login')) {
return Promise.reject(error as Error)
}
if (!refreshPromise) {
refreshPromise = client
.post('/api/auth/refresh')
.then(() => {
refreshPromise = null
})
.catch(() => {
refreshPromise = null
return Promise.reject(error as Error)
})
}
try {
await refreshPromise
return client(originalRequest)
} catch {
return Promise.reject(error as Error)
}
}
return Promise.reject(error as Error)
},
)
return client
}
export const api = createApiClient()
// ─── Auth ────────────────────────────────────────────────────────────────────
export interface LoginRequest {
email: string
password: string
}
export interface TokenResponse {
access_token: string
refresh_token: string
token_type: string
auth_upgrade_required?: boolean
}
export interface UserMe {
id: string
email: string
name: string
role: string
tenant_id: string | null
auth_version: number
}
export interface MessageResponse {
message: string
}
// SRP Authentication types
export interface SRPInitResponse {
salt: string
server_public: string
session_id: string
pbkdf2_salt: string // base64-encoded, from user_key_sets
hkdf_salt: string // base64-encoded, from user_key_sets
}
export interface SRPVerifyResponse {
access_token: string
refresh_token: string
token_type: string
server_proof: string
encrypted_key_set: {
encrypted_private_key: string // base64
private_key_nonce: string // base64
encrypted_vault_key: string // base64
vault_key_nonce: string // base64
public_key: string // base64
pbkdf2_salt: string // base64
hkdf_salt: string // base64
pbkdf2_iterations: number
} | null
}
export const authApi = {
login: (data: LoginRequest) =>
api.post<TokenResponse>('/api/auth/login', data).then((r) => r.data),
logout: () => api.post('/api/auth/logout').then((r) => r.data),
me: () => api.get<UserMe>('/api/auth/me').then((r) => r.data),
refresh: () => api.post<TokenResponse>('/api/auth/refresh').then((r) => r.data),
forgotPassword: (email: string) =>
api.post<MessageResponse>('/api/auth/forgot-password', { email }).then((r) => r.data),
resetPassword: (token: string, newPassword: string) =>
api
.post<MessageResponse>('/api/auth/reset-password', {
token,
new_password: newPassword,
})
.then((r) => r.data),
srpInit: async (email: string): Promise<SRPInitResponse> => {
const { data } = await api.post<SRPInitResponse>('/api/auth/srp/init', { email })
return data
},
srpVerify: async (params: {
email: string
session_id: string
client_public: string
client_proof: string
}): Promise<SRPVerifyResponse> => {
const { data } = await api.post<SRPVerifyResponse>('/api/auth/srp/verify', params)
return data
},
registerSRP: async (params: {
srp_salt: string
srp_verifier: string
encrypted_private_key: string
private_key_nonce: string
encrypted_vault_key: string
vault_key_nonce: string
public_key: string
pbkdf2_salt: string
hkdf_salt: string
}): Promise<MessageResponse> => {
const { data } = await api.post<MessageResponse>('/api/auth/register-srp', params)
return data
},
getEmergencyKitPDF: async (): Promise<Blob> => {
const { data } = await api.get('/api/auth/emergency-kit-template', {
responseType: 'blob',
})
return data as Blob
},
changePassword: async (params: {
current_password: string
new_password: string
new_srp_salt?: string
new_srp_verifier?: string
encrypted_private_key?: string
private_key_nonce?: string
encrypted_vault_key?: string
vault_key_nonce?: string
public_key?: string
pbkdf2_salt?: string
hkdf_salt?: string
}): Promise<MessageResponse> => {
const { data } = await api.post<MessageResponse>('/api/auth/change-password', params)
return data
},
deleteMyAccount: async (confirmation: string): Promise<MessageResponse> => {
const { data } = await api.delete<MessageResponse>('/api/auth/delete-my-account', {
data: { confirmation },
})
return data
},
exportMyData: async (): Promise<void> => {
const response = await api.get('/api/auth/export-my-data', {
responseType: 'blob',
})
const blob = new Blob([response.data as BlobPart], { type: 'application/json' })
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'my-data-export.json'
a.click()
window.URL.revokeObjectURL(url)
},
}
// ─── Tenants ─────────────────────────────────────────────────────────────────
export interface TenantResponse {
id: string
name: string
description: string | null
contact_email: string | null
user_count: number
device_count: number
created_at: string
}
export interface TenantCreate {
name: string
description?: string
contact_email?: string
}
export const tenantsApi = {
list: () => api.get<TenantResponse[]>('/api/tenants').then((r) => r.data),
get: (id: string) => api.get<TenantResponse>(`/api/tenants/${id}`).then((r) => r.data),
create: (data: TenantCreate) =>
api.post<TenantResponse>('/api/tenants', data).then((r) => r.data),
update: (id: string, data: Partial<TenantCreate>) =>
api.put<TenantResponse>(`/api/tenants/${id}`, data).then((r) => r.data),
delete: (id: string) => api.delete(`/api/tenants/${id}`).then((r) => r.data),
}
// ─── Users ────────────────────────────────────────────────────────────────────
export interface UserResponse {
id: string
name: string
email: string
role: string
tenant_id: string | null
is_active: boolean
last_login: string | null
created_at: string
}
export interface UserCreate {
name: string
email: string
password: string
role: string
}
export const usersApi = {
list: (tenantId: string) =>
api.get<UserResponse[]>(`/api/tenants/${tenantId}/users`).then((r) => r.data),
create: (tenantId: string, data: UserCreate) =>
api.post<UserResponse>(`/api/tenants/${tenantId}/users`, data).then((r) => r.data),
update: (tenantId: string, userId: string, data: Partial<UserCreate>) =>
api
.put<UserResponse>(`/api/tenants/${tenantId}/users/${userId}`, data)
.then((r) => r.data),
deactivate: (tenantId: string, userId: string) =>
api.delete(`/api/tenants/${tenantId}/users/${userId}`).then((r) => r.data),
}
// ─── Devices ─────────────────────────────────────────────────────────────────
export interface DeviceTagRef {
id: string
name: string
color: string | null
}
export interface DeviceGroupRef {
id: string
name: string
}
export interface DeviceResponse {
id: string
hostname: string
ip_address: string
api_port: number
api_ssl_port: number
model: string | null
serial_number: string | null
firmware_version: string | null
routeros_version: string | null
uptime_seconds: number | null
last_seen: string | null
latitude: number | null
longitude: number | null
status: string
tls_mode: string
device_type: string // "routeros" | "snmp"
snmp_port: number | null
snmp_version: string | null
snmp_profile_id: string | null
credential_profile_id: string | null
board_name: string | null
tags: DeviceTagRef[]
groups: DeviceGroupRef[]
site_id: string | null
site_name: string | null
sector_id: string | null
sector_name: string | null
created_at: string
}
export interface DeviceListResponse {
items: DeviceResponse[]
total: number
page: number
page_size: number
}
export interface DeviceCreate {
hostname: string
ip_address: string
device_type?: 'routeros' | 'snmp'
api_port?: number
api_ssl_port?: number
username?: string
password?: string
credential_profile_id?: string
snmp_version?: 'v2c' | 'v3'
snmp_port?: number
snmp_profile_id?: string
}
export interface DeviceUpdate {
hostname?: string
ip_address?: string
api_port?: number
api_ssl_port?: number
username?: string
password?: string
latitude?: number | null
longitude?: number | null
tls_mode?: string
}
export interface DeviceListParams {
page?: number
page_size?: number
sort_by?: string
sort_dir?: 'asc' | 'desc'
search?: string
status?: string
device_type?: string // "routeros" | "snmp"
model?: string
tag?: string
site_id?: string
sector_id?: string
}
export const devicesApi = {
list: (tenantId: string, params?: DeviceListParams) =>
api
.get<DeviceListResponse>(`/api/tenants/${tenantId}/devices`, { params })
.then((r) => r.data),
get: (tenantId: string, deviceId: string) =>
api
.get<DeviceResponse>(`/api/tenants/${tenantId}/devices/${deviceId}`)
.then((r) => r.data),
create: (tenantId: string, data: DeviceCreate) =>
api
.post<DeviceResponse>(`/api/tenants/${tenantId}/devices`, data)
.then((r) => r.data),
update: (tenantId: string, deviceId: string, data: DeviceUpdate) =>
api
.put<DeviceResponse>(`/api/tenants/${tenantId}/devices/${deviceId}`, data)
.then((r) => r.data),
delete: (tenantId: string, deviceId: string) =>
api.delete(`/api/tenants/${tenantId}/devices/${deviceId}`).then((r) => r.data),
scan: (tenantId: string, cidr: string) =>
api
.post<SubnetScanResponse>(`/api/tenants/${tenantId}/devices/scan`, { cidr })
.then((r) => r.data),
bulkAdd: (tenantId: string, data: BulkAddRequest) =>
api
.post<BulkAddResult>(`/api/tenants/${tenantId}/devices/bulk-add`, data)
.then((r) => r.data),
bulkAddWithProfile: (tenantId: string, data: BulkAddWithProfileRequest) =>
api
.post<BulkAddWithProfileResult>(`/api/tenants/${tenantId}/devices/bulk`, data)
.then((r) => r.data),
addToGroup: (tenantId: string, deviceId: string, groupId: string) =>
api
.post(`/api/tenants/${tenantId}/devices/${deviceId}/groups/${groupId}`)
.then((r) => r.data),
removeFromGroup: (tenantId: string, deviceId: string, groupId: string) =>
api
.delete(`/api/tenants/${tenantId}/devices/${deviceId}/groups/${groupId}`)
.then((r) => r.data),
addTag: (tenantId: string, deviceId: string, tagId: string) =>
api
.post(`/api/tenants/${tenantId}/devices/${deviceId}/tags/${tagId}`)
.then((r) => r.data),
removeTag: (tenantId: string, deviceId: string, tagId: string) =>
api
.delete(`/api/tenants/${tenantId}/devices/${deviceId}/tags/${tagId}`)
.then((r) => r.data),
}
// ─── Subnet scan types ────────────────────────────────────────────────────────
export interface SubnetScanResult {
ip_address: string
hostname: string | null
api_port_open: boolean
api_ssl_port_open: boolean
}
export interface SubnetScanResponse {
cidr: string
discovered: SubnetScanResult[]
total_scanned: number
total_discovered: number
}
export interface BulkDeviceAdd {
ip_address: string
hostname?: string
api_port?: number
api_ssl_port?: number
username?: string
password?: string
}
export interface BulkAddRequest {
devices: BulkDeviceAdd[]
shared_username?: string
shared_password?: string
}
export interface BulkAddResult {
added: DeviceResponse[]
failed: Array<{ ip_address: string; error: string }>
}
// ─── Credential Profiles ─────────────────────────────────────────────────────
export interface CredentialProfileResponse {
id: string
name: string
description: string | null
credential_type: string // "routeros" | "snmp_v2c" | "snmp_v3"
device_count: number
created_at: string
updated_at: string
}
export interface CredentialProfileListResponse {
profiles: CredentialProfileResponse[]
}
export interface CredentialProfileCreate {
name: string
description?: string
credential_type: string
// RouterOS
username?: string
password?: string
// SNMP v1/v2c
community?: string
// SNMP v3
security_level?: string
auth_protocol?: string
auth_passphrase?: string
priv_protocol?: string
priv_passphrase?: string
}
export interface CredentialProfileUpdate extends Partial<CredentialProfileCreate> {}
export const credentialProfilesApi = {
list: (tenantId: string, credentialType?: string) =>
api
.get<CredentialProfileListResponse>(
`/api/tenants/${tenantId}/credential-profiles`,
{ params: credentialType ? { credential_type: credentialType } : undefined },
)
.then((r) => r.data.profiles),
get: (tenantId: string, profileId: string) =>
api
.get<CredentialProfileResponse>(
`/api/tenants/${tenantId}/credential-profiles/${profileId}`,
)
.then((r) => r.data),
create: (tenantId: string, data: CredentialProfileCreate) =>
api
.post<CredentialProfileResponse>(
`/api/tenants/${tenantId}/credential-profiles`,
data,
)
.then((r) => r.data),
update: (tenantId: string, profileId: string, data: CredentialProfileUpdate) =>
api
.put<CredentialProfileResponse>(
`/api/tenants/${tenantId}/credential-profiles/${profileId}`,
data,
)
.then((r) => r.data),
delete: (tenantId: string, profileId: string) =>
api
.delete(`/api/tenants/${tenantId}/credential-profiles/${profileId}`)
.then((r) => r.data),
devices: (tenantId: string, profileId: string) =>
api
.get<DeviceResponse[]>(
`/api/tenants/${tenantId}/credential-profiles/${profileId}/devices`,
)
.then((r) => r.data),
}
// ─── SNMP Profiles ───────────────────────────────────────────────────────────
export interface SNMPProfileResponse {
id: string
name: string
description: string | null
is_system: boolean
profile_data: Record<string, unknown> | null
sys_object_id: string | null
vendor: string | null
category: string | null
tenant_id: string | null
device_count: number
created_at: string
updated_at: string
}
export interface OIDNode {
oid: string
name: string
description?: string
type?: string
access?: string
status?: string
children?: OIDNode[]
}
export interface MIBParseResponse {
module_name: string
nodes: OIDNode[]
node_count: number
}
export interface ProfileTestRequest {
ip_address: string
snmp_port?: number
snmp_version: string
community?: string
security_level?: string
username?: string
auth_protocol?: string
auth_passphrase?: string
priv_protocol?: string
priv_passphrase?: string
}
export interface ProfileTestResponse {
success: boolean
device_info?: { sys_object_id?: string; sys_descr?: string; sys_name?: string }
error?: string
}
export interface SNMPProfileCreate {
name: string
description?: string
sys_object_id?: string
vendor?: string
category?: string
profile_data: Record<string, unknown>
}
export const snmpProfilesApi = {
list: (tenantId: string) =>
api
.get<{ profiles: SNMPProfileResponse[] }>(`/api/tenants/${tenantId}/snmp-profiles`)
.then((r) => r.data.profiles),
get: (tenantId: string, profileId: string) =>
api
.get<SNMPProfileResponse>(
`/api/tenants/${tenantId}/snmp-profiles/${profileId}`,
)
.then((r) => r.data),
create: (tenantId: string, data: SNMPProfileCreate) =>
api
.post<SNMPProfileResponse>(
`/api/tenants/${tenantId}/snmp-profiles`,
data,
)
.then((r) => r.data),
update: (tenantId: string, profileId: string, data: SNMPProfileCreate) =>
api
.put<SNMPProfileResponse>(
`/api/tenants/${tenantId}/snmp-profiles/${profileId}`,
data,
)
.then((r) => r.data),
delete: (tenantId: string, profileId: string) =>
api
.delete(`/api/tenants/${tenantId}/snmp-profiles/${profileId}`)
.then((r) => r.data),
parseMib: (tenantId: string, file: File) => {
const formData = new FormData()
formData.append('file', file)
return api
.post<MIBParseResponse>(
`/api/tenants/${tenantId}/snmp-profiles/parse-mib`,
formData,
)
.then((r) => r.data)
},
testProfile: (tenantId: string, profileId: string, data: ProfileTestRequest) =>
api
.post<ProfileTestResponse>(
`/api/tenants/${tenantId}/snmp-profiles/${profileId}/test`,
data,
)
.then((r) => r.data),
}
// ─── Bulk Add (credential profile) ──────────────────────────────────────────
export interface BulkAddWithProfileDevice {
ip_address: string
hostname?: string
}
export interface BulkAddWithProfileDefaults {
api_port?: number
api_ssl_port?: number
tls_mode?: string
snmp_port?: number
snmp_version?: string
snmp_profile_id?: string
}
export interface BulkAddWithProfileRequest {
credential_profile_id: string
device_type: 'routeros' | 'snmp'
defaults?: BulkAddWithProfileDefaults
devices: BulkAddWithProfileDevice[]
}
export interface BulkAddWithProfileDeviceResult {
ip_address: string
hostname: string | null
success: boolean
device_id: string | null
error: string | null
}
export interface BulkAddWithProfileResult {
total: number
succeeded: number
failed: number
results: BulkAddWithProfileDeviceResult[]
}
// ─── Device Groups ────────────────────────────────────────────────────────────
export interface DeviceGroupResponse {
id: string
name: string
description: string | null
device_count: number
created_at: string
}
export interface DeviceGroupCreate {
name: string
description?: string
}
export const deviceGroupsApi = {
list: (tenantId: string) =>
api.get<DeviceGroupResponse[]>(`/api/tenants/${tenantId}/device-groups`).then((r) => r.data),
create: (tenantId: string, data: DeviceGroupCreate) =>
api
.post<DeviceGroupResponse>(`/api/tenants/${tenantId}/device-groups`, data)
.then((r) => r.data),
delete: (tenantId: string, groupId: string) =>
api.delete(`/api/tenants/${tenantId}/device-groups/${groupId}`).then((r) => r.data),
}
// ─── Device Tags ──────────────────────────────────────────────────────────────
export interface DeviceTagResponse {
id: string
name: string
color: string | null
}
export interface DeviceTagCreate {
name: string
color?: string
}
export const deviceTagsApi = {
list: (tenantId: string) =>
api.get<DeviceTagResponse[]>(`/api/tenants/${tenantId}/device-tags`).then((r) => r.data),
create: (tenantId: string, data: DeviceTagCreate) =>
api
.post<DeviceTagResponse>(`/api/tenants/${tenantId}/device-tags`, data)
.then((r) => r.data),
delete: (tenantId: string, tagId: string) =>
api.delete(`/api/tenants/${tenantId}/device-tags/${tagId}`).then((r) => r.data),
}
// ─── Sites ───────────────────────────────────────────────────────────────────
export interface SiteResponse {
id: string
name: string
latitude: number | null
longitude: number | null
address: string | null
elevation: number | null
notes: string | null
device_count: number
online_count: number
online_percent: number
alert_count: number
created_at: string
updated_at: string
}
export interface SiteListResponse {
sites: SiteResponse[]
unassigned_count: number
}
export interface SiteCreate {
name: string
latitude?: number | null
longitude?: number | null
address?: string | null
elevation?: number | null
notes?: string | null
}
export interface SiteUpdate {
name?: string
latitude?: number | null
longitude?: number | null
address?: string | null
elevation?: number | null
notes?: string | null
}
export const sitesApi = {
list: (tenantId: string) =>
api.get<SiteListResponse>(`/api/tenants/${tenantId}/sites`).then((r) => r.data),
get: (tenantId: string, siteId: string) =>
api.get<SiteResponse>(`/api/tenants/${tenantId}/sites/${siteId}`).then((r) => r.data),
create: (tenantId: string, data: SiteCreate) =>
api.post<SiteResponse>(`/api/tenants/${tenantId}/sites`, data).then((r) => r.data),
update: (tenantId: string, siteId: string, data: SiteUpdate) =>
api.put<SiteResponse>(`/api/tenants/${tenantId}/sites/${siteId}`, data).then((r) => r.data),
delete: (tenantId: string, siteId: string) =>
api.delete(`/api/tenants/${tenantId}/sites/${siteId}`).then((r) => r.data),
assignDevice: (tenantId: string, siteId: string, deviceId: string) =>
api.post(`/api/tenants/${tenantId}/sites/${siteId}/devices/${deviceId}`).then((r) => r.data),
removeDevice: (tenantId: string, siteId: string, deviceId: string) =>
api.delete(`/api/tenants/${tenantId}/sites/${siteId}/devices/${deviceId}`).then((r) => r.data),
bulkAssign: (tenantId: string, siteId: string, deviceIds: string[]) =>
api
.post<{ assigned: number }>(`/api/tenants/${tenantId}/sites/${siteId}/devices/bulk-assign`, {
device_ids: deviceIds,
})
.then((r) => r.data),
}
// ─── Sectors ──────────────────────────────────────────────────────────────────
export interface SectorResponse {
id: string
site_id: string
name: string
azimuth: number | null
description: string | null
device_count: number
created_at: string
updated_at: string
}
export interface SectorListResponse {
items: SectorResponse[]
total: number
}
export interface SectorCreate {
name: string
azimuth?: number | null
description?: string | null
}
export interface SectorUpdate {
name?: string
azimuth?: number | null
description?: string | null
}
export const sectorsApi = {
list: (tenantId: string, siteId: string) =>
api
.get<SectorListResponse>(`/api/tenants/${tenantId}/sites/${siteId}/sectors`)
.then((r) => r.data),
create: (tenantId: string, siteId: string, data: SectorCreate) =>
api
.post<SectorResponse>(`/api/tenants/${tenantId}/sites/${siteId}/sectors`, data)
.then((r) => r.data),
update: (tenantId: string, siteId: string, sectorId: string, data: SectorUpdate) =>
api
.put<SectorResponse>(
`/api/tenants/${tenantId}/sites/${siteId}/sectors/${sectorId}`,
data,
)
.then((r) => r.data),
delete: (tenantId: string, siteId: string, sectorId: string) =>
api
.delete(`/api/tenants/${tenantId}/sites/${siteId}/sectors/${sectorId}`)
.then((r) => r.data),
assignDevice: (tenantId: string, deviceId: string, sectorId: string | null) =>
api
.put(`/api/tenants/${tenantId}/devices/${deviceId}/sector`, {
sector_id: sectorId,
})
.then((r) => r.data),
}
// ─── Wireless ─────────────────────────────────────────────────────────────────
export interface RegistrationResponse {
mac_address: string
interface: string | null
signal_strength: number | null
tx_ccq: number | null
tx_rate: string | null
rx_rate: string | null
distance: number | null
uptime: string | null
last_seen: string
hostname: string | null
device_id: string | null
}
export interface RegistrationListResponse {
items: RegistrationResponse[]
total: number
}
export interface RFStatsResponse {
interface: string
noise_floor: number | null
channel_width: number | null
tx_power: number | null
registered_clients: number | null
last_seen: string
}
export interface RFStatsListResponse {
items: RFStatsResponse[]
total: number
}
export interface LinkResponse {
id: string
ap_device_id: string
cpe_device_id: string
ap_hostname: string | null
cpe_hostname: string | null
interface: string | null
client_mac: string
signal_strength: number | null
tx_ccq: number | null
tx_rate: string | null
rx_rate: string | null
state: string
missed_polls: number
discovered_at: string
last_seen: string
}
export interface LinkListResponse {
items: LinkResponse[]
total: number
}
export interface UnknownClientResponse {
mac_address: string
interface: string | null
signal_strength: number | null
tx_rate: string | null
rx_rate: string | null
last_seen: string
}
export interface UnknownClientListResponse {
items: UnknownClientResponse[]
total: number
}
export const wirelessApi = {
getLinks: (tenantId: string, params?: { state?: string; device_id?: string }) =>
api
.get<LinkListResponse>(`/api/tenants/${tenantId}/links`, { params })
.then((r) => r.data),
getSiteLinks: (tenantId: string, siteId: string) =>
api
.get<LinkListResponse>(`/api/tenants/${tenantId}/sites/${siteId}/links`)
.then((r) => r.data),
getDeviceLinks: (tenantId: string, deviceId: string) =>
api
.get<LinkListResponse>(`/api/tenants/${tenantId}/devices/${deviceId}/links`)
.then((r) => r.data),
getDeviceRegistrations: (tenantId: string, deviceId: string) =>
api
.get<RegistrationListResponse>(
`/api/tenants/${tenantId}/devices/${deviceId}/registrations`,
)
.then((r) => r.data),
getDeviceRFStats: (tenantId: string, deviceId: string) =>
api
.get<RFStatsListResponse>(
`/api/tenants/${tenantId}/devices/${deviceId}/rf-stats`,
)
.then((r) => r.data),
getUnknownClients: (tenantId: string, deviceId: string) =>
api
.get<UnknownClientListResponse>(
`/api/tenants/${tenantId}/devices/${deviceId}/unknown-clients`,
)
.then((r) => r.data),
}
// ─── Metrics ──────────────────────────────────────────────────────────────────
export interface HealthMetricPoint {
bucket: string // ISO timestamp
avg_cpu: number | null
max_cpu: number | null
avg_mem_pct: number | null
avg_disk_pct: number | null
avg_temp: number | null
}
export interface InterfaceMetricPoint {
bucket: string
interface: string
avg_rx_bps: number | null
avg_tx_bps: number | null
max_rx_bps: number | null
max_tx_bps: number | null
}
export interface WirelessMetricPoint {
bucket: string
interface: string
avg_clients: number | null
max_clients: number | null
avg_signal: number | null
avg_ccq: number | null
frequency: number | null
}
export interface WirelessLatest {
interface: string
client_count: number | null
avg_signal: number | null
ccq: number | null
frequency: number | null
time: string
}
export interface WirelessIssue {
device_id: string
hostname: string
tenant_name?: string
interface: string
issue: string
signal: number | null
ccq: number | null
client_count: number
frequency: number
}
export interface FleetDevice {
id: string
hostname: string
ip_address: string
status: string
model: string | null
last_seen: string | null
uptime_seconds: number | null
last_cpu_load: number | null
last_memory_used_pct: number | null
latitude: number | null
longitude: number | null
tenant_id: string
tenant_name: string
client_count: number | null
avg_signal: number | null
cpe_signal: number | null
ap_hostname: string | null
}
export interface SparklinePoint {
cpu_load: number | null
time: string
}
// ─── Config Backups ───────────────────────────────────────────────────────────
export interface ConfigBackupEntry {
id: string
commit_sha: string
trigger_type: 'scheduled' | 'manual' | 'pre-restore' | 'checkpoint' | 'config-change'
lines_added: number | null
lines_removed: number | null
encryption_tier: number | null
created_at: string
}
export interface RestoreResult {
status: 'committed' | 'reverted' | 'failed'
message: string
pre_backup_sha?: string
}
export interface RestorePreview {
diff: { added: number; removed: number; modified: number }
categories: Array<{
path: string
adds: number
removes: number
risk: 'none' | 'low' | 'medium' | 'high'
}>
warnings: string[]
validation: { valid: boolean; errors: string[] }
}
export interface BackupSchedule {
id: string | null
cron_expression: string
enabled: boolean
device_id: string | null
is_default?: boolean
}
export const configApi = {
listBackups: (tenantId: string, deviceId: string) =>
api
.get<ConfigBackupEntry[]>(
`/api/tenants/${tenantId}/devices/${deviceId}/config/backups`,
)
.then((r) => r.data),
triggerBackup: (tenantId: string, deviceId: string) =>
api
.post<ConfigBackupEntry>(
`/api/tenants/${tenantId}/devices/${deviceId}/config/backups`,
)
.then((r) => r.data),
createCheckpoint: (tenantId: string, deviceId: string) =>
api
.post<ConfigBackupEntry>(
`/api/tenants/${tenantId}/devices/${deviceId}/config/checkpoint`,
)
.then((r) => r.data),
getExportText: (tenantId: string, deviceId: string, commitSha: string) =>
api
.get<string>(
`/api/tenants/${tenantId}/devices/${deviceId}/config/backups/${commitSha}/export`,
{ responseType: 'text' },
)
.then((r) => r.data),
downloadBinary: (tenantId: string, deviceId: string, commitSha: string) =>
api
.get<Blob>(
`/api/tenants/${tenantId}/devices/${deviceId}/config/backups/${commitSha}/binary`,
{ responseType: 'blob' },
)
.then((r) => r.data),
restore: (tenantId: string, deviceId: string, commitSha: string) =>
api
.post<RestoreResult>(
`/api/tenants/${tenantId}/devices/${deviceId}/config/restore`,
{ commit_sha: commitSha },
)
.then((r) => r.data),
previewRestore: (tenantId: string, deviceId: string, commitSha: string) =>
api
.post<RestorePreview>(
`/api/tenants/${tenantId}/devices/${deviceId}/config/preview-restore`,
{ commit_sha: commitSha },
)
.then((r) => r.data),
emergencyRollback: (tenantId: string, deviceId: string) =>
api
.post<
RestoreResult & { rolled_back_to: string; rolled_back_to_date: string }
>(
`/api/tenants/${tenantId}/devices/${deviceId}/config/emergency-rollback`,
)
.then((r) => r.data),
getSchedule: (tenantId: string, deviceId: string) =>
api
.get<BackupSchedule>(
`/api/tenants/${tenantId}/devices/${deviceId}/config/schedules`,
)
.then((r) => r.data),
updateSchedule: (
tenantId: string,
deviceId: string,
data: Partial<BackupSchedule>,
) =>
api
.put<BackupSchedule>(
`/api/tenants/${tenantId}/devices/${deviceId}/config/schedules`,
data,
)
.then((r) => r.data),
}
/**
* Get a fresh access token for SSE connections.
* The refresh token is in httpOnly cookie, so just call the endpoint.
*/
export async function getAccessToken(): Promise<string> {
const response = await authApi.refresh()
return response.access_token
}
export interface SNMPMetricPoint {
bucket: string
metric_name: string
metric_group: string
oid: string
avg_value: number | null
max_value: number | null
min_value: number | null
}
export const metricsApi = {
health: (tenantId: string, deviceId: string, start: string, end: string) =>
api
.get<HealthMetricPoint[]>(
`/api/tenants/${tenantId}/devices/${deviceId}/metrics/health`,
{ params: { start, end } },
)
.then((r) => r.data),
interfaces: (
tenantId: string,
deviceId: string,
start: string,
end: string,
iface?: string,
) =>
api
.get<InterfaceMetricPoint[]>(
`/api/tenants/${tenantId}/devices/${deviceId}/metrics/interfaces`,
{ params: { start, end, ...(iface ? { interface: iface } : {}) } },
)
.then((r) => r.data),
interfaceList: (tenantId: string, deviceId: string) =>
api
.get<string[]>(`/api/tenants/${tenantId}/devices/${deviceId}/metrics/interfaces/list`)
.then((r) => r.data),
wireless: (tenantId: string, deviceId: string, start: string, end: string) =>
api
.get<WirelessMetricPoint[]>(
`/api/tenants/${tenantId}/devices/${deviceId}/metrics/wireless`,
{ params: { start, end } },
)
.then((r) => r.data),
wirelessLatest: (tenantId: string, deviceId: string) =>
api
.get<WirelessLatest[]>(
`/api/tenants/${tenantId}/devices/${deviceId}/metrics/wireless/latest`,
)
.then((r) => r.data),
fleetSummary: (tenantId: string) =>
api
.get<FleetDevice[]>(`/api/tenants/${tenantId}/fleet/summary`)
.then((r) => r.data),
/** Cross-tenant fleet summary for super_admin users */
fleetSummaryAll: () =>
api.get<FleetDevice[]>(`/api/fleet/summary`).then((r) => r.data),
wirelessIssues: (tenantId: string) =>
api
.get<WirelessIssue[]>(`/api/tenants/${tenantId}/fleet/wireless-issues`)
.then((r) => r.data),
fleetWirelessIssues: () =>
api.get<WirelessIssue[]>(`/api/fleet/wireless-issues`).then((r) => r.data),
sparkline: (tenantId: string, deviceId: string) =>
api
.get<SparklinePoint[]>(
`/api/tenants/${tenantId}/devices/${deviceId}/metrics/sparkline`,
)
.then((r) => r.data),
snmp: (tenantId: string, deviceId: string, metricName: string, start: string, end: string) =>
api
.get<SNMPMetricPoint[]>(
`/api/tenants/${tenantId}/devices/${deviceId}/metrics/snmp`,
{ params: { metric_name: metricName, start, end } },
)
.then((r) => r.data),
}
// ─── Maintenance Windows ────────────────────────────────────────────────────
export interface MaintenanceWindow {
id: string
tenant_id: string
name: string
device_ids: string[]
start_at: string
end_at: string
suppress_alerts: boolean
notes: string | null
created_by: string | null
created_at: string
}
export interface MaintenanceWindowCreate {
name: string
device_ids: string[]
start_at: string
end_at: string
suppress_alerts: boolean
notes?: string
}
export interface MaintenanceWindowUpdate {
name?: string
device_ids?: string[]
start_at?: string
end_at?: string
suppress_alerts?: boolean
notes?: string
}
export const maintenanceApi = {
list: (tenantId: string, status?: string) =>
api
.get<MaintenanceWindow[]>(
`/api/tenants/${tenantId}/maintenance-windows`,
{ params: status ? { status } : {} },
)
.then((r) => r.data),
create: (tenantId: string, data: MaintenanceWindowCreate) =>
api
.post<MaintenanceWindow>(
`/api/tenants/${tenantId}/maintenance-windows`,
data,
)
.then((r) => r.data),
update: (tenantId: string, windowId: string, data: MaintenanceWindowUpdate) =>
api
.put<MaintenanceWindow>(
`/api/tenants/${tenantId}/maintenance-windows/${windowId}`,
data,
)
.then((r) => r.data),
delete: (tenantId: string, windowId: string) =>
api
.delete(`/api/tenants/${tenantId}/maintenance-windows/${windowId}`)
.then((r) => r.data),
}
// ─── Audit Logs ──────────────────────────────────────────────────────────────
export interface AuditLogEntry {
id: string
user_email: string | null
action: string
resource_type: string | null
resource_id: string | null
device_id: string | null
device_name: string | null
details: Record<string, unknown>
ip_address: string | null
created_at: string
}
export interface AuditLogResponse {
items: AuditLogEntry[]
total: number
page: number
per_page: number
}
export interface AuditLogParams {
page?: number
per_page?: number
action?: string
user_id?: string
device_id?: string
date_from?: string
date_to?: string
}
export const auditLogsApi = {
list: (tenantId: string, params?: AuditLogParams) =>
api
.get<AuditLogResponse>(`/api/tenants/${tenantId}/audit-logs`, { params })
.then((r) => r.data),
exportCsv: async (tenantId: string, params?: AuditLogParams) => {
const response = await api.get(`/api/tenants/${tenantId}/audit-logs`, {
params: { ...params, format: 'csv' },
responseType: 'blob',
})
const blob = new Blob([response.data as BlobPart], { type: 'text/csv' })
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'audit-logs.csv'
a.click()
window.URL.revokeObjectURL(url)
},
}
// ─── Reports ─────────────────────────────────────────────────────────────────
export interface ReportRequest {
type: 'device_inventory' | 'metrics_summary' | 'alert_history' | 'change_log'
date_from?: string
date_to?: string
format: 'pdf' | 'csv'
}
export const reportsApi = {
generate: async (tenantId: string, request: ReportRequest) => {
const response = await api.post(
`/api/tenants/${tenantId}/reports/generate`,
request,
{ responseType: 'blob' },
)
// Extract filename from Content-Disposition header
const disposition = response.headers['content-disposition'] ?? ''
const filenameMatch = disposition.match(/filename="?([^"]+)"?/)
const filename = filenameMatch
? filenameMatch[1]
: `report.${request.format === 'csv' ? 'csv' : 'pdf'}`
// Trigger browser download
const blob = new Blob([response.data as BlobPart], {
type: request.format === 'csv' ? 'text/csv' : 'application/pdf',
})
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
window.URL.revokeObjectURL(url)
},
}
// ─── API Keys ──────────────────────────────────────────────────────────────
export interface ApiKeyResponse {
id: string
name: string
key_prefix: string
scopes: string[]
expires_at: string | null
last_used_at: string | null
created_at: string
revoked_at: string | null
}
export interface ApiKeyCreateResponse extends ApiKeyResponse {
key: string
}
export interface ApiKeyCreate {
name: string
scopes: string[]
expires_at?: string
}
export const apiKeysApi = {
list: (tenantId: string) =>
api.get<ApiKeyResponse[]>(`/api/tenants/${tenantId}/api-keys`).then((r) => r.data),
create: (tenantId: string, data: ApiKeyCreate) =>
api
.post<ApiKeyCreateResponse>(`/api/tenants/${tenantId}/api-keys`, data)
.then((r) => r.data),
revoke: (tenantId: string, keyId: string) =>
api.delete(`/api/tenants/${tenantId}/api-keys/${keyId}`).then((r) => r.data),
}
// ─── Remote Access ───────────────────────────────────────────────────────────
export const remoteAccessApi = {
openWinbox: (tenantId: string, deviceId: string) =>
api
.post<{
tunnel_id: string
host: string
port: number
winbox_uri: string
idle_timeout_seconds: number
}>(`/api/tenants/${tenantId}/devices/${deviceId}/winbox-session`)
.then((r) => r.data),
closeWinbox: (tenantId: string, deviceId: string, tunnelId: string) =>
api
.delete(`/api/tenants/${tenantId}/devices/${deviceId}/winbox-session/${tunnelId}`)
.then((r) => r.data),
openSSH: (tenantId: string, deviceId: string, cols: number, rows: number) =>
api
.post<{
token: string
websocket_url: string
idle_timeout_seconds: number
}>(`/api/tenants/${tenantId}/devices/${deviceId}/ssh-session`, { cols, rows })
.then((r) => r.data),
getSessions: (tenantId: string, deviceId: string) =>
api
.get<{
winbox_tunnels: Array<{ tunnel_id: string; local_port: number; idle_seconds: number; created_at: string }>
ssh_sessions: Array<{ session_id: string; idle_seconds: number; created_at: string }>
}>(`/api/tenants/${tenantId}/devices/${deviceId}/sessions`)
.then((r) => r.data),
}
// ─── Remote WinBox (Browser) ─────────────────────────────────────────────────
export interface RemoteWinBoxSession {
session_id: string
status: 'creating' | 'active' | 'grace' | 'terminating' | 'terminated' | 'failed'
websocket_path?: string
xpra_ws_port?: number
idle_timeout_seconds: number
max_lifetime_seconds: number
expires_at: string
max_expires_at: string
created_at?: string
}
export const remoteWinboxApi = {
create: (tenantId: string, deviceId: string, opts?: {
idle_timeout_seconds?: number
max_lifetime_seconds?: number
}) =>
api
.post<RemoteWinBoxSession>(
`/api/tenants/${tenantId}/devices/${deviceId}/winbox-remote-sessions`,
opts || {},
)
.then((r) => r.data),
get: (tenantId: string, deviceId: string, sessionId: string) =>
api
.get<RemoteWinBoxSession>(
`/api/tenants/${tenantId}/devices/${deviceId}/winbox-remote-sessions/${sessionId}`,
)
.then((r) => r.data),
list: (tenantId: string, deviceId: string) =>
api
.get<RemoteWinBoxSession[]>(
`/api/tenants/${tenantId}/devices/${deviceId}/winbox-remote-sessions`,
)
.then((r) => r.data),
delete: (tenantId: string, deviceId: string, sessionId: string) =>
api
.delete(
`/api/tenants/${tenantId}/devices/${deviceId}/winbox-remote-sessions/${sessionId}`,
)
.then((r) => r.data),
getWebSocketUrl: (sessionPath: string) => {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${proto}//${window.location.host}${sessionPath}`
},
}
// ─── Config History ─────────────────────────────────────────────────────────
export interface ConfigChangeEntry {
id: string
component: string
summary: string
created_at: string
diff_id: string
lines_added: number
lines_removed: number
snapshot_id: string
}
export interface DiffResponse {
id: string
diff_text: string
lines_added: number
lines_removed: number
old_snapshot_id: string
new_snapshot_id: string
created_at: string
}
export interface SnapshotResponse {
id: string
config_text: string
sha256_hash: string
collected_at: string
}
export const configHistoryApi = {
list: (tenantId: string, deviceId: string, limit = 50, offset = 0) =>
api
.get<ConfigChangeEntry[]>(
`/api/tenants/${tenantId}/devices/${deviceId}/config-history`,
{ params: { limit, offset } },
)
.then((r) => r.data),
getDiff: (tenantId: string, deviceId: string, snapshotId: string) =>
api
.get<DiffResponse>(
`/api/tenants/${tenantId}/devices/${deviceId}/config/${snapshotId}/diff`,
)
.then((r) => r.data),
getSnapshot: (tenantId: string, deviceId: string, snapshotId: string) =>
api
.get<SnapshotResponse>(
`/api/tenants/${tenantId}/devices/${deviceId}/config/${snapshotId}`,
)
.then((r) => r.data),
}
// ─── VPN (WireGuard) ────────────────────────────────────────────────────────
export interface VpnConfigResponse {
id: string
tenant_id: string
server_public_key: string
subnet: string
server_port: number
server_address: string
endpoint: string | null
is_enabled: boolean
peer_count: number
created_at: string
}
export interface VpnPeerResponse {
id: string
device_id: string
device_hostname: string
device_ip: string
peer_public_key: string
assigned_ip: string
is_enabled: boolean
last_handshake: string | null
created_at: string
}
export interface VpnOnboardRequest {
hostname: string
username: string
password: string
}
export interface VpnOnboardResponse {
device_id: string
peer_id: string
hostname: string
assigned_ip: string
routeros_commands: string[]
}
export interface VpnPeerConfig {
peer_private_key: string
peer_public_key: string
assigned_ip: string
server_public_key: string
server_endpoint: string
allowed_ips: string
routeros_commands: string[]
}
export const vpnApi = {
getConfig: (tenantId: string) =>
api.get<VpnConfigResponse | null>(`/api/tenants/${tenantId}/vpn`).then((r) => r.data),
setup: (tenantId: string, endpoint?: string) =>
api.post<VpnConfigResponse>(`/api/tenants/${tenantId}/vpn`, { endpoint }).then((r) => r.data),
updateConfig: (tenantId: string, data: { endpoint?: string; is_enabled?: boolean }) =>
api.patch<VpnConfigResponse>(`/api/tenants/${tenantId}/vpn`, data).then((r) => r.data),
deleteConfig: (tenantId: string) =>
api.delete(`/api/tenants/${tenantId}/vpn`),
listPeers: (tenantId: string) =>
api.get<VpnPeerResponse[]>(`/api/tenants/${tenantId}/vpn/peers`).then((r) => r.data),
addPeer: (tenantId: string, deviceId: string) =>
api.post<VpnPeerResponse>(`/api/tenants/${tenantId}/vpn/peers`, { device_id: deviceId }).then((r) => r.data),
removePeer: (tenantId: string, peerId: string) =>
api.delete(`/api/tenants/${tenantId}/vpn/peers/${peerId}`).then((r) => r.data),
getPeerConfig: (tenantId: string, peerId: string) =>
api.get<VpnPeerConfig>(`/api/tenants/${tenantId}/vpn/peers/${peerId}/config`).then((r) => r.data),
onboard: (tenantId: string, data: VpnOnboardRequest) =>
api.post<VpnOnboardResponse>(`/api/tenants/${tenantId}/vpn/peers/onboard`, data).then((r) => r.data),
}
// ─── Signal History ──────────────────────────────────────────────────────────
export interface SignalHistoryPoint {
timestamp: string
signal_avg: number
signal_min: number
signal_max: number
}
export interface SignalHistoryResponse {
items: SignalHistoryPoint[]
mac_address: string
range: string
}
export const signalHistoryApi = {
get: async (
tenantId: string,
deviceId: string,
macAddress: string,
range: string = '7d',
): Promise<SignalHistoryResponse> => {
const { data } = await api.get<SignalHistoryResponse>(
`/api/tenants/${tenantId}/devices/${deviceId}/signal-history`,
{ params: { mac_address: macAddress, range } },
)
return data
},
}
// ─── Site Alert Rules ────────────────────────────────────────────────────────
export interface SiteAlertRuleResponse {
id: string
tenant_id: string
site_id: string
sector_id: string | null
rule_type: string
name: string
description: string | null
threshold_value: number
threshold_unit: string
enabled: boolean
created_at: string
updated_at: string
}
export interface SiteAlertRuleListResponse {
items: SiteAlertRuleResponse[]
total: number
}
export interface SiteAlertRuleCreate {
name: string
rule_type: string
threshold_value: number
threshold_unit: string
sector_id?: string
description?: string
enabled?: boolean
}
export interface SiteAlertRuleUpdate {
name?: string
threshold_value?: number
threshold_unit?: string
description?: string
enabled?: boolean
}
export const alertRulesApi = {
list: async (
tenantId: string,
siteId: string,
sectorId?: string,
): Promise<SiteAlertRuleListResponse> => {
const { data } = await api.get<SiteAlertRuleListResponse>(
`/api/tenants/${tenantId}/sites/${siteId}/alert-rules`,
{ params: sectorId ? { sector_id: sectorId } : undefined },
)
return data
},
get: async (
tenantId: string,
siteId: string,
ruleId: string,
): Promise<SiteAlertRuleResponse> => {
const { data } = await api.get<SiteAlertRuleResponse>(
`/api/tenants/${tenantId}/sites/${siteId}/alert-rules/${ruleId}`,
)
return data
},
create: async (
tenantId: string,
siteId: string,
rule: SiteAlertRuleCreate,
): Promise<SiteAlertRuleResponse> => {
const { data } = await api.post<SiteAlertRuleResponse>(
`/api/tenants/${tenantId}/sites/${siteId}/alert-rules`,
rule,
)
return data
},
update: async (
tenantId: string,
siteId: string,
ruleId: string,
rule: SiteAlertRuleUpdate,
): Promise<SiteAlertRuleResponse> => {
const { data } = await api.put<SiteAlertRuleResponse>(
`/api/tenants/${tenantId}/sites/${siteId}/alert-rules/${ruleId}`,
rule,
)
return data
},
delete: async (tenantId: string, siteId: string, ruleId: string): Promise<void> => {
await api.delete(`/api/tenants/${tenantId}/sites/${siteId}/alert-rules/${ruleId}`)
},
}
// ─── Site Alert Events ───────────────────────────────────────────────────────
export interface SiteAlertEventResponse {
id: string
tenant_id: string
site_id: string
sector_id: string | null
rule_id: string | null
device_id: string | null
link_id: string | null
severity: string
message: string
state: string
consecutive_hits: number
triggered_at: string
resolved_at: string | null
resolved_by: string | null
}
export interface SiteAlertEventListResponse {
items: SiteAlertEventResponse[]
total: number
}
export const alertEventsApi = {
list: async (
tenantId: string,
siteId: string,
state?: string,
limit?: number,
): Promise<SiteAlertEventListResponse> => {
const { data } = await api.get<SiteAlertEventListResponse>(
`/api/tenants/${tenantId}/sites/${siteId}/alert-events`,
{ params: { state, limit } },
)
return data
},
resolve: async (
tenantId: string,
eventId: string,
): Promise<SiteAlertEventResponse> => {
const { data } = await api.post<SiteAlertEventResponse>(
`/api/tenants/${tenantId}/alert-events/${eventId}/resolve`,
)
return data
},
activeCount: async (tenantId: string): Promise<number> => {
const { data } = await api.get<{ count: number }>(
`/api/tenants/${tenantId}/alert-events/count`,
)
return data.count
},
}