Админка: страницы Тикеты + Мониторинг, роль support в навигации
TicketsPage.tsx — список open/closed тикетов + тред сообщений + ответ + закрытие (owner/moderator/support). ObservabilityPage.tsx — вкладки дашбордов Grafana через iframe за auth-гейтом Caddy, URL берётся из GET /api/v1/config (grafana_url), страница просто не показывает дашборды, пока GRAFANA_PUBLIC_URL не задан на сервере. StaffPage.tsx — форма создания support-аккаунта (только Owner). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+82
-5
@@ -1,6 +1,13 @@
|
||||
export const API_BASE = import.meta.env.VITE_API_BASE || "/api/v1";
|
||||
|
||||
let configPromise: Promise<{ bot_username: string }> | null = null;
|
||||
interface PublicConfig {
|
||||
bot_username: string;
|
||||
/// URL Grafana за auth-гейтом Caddy — `null`, пока GRAFANA_PUBLIC_URL не
|
||||
/// задан на сервере (см. ObservabilityPage.tsx).
|
||||
grafana_url: string | null;
|
||||
}
|
||||
|
||||
let configPromise: Promise<PublicConfig> | null = null;
|
||||
|
||||
/// Юзернейм бота — намеренно не `import.meta.env.VITE_BOT_USERNAME`: этот SPA
|
||||
/// собирается один раз в CI, и один и тот же статический бандл раздаётся и
|
||||
@@ -8,12 +15,13 @@ let configPromise: Promise<{ bot_username: string }> | null = null;
|
||||
/// не различалось бы между стендами (ровно так и вышло на практике: dev видел
|
||||
/// прод-бота, у которого в BotFather другой домен — виджет логина отвечал
|
||||
/// "Bot domain invalid"). `/api/v1/config` читает env на сервере заново на
|
||||
/// каждый запрос, поэтому корректно отдаёт "свой" бот для каждого стенда.
|
||||
export function getPublicConfig(): Promise<{ bot_username: string }> {
|
||||
/// каждый запрос, поэтому корректно отдаёт "свой" бот (и Grafana URL) для
|
||||
/// каждого стенда.
|
||||
export function getPublicConfig(): Promise<PublicConfig> {
|
||||
if (!configPromise) {
|
||||
configPromise = fetch(`${API_BASE}/config`)
|
||||
.then((res) => res.json())
|
||||
.catch(() => ({ bot_username: "ntrnr_vpn_bot" }));
|
||||
.catch(() => ({ bot_username: "ntrnr_vpn_bot", grafana_url: null }));
|
||||
}
|
||||
return configPromise;
|
||||
}
|
||||
@@ -178,7 +186,7 @@ export const adminLogin = async (username: string, password: string) => {
|
||||
};
|
||||
|
||||
export interface StaffRole {
|
||||
role: "owner" | "moderator" | "partner" | "user" | "admin";
|
||||
role: "owner" | "moderator" | "partner" | "support" | "user" | "admin";
|
||||
can_manage_nodes: boolean;
|
||||
username: string | null;
|
||||
partner_code: string | null;
|
||||
@@ -206,6 +214,14 @@ export const createModerator = async (
|
||||
return parseOrThrow(res, "Failed to create moderator");
|
||||
};
|
||||
|
||||
export const createSupport = async (username: string, password: string) => {
|
||||
const res = await apiFetch("/admin/staff/support", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
return parseOrThrow(res, "Failed to create support account");
|
||||
};
|
||||
|
||||
export const createPartner = async (
|
||||
username: string,
|
||||
password: string,
|
||||
@@ -303,6 +319,67 @@ export const requestWithdrawal = async (
|
||||
return parseOrThrow(res, "Failed to submit withdrawal request");
|
||||
};
|
||||
|
||||
// --- Тикеты техподдержки (Owner/Moderator/Support) ---
|
||||
|
||||
export interface SupportTicketSummary {
|
||||
id: string;
|
||||
user_id: string;
|
||||
tg_id: number | null;
|
||||
tg_username: string | null;
|
||||
status: "open" | "closed";
|
||||
message_count: number;
|
||||
last_message_preview: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SupportTicket {
|
||||
id: string;
|
||||
user_id: string;
|
||||
status: "open" | "closed";
|
||||
assigned_to: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SupportMessage {
|
||||
id: string;
|
||||
ticket_id: string;
|
||||
sender_role: "user" | "staff";
|
||||
sender_user_id: string | null;
|
||||
body: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export const fetchTickets = async (
|
||||
status: "open" | "closed" | "all" = "open",
|
||||
): Promise<SupportTicketSummary[]> => {
|
||||
const res = await apiFetch(`/admin/support/tickets?status=${status}`);
|
||||
return parseOrThrow(res, "Failed to fetch tickets");
|
||||
};
|
||||
|
||||
export const fetchTicket = async (
|
||||
id: string,
|
||||
): Promise<{ ticket: SupportTicket; messages: SupportMessage[] }> => {
|
||||
const res = await apiFetch(`/admin/support/tickets/${id}`);
|
||||
return parseOrThrow(res, "Failed to fetch ticket");
|
||||
};
|
||||
|
||||
export const replyToTicket = async (id: string, text: string) => {
|
||||
const res = await apiFetch(`/admin/support/tickets/${id}/reply`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ text }),
|
||||
});
|
||||
return parseOrThrow(res, "Failed to send reply");
|
||||
};
|
||||
|
||||
export const closeTicket = async (id: string) => {
|
||||
const res = await apiFetch(`/admin/support/tickets/${id}/close`, {
|
||||
method: "PATCH",
|
||||
});
|
||||
return parseOrThrow(res, "Failed to close ticket");
|
||||
};
|
||||
|
||||
export const logout = async () => {
|
||||
await fetch(`${API_BASE}/auth/logout`, {
|
||||
method: "POST",
|
||||
|
||||
Reference in New Issue
Block a user