Netpulse_SasS/web/src/api/client.ts
byrsapty 96692ec9df Вхід за логіном, групи хостів і права доступу
Логін:
- 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>
2026-08-24 02:00:04 +03:00

347 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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()
},
}