Логін: - core.users.username замість пошти як ідентифікатор: у мережевій інсталяції половина акаунтів технічні (noc, monitoring, oncall) і скриньки не мають узагалі. Пошта лишилась необов'язковим полем - наявним користувачам логін виведено з пошти, збіги розведено суфіксом: мовчки злити admin@a.com і admin@b.com в один логін — це втрата акаунта, а не міграція - сервер шукає за логіном і за поштою, тому звичка вводити email нікого не відхиляє Групи (модель Zabbix): - групи хостів і групи доступу; права read/write/deny на групу хостів - роль каже, що вільно робити; група — над якими хостами. Інженер над філією та інженер над усією мережею мають однакову роль і різний доступ - хто не входить у жодну групу, групами не обмежений — свідомо не по-заббіксівськи: там кожна нова інсталяція починається з питання «чому порожньо» - заборона перемагає дозвіл, інакше її обійти додаванням у сусідню групу - фільтр накладається в самому запиті, а не після вибірки Хости: додавання, редагування, м'яке видалення, прив'язка до груп, фільтр за групою в таблиці. Мапа: створення мапи з транслітерацією slug, інспектор вузла — підпис, значок, розмір, ширина, колір рамки, закріплення. Виправлено за скаргами: - перемикач вилазив на 14px за трек: у ручки не заданий left, а статичну позицію зсуває типове text-align: center у <button> - сторінка правил не оновлювала «активних»: після повернення правила алерти піднімаються наступним тіком движка, тобто ПІСЛЯ нашого перечитування — бракувало підписки на живі події - канал із секретом показувався як «секрету немає» Живий прогін: eng у групі з read на «Доступ» бачить 2 хости замість 3, обидва без запису, алертів 1 замість 3, редагування чужого хоста 403. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
347 lines
11 KiB
TypeScript
347 lines
11 KiB
TypeScript
import { meFromLogin, session } from './session'
|
||
import type {
|
||
AccessLevel,
|
||
AgentSummary,
|
||
Alert,
|
||
AlertCounts,
|
||
AlertRule,
|
||
BuildResult,
|
||
Channel,
|
||
DeviceGroup,
|
||
DeviceSummary,
|
||
LoginResponse,
|
||
MapPatch,
|
||
MapState,
|
||
MapSummary,
|
||
PatchResult,
|
||
Role,
|
||
TeamMember,
|
||
UserGroup,
|
||
UndoResult,
|
||
} from '../types'
|
||
|
||
/** Те, що приймає POST/PATCH /api/v1/devices. */
|
||
export interface DeviceInput {
|
||
name: string
|
||
address?: string
|
||
kind: string
|
||
vendor?: string
|
||
agent_id?: string
|
||
enabled?: boolean
|
||
group_ids?: string[]
|
||
notes?: string
|
||
}
|
||
|
||
|
||
// Машинний токен зі змінної збірки лишається запасним шляхом для кіосків
|
||
// і NOC-екранів, де входити нікому. Жива сесія людини має пріоритет.
|
||
const STATIC_TOKEN = import.meta.env.VITE_API_TOKEN ?? ''
|
||
|
||
// Чи налаштований машинний токен: у цьому режимі сторінка входу не
|
||
// показується взагалі (кіоск, NOC-екран).
|
||
export const staticTokenPresent = STATIC_TOKEN !== ''
|
||
|
||
export function authToken(): string {
|
||
return session.token() || STATIC_TOKEN
|
||
}
|
||
|
||
export class ApiError extends Error {
|
||
constructor(
|
||
readonly status: number,
|
||
readonly code: string,
|
||
message: string,
|
||
) {
|
||
super(message)
|
||
}
|
||
|
||
/** Мапу змінив хтось інший — треба перечитати й повторити правку. */
|
||
get isConflict() {
|
||
return this.status === 409
|
||
}
|
||
|
||
/** Уперлись у ліміт тарифу: UI має показати пропозицію оновитись. */
|
||
get isPlanLimit() {
|
||
return this.status === 402
|
||
}
|
||
|
||
/** Немає права на дію. */
|
||
get isForbidden() {
|
||
return this.status === 403
|
||
}
|
||
}
|
||
|
||
async function raw<T>(path: string, init?: RequestInit): Promise<T> {
|
||
const headers: Record<string, string> = {
|
||
'Content-Type': 'application/json',
|
||
...((init?.headers as Record<string, string>) ?? {}),
|
||
}
|
||
const t = authToken()
|
||
if (t) headers.Authorization = `Bearer ${t}`
|
||
|
||
// credentials потрібні для httpOnly-кукі з refresh-токеном.
|
||
const res = await fetch(path, { ...init, headers, credentials: 'same-origin' })
|
||
|
||
if (res.status === 204) return undefined as T
|
||
|
||
const text = await res.text()
|
||
const body = text ? JSON.parse(text) : {}
|
||
|
||
if (!res.ok) {
|
||
const err = body?.error ?? {}
|
||
throw new ApiError(res.status, err.code ?? 'unknown', err.message ?? res.statusText)
|
||
}
|
||
return body as T
|
||
}
|
||
|
||
// Один refresh на всіх одночасних запитах.
|
||
//
|
||
// Якщо токен протух під час завантаження мапи, паралельні виклики
|
||
// інакше влаштували б чергу з десятка обмінів — а ротація зробила б
|
||
// усі, крім першого, недійсними, і людину викинуло б на вхід рівно
|
||
// тоді, коли все насправді гаразд.
|
||
let refreshing: Promise<boolean> | null = null
|
||
|
||
async function refreshOnce(): Promise<boolean> {
|
||
if (!refreshing) {
|
||
refreshing = raw<LoginResponse>('/api/v1/auth/refresh', { method: 'POST' })
|
||
.then((r) => {
|
||
if (!r.access_token) return false
|
||
session.set(r.access_token, meFromLogin(r))
|
||
return true
|
||
})
|
||
.catch(() => false)
|
||
.finally(() => {
|
||
refreshing = null
|
||
})
|
||
}
|
||
return refreshing
|
||
}
|
||
|
||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||
try {
|
||
return await raw<T>(path, init)
|
||
} catch (e) {
|
||
// Протермінований токен — не привід викидати людину на сторінку
|
||
// входу: refresh-кукі ще жива, тож тихо оновлюємо й повторюємо.
|
||
if (e instanceof ApiError && e.status === 401 && e.code === 'token_expired') {
|
||
if (await refreshOnce()) return raw<T>(path, init)
|
||
session.clear()
|
||
}
|
||
throw e
|
||
}
|
||
}
|
||
|
||
export const api = {
|
||
// --- вхід ---
|
||
|
||
login: (login: string, password: string, tenantID?: string) =>
|
||
raw<LoginResponse>('/api/v1/auth/login', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ login, password, tenant_id: tenantID }),
|
||
}),
|
||
|
||
/** Тихе відновлення сесії після перезавантаження сторінки. */
|
||
restore: () => refreshOnce(),
|
||
|
||
logout: async () => {
|
||
try {
|
||
await raw<void>('/api/v1/auth/logout', { method: 'POST' })
|
||
} finally {
|
||
session.clear()
|
||
}
|
||
},
|
||
|
||
me: () =>
|
||
request<{
|
||
tenant_id: string
|
||
email?: string
|
||
user_id?: string
|
||
token_name?: string
|
||
permissions: string[]
|
||
}>('/api/v1/me'),
|
||
|
||
// --- команда ---
|
||
|
||
team: () => request<{ members: TeamMember[] }>('/api/v1/team').then((r) => r.members ?? []),
|
||
|
||
roles: () => request<{ roles: Role[] }>('/api/v1/roles').then((r) => r.roles ?? []),
|
||
|
||
addMember: (u: {
|
||
username: string
|
||
password: string
|
||
role_key: string
|
||
email?: string
|
||
full_name?: string
|
||
}) => request<{ id: string }>('/api/v1/team', { method: 'POST', body: JSON.stringify(u) }),
|
||
|
||
setRole: (userID: string, roleID: string) =>
|
||
request<void>(`/api/v1/team/${userID}`, {
|
||
method: 'PATCH',
|
||
body: JSON.stringify({ role_id: roleID }),
|
||
}),
|
||
|
||
removeMember: (userID: string) =>
|
||
request<void>(`/api/v1/team/${userID}`, { method: 'DELETE' }),
|
||
|
||
// --- мапи ---
|
||
|
||
listMaps: () => request<{ maps: MapSummary[] }>('/api/v1/maps').then((r) => r.maps ?? []),
|
||
|
||
getMap: (id: string) => request<MapState>(`/api/v1/maps/${id}`),
|
||
|
||
createMap: (name: string, slug: string, kind = 'logical') =>
|
||
request<MapSummary>('/api/v1/maps', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ name, slug, kind }),
|
||
}),
|
||
|
||
patchMap: (id: string, patch: MapPatch) =>
|
||
request<PatchResult>(`/api/v1/maps/${id}`, {
|
||
method: 'PATCH',
|
||
body: JSON.stringify(patch),
|
||
}),
|
||
|
||
buildMap: (id: string) => request<BuildResult>(`/api/v1/maps/${id}/build`, { method: 'POST' }),
|
||
|
||
undoMap: (id: string) => request<UndoResult>(`/api/v1/maps/${id}/undo`, { method: 'POST' }),
|
||
|
||
// --- інвентар ---
|
||
|
||
listDevices: () =>
|
||
request<{ devices: DeviceSummary[] }>('/api/v1/devices').then((r) => r.devices ?? []),
|
||
|
||
listAgents: () =>
|
||
request<{ agents: AgentSummary[] }>('/api/v1/agents').then((r) => r.agents ?? []),
|
||
|
||
// --- хости ---
|
||
|
||
createDevice: (d: DeviceInput) =>
|
||
request<{ id: string }>('/api/v1/devices', { method: 'POST', body: JSON.stringify(d) }),
|
||
|
||
updateDevice: (id: string, d: DeviceInput) =>
|
||
request<void>(`/api/v1/devices/${id}`, { method: 'PATCH', body: JSON.stringify(d) }),
|
||
|
||
deleteDevice: (id: string) => request<void>(`/api/v1/devices/${id}`, { method: 'DELETE' }),
|
||
|
||
// --- групи пристроїв ---
|
||
|
||
listDeviceGroups: () =>
|
||
request<{ groups: DeviceGroup[] }>('/api/v1/device-groups').then((r) => r.groups ?? []),
|
||
|
||
createDeviceGroup: (name: string, color = '') =>
|
||
request<{ id: string }>('/api/v1/device-groups', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ name, color }),
|
||
}),
|
||
|
||
deleteDeviceGroup: (id: string) =>
|
||
request<void>(`/api/v1/device-groups/${id}`, { method: 'DELETE' }),
|
||
|
||
// --- групи користувачів ---
|
||
|
||
listUserGroups: () =>
|
||
request<{ groups: UserGroup[] }>('/api/v1/user-groups').then((r) => r.groups ?? []),
|
||
|
||
createUserGroup: (name: string, description = '') =>
|
||
request<{ id: string }>('/api/v1/user-groups', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ name, description }),
|
||
}),
|
||
|
||
updateUserGroup: (
|
||
id: string,
|
||
patch: { member_ids?: string[]; permissions?: Record<string, AccessLevel> },
|
||
) => request<void>(`/api/v1/user-groups/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }),
|
||
|
||
deleteUserGroup: (id: string) =>
|
||
request<void>(`/api/v1/user-groups/${id}`, { method: 'DELETE' }),
|
||
|
||
// --- алерти ---
|
||
|
||
listAlerts: () =>
|
||
request<{ alerts: Alert[]; counts: AlertCounts }>('/api/v1/alerts').then((r) => ({
|
||
alerts: r.alerts ?? [],
|
||
counts: r.counts ?? { firing: 0, acknowledged: 0, suppressed: 0, disaster: 0, high: 0 },
|
||
})),
|
||
|
||
ackAlert: (id: string, comment = '') =>
|
||
request<Alert>(`/api/v1/alerts/${id}/ack`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ comment }),
|
||
}),
|
||
|
||
closeAlert: (id: string) =>
|
||
request<void>(`/api/v1/alerts/${id}/close`, { method: 'POST' }),
|
||
|
||
muteDevice: (deviceID: string, minutes: number, reason = '') =>
|
||
request<{ until: string }>('/api/v1/mutes', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ device_id: deviceID, minutes, reason }),
|
||
}),
|
||
|
||
listAlertRules: () =>
|
||
request<{ rules: AlertRule[] }>('/api/v1/alert-rules').then((r) => r.rules ?? []),
|
||
|
||
createAlertRule: (rule: {
|
||
name: string
|
||
source: string
|
||
severity: string
|
||
condition: Record<string, unknown>
|
||
selector?: Record<string, unknown>
|
||
for_seconds: number
|
||
depends_on_topology: boolean
|
||
}) =>
|
||
request<{ id: string }>('/api/v1/alert-rules', {
|
||
method: 'POST',
|
||
body: JSON.stringify(rule),
|
||
}),
|
||
|
||
setRuleEnabled: (id: string, enabled: boolean) =>
|
||
request<void>(`/api/v1/alert-rules/${id}`, {
|
||
method: 'PATCH',
|
||
body: JSON.stringify({ enabled }),
|
||
}),
|
||
|
||
deleteAlertRule: (id: string) =>
|
||
request<void>(`/api/v1/alert-rules/${id}`, { method: 'DELETE' }),
|
||
|
||
// --- канали доставки ---
|
||
|
||
listChannels: () =>
|
||
request<{ channels: Channel[] }>('/api/v1/channels').then((r) => r.channels ?? []),
|
||
|
||
createChannel: (ch: {
|
||
kind: string
|
||
name: string
|
||
config: Record<string, unknown>
|
||
min_severity: string
|
||
secret?: string
|
||
template?: string
|
||
}) =>
|
||
request<{ id: string }>('/api/v1/channels', {
|
||
method: 'POST',
|
||
body: JSON.stringify(ch),
|
||
}),
|
||
|
||
deleteChannel: (id: string) => request<void>(`/api/v1/channels/${id}`, { method: 'DELETE' }),
|
||
|
||
/** Помилка каналу — це відповідь, а не збій: сервер віддає її кодом 200. */
|
||
testChannel: (id: string) =>
|
||
request<{ ok: boolean; error?: string }>(`/api/v1/channels/${id}/test`, { method: 'POST' }),
|
||
|
||
// --- профіль ---
|
||
|
||
/**
|
||
* Зміна пароля відкликає ВСІ сесії, включно з поточною, тому одразу
|
||
* після успіху сесія чиститься локально. Лишити людину на сторінці з
|
||
* токеном, який доживає свої хвилини, означало б показати їй помилку
|
||
* через 15 хвилин замість чесного «увійдіть заново» зараз.
|
||
*/
|
||
changePassword: async (oldPassword: string, newPassword: string) => {
|
||
await request<void>('/api/v1/auth/password', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ old_password: oldPassword, new_password: newPassword }),
|
||
})
|
||
session.clear()
|
||
},
|
||
}
|