1154 lines
33 KiB
TypeScript
1154 lines
33 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
|
|
tags: DeviceTagRef[]
|
|
groups: DeviceGroupRef[]
|
|
created_at: string
|
|
}
|
|
|
|
export interface DeviceListResponse {
|
|
items: DeviceResponse[]
|
|
total: number
|
|
page: number
|
|
page_size: number
|
|
}
|
|
|
|
export interface DeviceCreate {
|
|
hostname: string
|
|
ip_address: string
|
|
api_port?: number
|
|
api_ssl_port?: number
|
|
username: string
|
|
password: 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
|
|
model?: string
|
|
tag?: 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),
|
|
|
|
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 }>
|
|
}
|
|
|
|
// ─── 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),
|
|
}
|
|
|
|
// ─── 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 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
|
|
}
|
|
|
|
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 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),
|
|
|
|
sparkline: (tenantId: string, deviceId: string) =>
|
|
api
|
|
.get<SparklinePoint[]>(
|
|
`/api/tenants/${tenantId}/devices/${deviceId}/metrics/sparkline`,
|
|
)
|
|
.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_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),
|
|
|
|
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),
|
|
}
|