feat(ui): add message cache management page and dashboard enhancements
- Introduced MessageCachePage for browsing and managing cached webhook events. - Enhanced DashboardPage to display runtime stats and message cache information. - Added new API types for message cache events and system stats. - Integrated SwaggerPage for API documentation and live request testing.
This commit is contained in:
1
web/.gitignore
vendored
1
web/.gitignore
vendored
@@ -22,3 +22,4 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
.gocache/
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>web</title>
|
||||
</head>
|
||||
|
||||
@@ -19,12 +19,13 @@
|
||||
"@tabler/icons-react": "^3.36.1",
|
||||
"@tanstack/react-query": "^5.90.20",
|
||||
"@warkypublic/oranguru": "^0.0.49",
|
||||
"@warkypublic/resolvespec-js": "^1.0.1",
|
||||
"axios": "^1.13.4",
|
||||
"dayjs": "^1.11.19",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"@warkypublic/resolvespec-js": "^1.0.1",
|
||||
"swagger-ui-react": "^5.32.0",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -41,4 +42,4 @@
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1455
web/pnpm-lock.yaml
generated
1455
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
BIN
web/public/favicon.ico
Normal file
BIN
web/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
BIN
web/public/logo.png
Normal file
BIN
web/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 114 KiB |
4
web/public/swagger-icon.svg
Normal file
4
web/public/swagger-icon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Swagger icon">
|
||||
<circle cx="32" cy="32" r="30" fill="#85EA2D"/>
|
||||
<path d="M17 24c0-4.97 4.03-9 9-9h12v8H26a1 1 0 0 0 0 2h12c4.97 0 9 4.03 9 9s-4.03 9-9 9H26v6h-8V40h20a1 1 0 0 0 0-2H26c-4.97 0-9-4.03-9-9 0-2.2.79-4.22 2.1-5.8A8.94 8.94 0 0 0 17 24z" fill="#173647"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 359 B |
@@ -1,29 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>WhatsHooked API</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
|
||||
<style>
|
||||
body { margin: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
||||
<script>
|
||||
SwaggerUIBundle({
|
||||
url: "../api.json",
|
||||
dom_id: "#swagger-ui",
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIBundle.SwaggerUIStandalonePreset,
|
||||
],
|
||||
layout: "BaseLayout",
|
||||
deepLinking: true,
|
||||
tryItOutEnabled: true,
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, lazy, Suspense } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import { Notifications } from '@mantine/notifications';
|
||||
@@ -11,11 +11,13 @@ import UsersPage from './pages/UsersPage';
|
||||
import HooksPage from './pages/HooksPage';
|
||||
import AccountsPage from './pages/AccountsPage';
|
||||
import EventLogsPage from './pages/EventLogsPage';
|
||||
import MessageCachePage from './pages/MessageCachePage';
|
||||
import SendMessagePage from './pages/SendMessagePage';
|
||||
import WhatsAppBusinessPage from './pages/WhatsAppBusinessPage';
|
||||
import TemplateManagementPage from './pages/TemplateManagementPage';
|
||||
import CatalogManagementPage from './pages/CatalogManagementPage';
|
||||
import FlowManagementPage from './pages/FlowManagementPage';
|
||||
const SwaggerPage = lazy(() => import('./pages/SwaggerPage'));
|
||||
|
||||
// Import Mantine styles
|
||||
import '@mantine/core/styles.css';
|
||||
@@ -55,6 +57,12 @@ function App() {
|
||||
<Route path="flows" element={<FlowManagementPage />} />
|
||||
<Route path="send-message" element={<SendMessagePage />} />
|
||||
<Route path="event-logs" element={<EventLogsPage />} />
|
||||
<Route path="message-cache" element={<MessageCachePage />} />
|
||||
<Route path="sw" element={
|
||||
<Suspense fallback={null}>
|
||||
<SwaggerPage />
|
||||
</Suspense>
|
||||
} />
|
||||
</Route>
|
||||
|
||||
{/* Catch all */}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Button,
|
||||
Avatar,
|
||||
Stack,
|
||||
Badge,
|
||||
Image,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import {
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
IconCategory,
|
||||
IconArrowsShuffle,
|
||||
IconFileText,
|
||||
IconDatabase,
|
||||
IconLogout,
|
||||
} from "@tabler/icons-react";
|
||||
import { useAuthStore } from "../stores/authStore";
|
||||
@@ -40,6 +41,14 @@ export default function DashboardLayout() {
|
||||
const isActive = (path: string) => location.pathname === path;
|
||||
const isAnyActive = (paths: string[]) =>
|
||||
paths.some((path) => location.pathname === path);
|
||||
const displayName =
|
||||
user?.username?.trim() ||
|
||||
user?.full_name?.trim() ||
|
||||
user?.email?.trim() ||
|
||||
"User";
|
||||
const displayInitial = displayName[0]?.toUpperCase() || "U";
|
||||
const logoSrc = `${import.meta.env.BASE_URL}logo.png`;
|
||||
const swaggerIconSrc = `${import.meta.env.BASE_URL}swagger-icon.svg`;
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
@@ -60,19 +69,17 @@ export default function DashboardLayout() {
|
||||
hiddenFrom="sm"
|
||||
size="sm"
|
||||
/>
|
||||
<Image src={logoSrc} alt="WhatsHooked logo" w={24} h={24} fit="contain" />
|
||||
<Text size="xl" fw={700}>
|
||||
WhatsHooked
|
||||
</Text>
|
||||
<Badge color="blue" variant="light">
|
||||
Admin
|
||||
</Badge>
|
||||
</Group>
|
||||
<Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{user?.username || "User"}
|
||||
{displayName}
|
||||
</Text>
|
||||
<Avatar color="blue" radius="xl" size="sm">
|
||||
{user?.username?.[0]?.toUpperCase() || "U"}
|
||||
{displayInitial}
|
||||
</Avatar>
|
||||
</Group>
|
||||
</Group>
|
||||
@@ -222,6 +229,32 @@ export default function DashboardLayout() {
|
||||
if (opened) toggle();
|
||||
}}
|
||||
/>
|
||||
<NavLink
|
||||
href="/message-cache"
|
||||
label="Message Cache"
|
||||
leftSection={
|
||||
<IconDatabase size={20} stroke={1.5} color="indigo" />
|
||||
}
|
||||
active={isActive("/message-cache")}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
navigate("/message-cache");
|
||||
if (opened) toggle();
|
||||
}}
|
||||
/>
|
||||
<NavLink
|
||||
href="/sw"
|
||||
label="Swagger"
|
||||
leftSection={
|
||||
<Image src={swaggerIconSrc} alt="Swagger" w={18} h={18} fit="contain" />
|
||||
}
|
||||
active={isActive("/sw")}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
navigate("/sw");
|
||||
if (opened) toggle();
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</AppShell.Section>
|
||||
|
||||
@@ -230,7 +263,7 @@ export default function DashboardLayout() {
|
||||
<Group justify="space-between" px="sm">
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
{user?.username || "User"}
|
||||
{displayName}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{user?.role || "user"}
|
||||
|
||||
@@ -15,6 +15,10 @@ import type {
|
||||
APIKey,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
MessageCacheListResponse,
|
||||
MessageCacheStats,
|
||||
SystemStats,
|
||||
WhatsAppAccountRuntimeStatus,
|
||||
} from "../types";
|
||||
|
||||
function getApiBaseUrl(): string {
|
||||
@@ -26,6 +30,47 @@ function getApiBaseUrl(): string {
|
||||
|
||||
const API_BASE_URL = getApiBaseUrl();
|
||||
|
||||
function normalizeUser(raw: unknown): User | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const value = raw as Record<string, unknown>;
|
||||
|
||||
const resolvedUsername =
|
||||
(typeof value.username === "string" && value.username) ||
|
||||
(typeof value.user_name === "string" && value.user_name) ||
|
||||
(typeof value.full_name === "string" && value.full_name) ||
|
||||
(typeof value.email === "string" && value.email.split("@")[0]) ||
|
||||
"User";
|
||||
|
||||
const resolvedRole =
|
||||
(typeof value.role === "string" && value.role) ||
|
||||
(Array.isArray(value.roles) && typeof value.roles[0] === "string" && value.roles[0]) ||
|
||||
"user";
|
||||
|
||||
const claims =
|
||||
value.claims && typeof value.claims === "object"
|
||||
? (value.claims as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
const resolvedID =
|
||||
(typeof value.id === "string" && value.id) ||
|
||||
(typeof value.user_id === "string" && value.user_id) ||
|
||||
(claims && typeof claims.user_id === "string" && claims.user_id) ||
|
||||
"0";
|
||||
|
||||
const normalized: User = {
|
||||
id: resolvedID,
|
||||
username: resolvedUsername,
|
||||
email: typeof value.email === "string" ? value.email : "",
|
||||
full_name: typeof value.full_name === "string" ? value.full_name : undefined,
|
||||
role: resolvedRole === "admin" ? "admin" : "user",
|
||||
active: typeof value.active === "boolean" ? value.active : true,
|
||||
created_at: typeof value.created_at === "string" ? value.created_at : "",
|
||||
updated_at: typeof value.updated_at === "string" ? value.updated_at : "",
|
||||
};
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
private client: AxiosInstance;
|
||||
|
||||
@@ -82,7 +127,11 @@ class ApiClient {
|
||||
);
|
||||
if (data.token) {
|
||||
this.setToken(data.token);
|
||||
localStorage.setItem("user", JSON.stringify(data.user));
|
||||
const normalizedUser = normalizeUser(data.user);
|
||||
if (normalizedUser) {
|
||||
localStorage.setItem("user", JSON.stringify(normalizedUser));
|
||||
data.user = normalizedUser;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
@@ -97,7 +146,12 @@ class ApiClient {
|
||||
|
||||
getCurrentUser(): User | null {
|
||||
const userStr = localStorage.getItem("user");
|
||||
return userStr ? JSON.parse(userStr) : null;
|
||||
if (!userStr) return null;
|
||||
try {
|
||||
return normalizeUser(JSON.parse(userStr));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
isAuthenticated(): boolean {
|
||||
@@ -213,6 +267,13 @@ class ApiClient {
|
||||
return data;
|
||||
}
|
||||
|
||||
async getAccountStatuses(): Promise<{ statuses: WhatsAppAccountRuntimeStatus[] }> {
|
||||
const { data } = await this.client.get<{ statuses: WhatsAppAccountRuntimeStatus[] }>(
|
||||
"/api/accounts/status",
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
async addAccountConfig(
|
||||
account: WhatsAppAccountConfig,
|
||||
): Promise<{ status: string; account_id: string }> {
|
||||
@@ -377,6 +438,73 @@ class ApiClient {
|
||||
return data;
|
||||
}
|
||||
|
||||
// Message cache API
|
||||
async getMessageCacheEvents(params?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
eventType?: string;
|
||||
}): Promise<MessageCacheListResponse> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.limit) searchParams.set("limit", String(params.limit));
|
||||
if (params?.offset !== undefined) searchParams.set("offset", String(params.offset));
|
||||
if (params?.eventType) searchParams.set("event_type", params.eventType);
|
||||
|
||||
const query = searchParams.toString();
|
||||
const url = query ? `/api/cache?${query}` : "/api/cache";
|
||||
const { data } = await this.client.get<MessageCacheListResponse>(url);
|
||||
return data;
|
||||
}
|
||||
|
||||
async getMessageCacheStats(): Promise<MessageCacheStats> {
|
||||
const { data } = await this.client.get<MessageCacheStats>("/api/cache/stats");
|
||||
return data;
|
||||
}
|
||||
|
||||
async replayCachedEvent(id: string): Promise<{ success: boolean; event_id: string; message: string }> {
|
||||
const { data } = await this.client.post<{ success: boolean; event_id: string; message: string }>(
|
||||
`/api/cache/event/replay?id=${encodeURIComponent(id)}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
async deleteCachedEvent(id: string): Promise<{ success: boolean; event_id: string; message: string }> {
|
||||
const { data } = await this.client.delete<{ success: boolean; event_id: string; message: string }>(
|
||||
`/api/cache/event/delete?id=${encodeURIComponent(id)}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
async replayAllCachedEvents(): Promise<{
|
||||
success: boolean;
|
||||
replayed: number;
|
||||
delivered: number;
|
||||
failed: number;
|
||||
remaining_cached: number;
|
||||
}> {
|
||||
const { data } = await this.client.post<{
|
||||
success: boolean;
|
||||
replayed: number;
|
||||
delivered: number;
|
||||
failed: number;
|
||||
remaining_cached: number;
|
||||
}>("/api/cache/replay");
|
||||
return data;
|
||||
}
|
||||
|
||||
async clearMessageCache(): Promise<{ success: boolean; cleared: number; message: string }> {
|
||||
const { data } = await this.client.delete<{ success: boolean; cleared: number; message: string }>(
|
||||
"/api/cache/clear?confirm=true",
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
async getQRCode(accountId: string): Promise<Blob> {
|
||||
const { data } = await this.client.get<Blob>(`/api/qr/${encodeURIComponent(accountId)}`, {
|
||||
responseType: "blob",
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
// API Keys API
|
||||
async getAPIKeys(): Promise<APIKey[]> {
|
||||
const { data } = await this.client.get<APIKey[]>("/api/v1/api_keys");
|
||||
@@ -397,6 +525,11 @@ class ApiClient {
|
||||
const { data } = await this.client.get<{ status: string }>("/health");
|
||||
return data;
|
||||
}
|
||||
|
||||
async getSystemStats(): Promise<SystemStats> {
|
||||
const { data } = await this.client.get<SystemStats>("/api/v1/system/stats");
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Title,
|
||||
@@ -10,74 +10,127 @@ import {
|
||||
Modal,
|
||||
TextInput,
|
||||
Select,
|
||||
Textarea,
|
||||
Checkbox,
|
||||
Stack,
|
||||
Alert,
|
||||
Loader,
|
||||
Center,
|
||||
ActionIcon
|
||||
ActionIcon,
|
||||
Code,
|
||||
Anchor
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconEdit, IconTrash, IconPlus, IconAlertCircle, IconBrandWhatsapp } from '@tabler/icons-react';
|
||||
import { IconEdit, IconTrash, IconPlus, IconAlertCircle, IconBrandWhatsapp, IconQrcode } from '@tabler/icons-react';
|
||||
import { apiClient } from '../lib/api';
|
||||
import type { WhatsAppAccount, WhatsAppAccountConfig } from '../types';
|
||||
import type { BusinessAPIConfig, WhatsAppAccount, WhatsAppAccountConfig } from '../types';
|
||||
|
||||
function buildSessionPath(accountId: string) {
|
||||
return `./sessions/${accountId}`;
|
||||
}
|
||||
|
||||
function toPrettyJSON(value: unknown) {
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function sortAccountsAlphabetically(accounts: WhatsAppAccount[]): WhatsAppAccount[] {
|
||||
return [...accounts].sort((a, b) =>
|
||||
(a.account_id || a.id).localeCompare((b.account_id || b.id), undefined, { sensitivity: 'base' }),
|
||||
);
|
||||
}
|
||||
|
||||
function mergeAccounts(
|
||||
configuredAccounts: WhatsAppAccountConfig[],
|
||||
databaseAccounts: WhatsAppAccount[],
|
||||
): WhatsAppAccount[] {
|
||||
const databaseAccountsById = new Map(
|
||||
databaseAccounts.map((account) => [account.id, account]),
|
||||
);
|
||||
function mapConfigToAccount(configuredAccount: WhatsAppAccountConfig): WhatsAppAccount {
|
||||
return {
|
||||
id: configuredAccount.id,
|
||||
account_id: configuredAccount.id,
|
||||
user_id: '',
|
||||
phone_number: configuredAccount.phone_number || '',
|
||||
display_name: '',
|
||||
account_type: configuredAccount.type || 'whatsmeow',
|
||||
status: configuredAccount.status || 'disconnected',
|
||||
show_qr: configuredAccount.show_qr,
|
||||
business_api: configuredAccount.business_api,
|
||||
config: configuredAccount.business_api ? JSON.stringify(configuredAccount.business_api, null, 2) : '',
|
||||
session_path: configuredAccount.session_path || buildSessionPath(configuredAccount.id),
|
||||
last_connected_at: undefined,
|
||||
active: !configuredAccount.disabled,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
};
|
||||
}
|
||||
|
||||
const mergedAccounts = configuredAccounts.map((configuredAccount) => {
|
||||
const databaseAccount = databaseAccountsById.get(configuredAccount.id);
|
||||
type BusinessAPIFormData = {
|
||||
phone_number_id: string;
|
||||
access_token: string;
|
||||
waba_id: string;
|
||||
business_account_id: string;
|
||||
api_version: string;
|
||||
webhook_path: string;
|
||||
verify_token: string;
|
||||
};
|
||||
|
||||
return {
|
||||
...databaseAccount,
|
||||
id: configuredAccount.id,
|
||||
account_id: configuredAccount.id,
|
||||
user_id: databaseAccount?.user_id || '',
|
||||
phone_number: configuredAccount.phone_number || databaseAccount?.phone_number || '',
|
||||
display_name: databaseAccount?.display_name || '',
|
||||
account_type: configuredAccount.type || databaseAccount?.account_type || 'whatsmeow',
|
||||
status: databaseAccount?.status || 'disconnected',
|
||||
config: configuredAccount.business_api
|
||||
? toPrettyJSON(configuredAccount.business_api)
|
||||
: (databaseAccount?.config || ''),
|
||||
session_path: configuredAccount.session_path || databaseAccount?.session_path || buildSessionPath(configuredAccount.id),
|
||||
last_connected_at: databaseAccount?.last_connected_at,
|
||||
active: !configuredAccount.disabled,
|
||||
created_at: databaseAccount?.created_at || '',
|
||||
updated_at: databaseAccount?.updated_at || '',
|
||||
};
|
||||
});
|
||||
function emptyBusinessAPIFormData(): BusinessAPIFormData {
|
||||
return {
|
||||
phone_number_id: '',
|
||||
access_token: '',
|
||||
waba_id: '',
|
||||
business_account_id: '',
|
||||
api_version: 'v21.0',
|
||||
webhook_path: '',
|
||||
verify_token: '',
|
||||
};
|
||||
}
|
||||
|
||||
const configuredIds = new Set(configuredAccounts.map((account) => account.id));
|
||||
const orphanedDatabaseAccounts = databaseAccounts
|
||||
.filter((account) => !configuredIds.has(account.id))
|
||||
.map((account) => ({
|
||||
...account,
|
||||
account_id: account.account_id || account.id,
|
||||
}));
|
||||
function toBusinessAPIFormData(config?: BusinessAPIConfig): BusinessAPIFormData {
|
||||
return {
|
||||
phone_number_id: typeof config?.phone_number_id === 'string' ? config.phone_number_id : '',
|
||||
access_token: typeof config?.access_token === 'string' ? config.access_token : '',
|
||||
waba_id: typeof config?.waba_id === 'string' ? config.waba_id : '',
|
||||
business_account_id: typeof config?.business_account_id === 'string' ? config.business_account_id : '',
|
||||
api_version: typeof config?.api_version === 'string' && config.api_version ? config.api_version : 'v21.0',
|
||||
webhook_path: typeof config?.webhook_path === 'string' ? config.webhook_path : '',
|
||||
verify_token: typeof config?.verify_token === 'string' ? config.verify_token : '',
|
||||
};
|
||||
}
|
||||
|
||||
return sortAccountsAlphabetically([...mergedAccounts, ...orphanedDatabaseAccounts]);
|
||||
function fromBusinessAPIFormData(form: BusinessAPIFormData): BusinessAPIConfig {
|
||||
const payload: BusinessAPIConfig = {};
|
||||
|
||||
const phoneNumberID = form.phone_number_id.trim();
|
||||
const accessToken = form.access_token.trim();
|
||||
const wabaID = form.waba_id.trim();
|
||||
const businessAccountID = form.business_account_id.trim();
|
||||
const apiVersion = form.api_version.trim();
|
||||
const webhookPath = form.webhook_path.trim();
|
||||
const verifyToken = form.verify_token.trim();
|
||||
|
||||
if (phoneNumberID) payload.phone_number_id = phoneNumberID;
|
||||
if (accessToken) payload.access_token = accessToken;
|
||||
if (wabaID) payload.waba_id = wabaID;
|
||||
if (businessAccountID) payload.business_account_id = businessAccountID;
|
||||
if (apiVersion) payload.api_version = apiVersion;
|
||||
if (webhookPath) payload.webhook_path = webhookPath;
|
||||
if (verifyToken) payload.verify_token = verifyToken;
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function parseLegacyBusinessAPIConfig(configJSON?: string): BusinessAPIConfig | undefined {
|
||||
if (!configJSON) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(configJSON);
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
return parsed as BusinessAPIConfig;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function getConnectionStatus(
|
||||
account: WhatsAppAccount,
|
||||
): string {
|
||||
return account.status;
|
||||
}
|
||||
|
||||
export default function AccountsPage() {
|
||||
@@ -85,58 +138,72 @@ export default function AccountsPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const [qrModalOpened, { open: openQRModal, close: closeQRModal }] = useDisclosure(false);
|
||||
const [editingAccount, setEditingAccount] = useState<WhatsAppAccount | null>(null);
|
||||
const [selectedQRAccount, setSelectedQRAccount] = useState<WhatsAppAccount | null>(null);
|
||||
const [qrImageError, setQRImageError] = useState<string | null>(null);
|
||||
const [qrRefreshTick, setQRRefreshTick] = useState(0);
|
||||
const [formData, setFormData] = useState({
|
||||
account_id: '',
|
||||
phone_number: '',
|
||||
display_name: '',
|
||||
account_type: 'whatsmeow' as 'whatsmeow' | 'business-api',
|
||||
config: '',
|
||||
business_api: emptyBusinessAPIFormData(),
|
||||
active: true
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadAccounts();
|
||||
}, []);
|
||||
|
||||
const loadAccounts = async () => {
|
||||
const loadAccounts = useCallback(async (showLoader = true) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [configuredAccounts, databaseAccounts] = await Promise.all([
|
||||
apiClient.getAccountConfigs(),
|
||||
apiClient.getAccounts(),
|
||||
]);
|
||||
setAccounts(mergeAccounts(configuredAccounts || [], databaseAccounts || []));
|
||||
if (showLoader) {
|
||||
setLoading(true);
|
||||
}
|
||||
const configuredAccounts = await apiClient.getAccountConfigs();
|
||||
setAccounts(sortAccountsAlphabetically((configuredAccounts || []).map(mapConfigToAccount)));
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Failed to load accounts');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (showLoader) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadAccounts();
|
||||
}, [loadAccounts]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
void loadAccounts(false);
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [loadAccounts]);
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingAccount(null);
|
||||
setFormData({
|
||||
account_id: '',
|
||||
phone_number: '',
|
||||
display_name: '',
|
||||
account_type: 'whatsmeow',
|
||||
config: '',
|
||||
active: true
|
||||
setFormData({
|
||||
account_id: '',
|
||||
phone_number: '',
|
||||
display_name: '',
|
||||
account_type: 'whatsmeow',
|
||||
business_api: emptyBusinessAPIFormData(),
|
||||
active: true
|
||||
});
|
||||
open();
|
||||
};
|
||||
|
||||
const handleEdit = (account: WhatsAppAccount) => {
|
||||
setEditingAccount(account);
|
||||
const accountBusinessAPI = account.business_api || parseLegacyBusinessAPIConfig(account.config);
|
||||
setFormData({
|
||||
account_id: account.account_id || account.id || '',
|
||||
phone_number: account.phone_number,
|
||||
display_name: account.display_name || '',
|
||||
account_type: account.account_type,
|
||||
config: account.config || '',
|
||||
business_api: toBusinessAPIFormData(accountBusinessAPI),
|
||||
active: account.active
|
||||
});
|
||||
open();
|
||||
@@ -164,22 +231,50 @@ export default function AccountsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const getQRCodePath = (accountId: string) => `/api/qr/${encodeURIComponent(accountId)}`;
|
||||
|
||||
const getQRCodeUrl = (accountId: string) => `${window.location.origin}${getQRCodePath(accountId)}`;
|
||||
|
||||
const handleOpenQRModal = (account: WhatsAppAccount) => {
|
||||
setSelectedQRAccount(account);
|
||||
setQRImageError(null);
|
||||
setQRRefreshTick(0);
|
||||
openQRModal();
|
||||
};
|
||||
|
||||
const handleCloseQRModal = () => {
|
||||
setSelectedQRAccount(null);
|
||||
setQRImageError(null);
|
||||
setQRRefreshTick(0);
|
||||
closeQRModal();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!qrModalOpened || !selectedQRAccount) {
|
||||
return;
|
||||
}
|
||||
|
||||
const refreshInterval = setInterval(() => {
|
||||
setQRRefreshTick((previous) => previous + 1);
|
||||
setQRImageError(null);
|
||||
}, 4000);
|
||||
|
||||
return () => clearInterval(refreshInterval);
|
||||
}, [qrModalOpened, selectedQRAccount]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const accountId = (editingAccount?.id || formData.account_id).trim();
|
||||
const parsedConfig = formData.config ? (() => {
|
||||
try {
|
||||
return JSON.parse(formData.config);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})() : null;
|
||||
const businessAPIPayload = fromBusinessAPIFormData(formData.business_api);
|
||||
|
||||
if (formData.config && parsedConfig === null) {
|
||||
if (
|
||||
formData.account_type === 'business-api' &&
|
||||
(!businessAPIPayload.phone_number_id || !businessAPIPayload.access_token)
|
||||
) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Config must be valid JSON',
|
||||
message: 'Phone Number ID and Access Token are required for Business API accounts',
|
||||
color: 'red',
|
||||
});
|
||||
return;
|
||||
@@ -194,8 +289,8 @@ export default function AccountsPage() {
|
||||
disabled: !formData.active,
|
||||
};
|
||||
|
||||
if (formData.account_type === 'business-api' && parsedConfig) {
|
||||
payload.business_api = parsedConfig;
|
||||
if (formData.account_type === 'business-api') {
|
||||
payload.business_api = businessAPIPayload;
|
||||
}
|
||||
|
||||
if (editingAccount) {
|
||||
@@ -229,6 +324,7 @@ export default function AccountsPage() {
|
||||
switch (status) {
|
||||
case 'connected': return 'green';
|
||||
case 'connecting': return 'yellow';
|
||||
case 'pairing': return 'yellow';
|
||||
case 'disconnected': return 'red';
|
||||
default: return 'gray';
|
||||
}
|
||||
@@ -250,7 +346,7 @@ export default function AccountsPage() {
|
||||
<Alert icon={<IconAlertCircle size={16} />} title="Error" color="red" mb="md">
|
||||
{error}
|
||||
</Alert>
|
||||
<Button onClick={loadAccounts}>Retry</Button>
|
||||
<Button onClick={() => loadAccounts()}>Retry</Button>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -293,19 +389,21 @@ export default function AccountsPage() {
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : (
|
||||
accounts.map((account) => (
|
||||
accounts.map((account) => {
|
||||
const connectionStatus = getConnectionStatus(account);
|
||||
return (
|
||||
<Table.Tr key={account.id}>
|
||||
<Table.Td fw={500}>{account.account_id || '-'}</Table.Td>
|
||||
<Table.Td>{account.phone_number || '-'}</Table.Td>
|
||||
<Table.Td>{account.display_name || '-'}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={account.account_type === 'whatsmeow' ? 'green' : 'blue'} variant="light">
|
||||
{account.account_type === 'whatsmeow' ? 'WhatsApp' : 'Business API'}
|
||||
{account.account_type === 'whatsmeow' ? 'Whatsapp' : 'Meta Business API'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={getStatusColor(account.status)} variant="light">
|
||||
{account.status}
|
||||
<Badge color={getStatusColor(connectionStatus)} variant="light">
|
||||
{connectionStatus}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
@@ -320,6 +418,16 @@ export default function AccountsPage() {
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
{account.account_type === 'whatsmeow' && (
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="teal"
|
||||
onClick={() => handleOpenQRModal(account)}
|
||||
title="View QR code"
|
||||
>
|
||||
<IconQrcode size={16} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="blue"
|
||||
@@ -337,7 +445,8 @@ export default function AccountsPage() {
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
@@ -385,28 +494,106 @@ export default function AccountsPage() {
|
||||
value={formData.account_type}
|
||||
onChange={(value) => setFormData({ ...formData, account_type: value as 'whatsmeow' | 'business-api' })}
|
||||
data={[
|
||||
{ value: 'whatsmeow', label: 'WhatsApp (WhatsMe)' },
|
||||
{ value: 'business-api', label: 'Business API' }
|
||||
{ value: 'whatsmeow', label: 'Whatsapp' },
|
||||
{ value: 'business-api', label: 'Meta Business API' }
|
||||
]}
|
||||
required
|
||||
disabled={!!editingAccount}
|
||||
description="WhatsApp: Personal/WhatsApp Business app connection. Business API: Official WhatsApp Business API"
|
||||
description="Whatsapp: Personal/WhatsApp Business app connection. Meta Business API: Official WhatsApp Business API"
|
||||
/>
|
||||
|
||||
{formData.account_type === 'business-api' && (
|
||||
<Textarea
|
||||
label="Business API Config (JSON)"
|
||||
placeholder={`{
|
||||
"api_key": "your-api-key",
|
||||
"api_url": "https://api.whatsapp.com",
|
||||
"phone_number_id": "123456"
|
||||
}`}
|
||||
value={formData.config}
|
||||
onChange={(e) => setFormData({ ...formData, config: e.target.value })}
|
||||
rows={6}
|
||||
styles={{ input: { fontFamily: 'monospace', fontSize: '13px' } }}
|
||||
description="Business API credentials and configuration"
|
||||
/>
|
||||
<>
|
||||
<TextInput
|
||||
label="Phone Number ID"
|
||||
placeholder="123456789012345"
|
||||
value={formData.business_api.phone_number_id}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
business_api: { ...formData.business_api, phone_number_id: e.target.value },
|
||||
})
|
||||
}
|
||||
required
|
||||
description="Required Meta phone number identifier"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Access Token"
|
||||
placeholder="EAAG..."
|
||||
type="password"
|
||||
value={formData.business_api.access_token}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
business_api: { ...formData.business_api, access_token: e.target.value },
|
||||
})
|
||||
}
|
||||
required
|
||||
description="Required WhatsApp Business API token"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="WABA ID"
|
||||
placeholder="Optional (resolved automatically when omitted)"
|
||||
value={formData.business_api.waba_id}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
business_api: { ...formData.business_api, waba_id: e.target.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Business Account ID"
|
||||
placeholder="Optional Facebook Business Manager ID"
|
||||
value={formData.business_api.business_account_id}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
business_api: { ...formData.business_api, business_account_id: e.target.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="API Version"
|
||||
placeholder="v21.0"
|
||||
value={formData.business_api.api_version}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
business_api: { ...formData.business_api, api_version: e.target.value },
|
||||
})
|
||||
}
|
||||
description="Defaults to v21.0 if empty"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Webhook Path"
|
||||
placeholder="/webhooks/whatsapp/{account}"
|
||||
value={formData.business_api.webhook_path}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
business_api: { ...formData.business_api, webhook_path: e.target.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Verify Token"
|
||||
placeholder="Optional webhook verification token"
|
||||
value={formData.business_api.verify_token}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
business_api: { ...formData.business_api, verify_token: e.target.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Checkbox
|
||||
@@ -422,6 +609,43 @@ export default function AccountsPage() {
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={qrModalOpened}
|
||||
onClose={handleCloseQRModal}
|
||||
title={`QR Code: ${selectedQRAccount?.account_id || selectedQRAccount?.id || ''}`}
|
||||
size="lg"
|
||||
>
|
||||
<Stack>
|
||||
{selectedQRAccount && (
|
||||
<>
|
||||
<Text size="sm" c="dimmed">QR image URL</Text>
|
||||
<Code block>{getQRCodeUrl(selectedQRAccount.id)}</Code>
|
||||
<Anchor
|
||||
href={getQRCodePath(selectedQRAccount.id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Open QR image in new tab
|
||||
</Anchor>
|
||||
{qrImageError ? (
|
||||
<Alert icon={<IconAlertCircle size={16} />} color="yellow" title="QR unavailable">
|
||||
{qrImageError}
|
||||
</Alert>
|
||||
) : (
|
||||
<img
|
||||
src={`${getQRCodePath(selectedQRAccount.id)}?t=${qrRefreshTick}`}
|
||||
alt={`QR code for account ${selectedQRAccount.account_id || selectedQRAccount.id}`}
|
||||
style={{ width: '100%', maxWidth: 420, alignSelf: 'center', borderRadius: 8 }}
|
||||
onError={() =>
|
||||
setQRImageError('No QR code available. The account may already be connected or pairing has not started yet.')
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, memo } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Title,
|
||||
@@ -9,33 +9,48 @@ import {
|
||||
ThemeIcon,
|
||||
Loader,
|
||||
Center,
|
||||
Stack
|
||||
Stack,
|
||||
Image,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconUsers,
|
||||
IconWebhook,
|
||||
IconBrandWhatsapp,
|
||||
IconFileText
|
||||
IconFileText,
|
||||
IconDatabase,
|
||||
IconCpu,
|
||||
IconDeviceDesktopAnalytics,
|
||||
IconNetwork,
|
||||
} from '@tabler/icons-react';
|
||||
import { apiClient } from '../lib/api';
|
||||
|
||||
interface Stats {
|
||||
interface DashboardStats {
|
||||
users: number;
|
||||
hooks: number;
|
||||
accounts: number;
|
||||
eventLogs: number;
|
||||
messageCacheEnabled: boolean;
|
||||
messageCacheCount: number;
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
interface RuntimeStats {
|
||||
goMemoryMB: number;
|
||||
goCPUPercent: number;
|
||||
networkBytesPerSec: number;
|
||||
}
|
||||
|
||||
const StatCard = memo(function StatCard({
|
||||
title,
|
||||
value,
|
||||
icon: Icon,
|
||||
color
|
||||
color,
|
||||
valueColor,
|
||||
}: {
|
||||
title: string;
|
||||
value: number;
|
||||
value: number | string;
|
||||
icon: any;
|
||||
color: string;
|
||||
valueColor?: string;
|
||||
}) {
|
||||
return (
|
||||
<Paper withBorder p="md" radius="md">
|
||||
@@ -44,8 +59,8 @@ function StatCard({
|
||||
<Text c="dimmed" tt="uppercase" fw={700} fz="xs">
|
||||
{title}
|
||||
</Text>
|
||||
<Text fw={700} fz="xl" mt="md">
|
||||
{value.toLocaleString()}
|
||||
<Text fw={700} fz="xl" mt="md" c={valueColor}>
|
||||
{typeof value === 'number' ? value.toLocaleString() : value}
|
||||
</Text>
|
||||
</div>
|
||||
<ThemeIcon
|
||||
@@ -59,51 +74,74 @@ function StatCard({
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [stats, setStats] = useState<Stats>({
|
||||
const logoSrc = `${import.meta.env.BASE_URL}logo.png`;
|
||||
const [stats, setStats] = useState<DashboardStats>({
|
||||
users: 0,
|
||||
hooks: 0,
|
||||
accounts: 0,
|
||||
eventLogs: 0
|
||||
eventLogs: 0,
|
||||
messageCacheEnabled: false,
|
||||
messageCacheCount: 0,
|
||||
});
|
||||
const [runtimeStats, setRuntimeStats] = useState<RuntimeStats>({
|
||||
goMemoryMB: 0,
|
||||
goCPUPercent: 0,
|
||||
networkBytesPerSec: 0,
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
loadDashboardStats();
|
||||
const intervalID = window.setInterval(loadDashboardStats, 60000);
|
||||
return () => window.clearInterval(intervalID);
|
||||
}, []);
|
||||
|
||||
const loadStats = async () => {
|
||||
useEffect(() => {
|
||||
loadRuntimeStats();
|
||||
const intervalID = window.setInterval(loadRuntimeStats, 5000);
|
||||
return () => window.clearInterval(intervalID);
|
||||
}, []);
|
||||
|
||||
const loadDashboardStats = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [usersResult, hooksResult, accountsResult, eventLogsResult] = await Promise.allSettled([
|
||||
const [usersResult, hooksResult, accountsResult, eventLogsResult, messageCacheResult] = await Promise.allSettled([
|
||||
apiClient.getUsers(),
|
||||
apiClient.getHooks(),
|
||||
apiClient.getAccounts(),
|
||||
apiClient.getEventLogs({ limit: 1, offset: 0, sort: '-created_at' })
|
||||
apiClient.getEventLogs({ limit: 1, offset: 0, sort: '-created_at' }),
|
||||
apiClient.getMessageCacheStats(),
|
||||
]);
|
||||
|
||||
const users = usersResult.status === 'fulfilled' ? usersResult.value : [];
|
||||
const hooks = hooksResult.status === 'fulfilled' ? hooksResult.value : [];
|
||||
const accounts = accountsResult.status === 'fulfilled' ? accountsResult.value : [];
|
||||
const eventLogs = eventLogsResult.status === 'fulfilled' ? eventLogsResult.value : null;
|
||||
const messageCache = messageCacheResult.status === 'fulfilled' ? messageCacheResult.value : null;
|
||||
|
||||
const eventLogCount = eventLogs?.meta?.total ?? eventLogs?.data?.length ?? 0;
|
||||
const messageCacheEnabled = !!messageCache?.enabled;
|
||||
const messageCacheCount = messageCache?.total_count ?? messageCache?.count ?? 0;
|
||||
|
||||
setStats({
|
||||
users: users?.length || 0,
|
||||
hooks: hooks?.length || 0,
|
||||
accounts: accounts?.length || 0,
|
||||
eventLogs: eventLogCount
|
||||
eventLogs: eventLogCount,
|
||||
messageCacheEnabled,
|
||||
messageCacheCount,
|
||||
});
|
||||
|
||||
if (usersResult.status === 'rejected' || hooksResult.status === 'rejected' || accountsResult.status === 'rejected' || eventLogsResult.status === 'rejected') {
|
||||
if (usersResult.status === 'rejected' || hooksResult.status === 'rejected' || accountsResult.status === 'rejected' || eventLogsResult.status === 'rejected' || messageCacheResult.status === 'rejected') {
|
||||
console.error('One or more dashboard stats failed to load', {
|
||||
users: usersResult.status === 'rejected' ? usersResult.reason : null,
|
||||
hooks: hooksResult.status === 'rejected' ? hooksResult.reason : null,
|
||||
accounts: accountsResult.status === 'rejected' ? accountsResult.reason : null,
|
||||
eventLogs: eventLogsResult.status === 'rejected' ? eventLogsResult.reason : null,
|
||||
messageCache: messageCacheResult.status === 'rejected' ? messageCacheResult.reason : null,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -113,6 +151,19 @@ export default function DashboardPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadRuntimeStats = async () => {
|
||||
try {
|
||||
const systemStats = await apiClient.getSystemStats();
|
||||
setRuntimeStats({
|
||||
goMemoryMB: Number(systemStats?.go_memory_mb ?? 0),
|
||||
goCPUPercent: Number(systemStats?.go_cpu_percent ?? 0),
|
||||
networkBytesPerSec: Number(systemStats?.network_bytes_per_sec ?? 0),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to load runtime stats:', err);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Container size="xl" py="xl">
|
||||
@@ -127,11 +178,12 @@ export default function DashboardPage() {
|
||||
<Container size="xl" py="xl">
|
||||
<Stack gap="xl">
|
||||
<div>
|
||||
<Image src={logoSrc} alt="WhatsHooked logo" w={120} h={120} fit="contain" mb="sm" />
|
||||
<Title order={2}>Dashboard</Title>
|
||||
<Text c="dimmed" size="sm">Welcome to WhatsHooked Admin Panel</Text>
|
||||
</div>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 3, lg: 4 }}>
|
||||
<StatCard
|
||||
title="Total Users"
|
||||
value={stats.users}
|
||||
@@ -156,6 +208,31 @@ export default function DashboardPage() {
|
||||
icon={IconFileText}
|
||||
color="violet"
|
||||
/>
|
||||
<StatCard
|
||||
title="Message Cache"
|
||||
value={stats.messageCacheEnabled ? stats.messageCacheCount : 'Disabled'}
|
||||
icon={IconDatabase}
|
||||
color={stats.messageCacheEnabled ? 'green' : 'gray'}
|
||||
valueColor={stats.messageCacheEnabled ? undefined : 'dimmed'}
|
||||
/>
|
||||
<StatCard
|
||||
title="Go Memory"
|
||||
value={`${runtimeStats.goMemoryMB.toFixed(2)} MB`}
|
||||
icon={IconDeviceDesktopAnalytics}
|
||||
color="cyan"
|
||||
/>
|
||||
<StatCard
|
||||
title="Go CPU"
|
||||
value={`${runtimeStats.goCPUPercent.toFixed(2)}%`}
|
||||
icon={IconCpu}
|
||||
color="orange"
|
||||
/>
|
||||
<StatCard
|
||||
title="Network Throughput"
|
||||
value={`${(runtimeStats.networkBytesPerSec / 1024).toFixed(2)} KB/s`}
|
||||
icon={IconNetwork}
|
||||
color="indigo"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Container>
|
||||
|
||||
444
web/src/pages/MessageCachePage.tsx
Normal file
444
web/src/pages/MessageCachePage.tsx
Normal file
@@ -0,0 +1,444 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Title,
|
||||
Text,
|
||||
Table,
|
||||
Badge,
|
||||
Group,
|
||||
Alert,
|
||||
Loader,
|
||||
Center,
|
||||
Stack,
|
||||
TextInput,
|
||||
Modal,
|
||||
Code,
|
||||
Button,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconDatabase,
|
||||
IconSearch,
|
||||
IconPlayerPlay,
|
||||
IconTrash,
|
||||
IconRefresh,
|
||||
IconTrashX,
|
||||
} from '@tabler/icons-react';
|
||||
import { apiClient } from '../lib/api';
|
||||
import type { MessageCacheEvent, MessageCacheStats } from '../types';
|
||||
import type { AxiosError } from 'axios';
|
||||
|
||||
const ITEMS_PER_PAGE = 50;
|
||||
|
||||
function getApiErrorMessage(err: unknown, fallback: string): string {
|
||||
const axiosErr = err as AxiosError<unknown>;
|
||||
const responseData = axiosErr?.response?.data;
|
||||
|
||||
if (typeof responseData === 'string' && responseData.trim() !== '') {
|
||||
return responseData.trim();
|
||||
}
|
||||
|
||||
if (responseData && typeof responseData === 'object') {
|
||||
const maybeMessage = (responseData as { message?: unknown }).message;
|
||||
const maybeError = (responseData as { error?: unknown }).error;
|
||||
if (typeof maybeMessage === 'string' && maybeMessage.trim() !== '') {
|
||||
return maybeMessage.trim();
|
||||
}
|
||||
if (typeof maybeError === 'string' && maybeError.trim() !== '') {
|
||||
return maybeError.trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (err instanceof Error && err.message.trim() !== '') {
|
||||
return err.message;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export default function MessageCachePage() {
|
||||
const [cachedEvents, setCachedEvents] = useState<MessageCacheEvent[]>([]);
|
||||
const [stats, setStats] = useState<MessageCacheStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [totalFilteredCount, setTotalFilteredCount] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [eventTypeQuery, setEventTypeQuery] = useState('');
|
||||
const [debouncedEventTypeQuery, setDebouncedEventTypeQuery] = useState('');
|
||||
const [modalTitle, setModalTitle] = useState('Cached Event Data');
|
||||
const [modalContent, setModalContent] = useState('');
|
||||
const [dataModalOpened, { open: openDataModal, close: closeDataModal }] = useDisclosure(false);
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedEventTypeQuery(eventTypeQuery.trim()), 350);
|
||||
return () => clearTimeout(timer);
|
||||
}, [eventTypeQuery]);
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
try {
|
||||
const cacheStats = await apiClient.getMessageCacheStats();
|
||||
setStats(cacheStats);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadCachePage = useCallback(async (targetOffset: number, reset: boolean) => {
|
||||
try {
|
||||
if (reset) {
|
||||
setLoading(true);
|
||||
} else {
|
||||
setLoadingMore(true);
|
||||
}
|
||||
|
||||
const result = await apiClient.getMessageCacheEvents({
|
||||
limit: ITEMS_PER_PAGE,
|
||||
offset: targetOffset,
|
||||
eventType: debouncedEventTypeQuery || undefined,
|
||||
});
|
||||
|
||||
const pageData = result.cached_events || [];
|
||||
const nextOffset = targetOffset + pageData.length;
|
||||
|
||||
if (reset) {
|
||||
setCachedEvents(pageData);
|
||||
} else {
|
||||
setCachedEvents((previousEvents) => [...previousEvents, ...pageData]);
|
||||
}
|
||||
|
||||
setOffset(nextOffset);
|
||||
setTotalFilteredCount(result.filtered_count);
|
||||
setHasMore(nextOffset < result.filtered_count);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(getApiErrorMessage(err, 'Failed to load cached events'));
|
||||
console.error(err);
|
||||
} finally {
|
||||
if (reset) {
|
||||
setLoading(false);
|
||||
} else {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}
|
||||
}, [debouncedEventTypeQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
setCachedEvents([]);
|
||||
setOffset(0);
|
||||
setHasMore(false);
|
||||
setTotalFilteredCount(null);
|
||||
loadCachePage(0, true);
|
||||
loadStats();
|
||||
}, [debouncedEventTypeQuery, loadCachePage, loadStats]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sentinelRef.current || loading || loadingMore || !hasMore || error) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting) {
|
||||
loadCachePage(offset, false);
|
||||
}
|
||||
},
|
||||
{ rootMargin: '250px' },
|
||||
);
|
||||
|
||||
observer.observe(sentinelRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [loading, loadingMore, hasMore, error, offset, loadCachePage]);
|
||||
|
||||
const refreshPage = async () => {
|
||||
await Promise.all([loadCachePage(0, true), loadStats()]);
|
||||
};
|
||||
|
||||
const openDetailsModal = (event: MessageCacheEvent) => {
|
||||
setModalTitle(`Cached Event: ${event.event.type}`);
|
||||
setModalContent(JSON.stringify(event, null, 2));
|
||||
openDataModal();
|
||||
};
|
||||
|
||||
const handleReplayEvent = async (id: string) => {
|
||||
try {
|
||||
setActionLoading(true);
|
||||
await apiClient.replayCachedEvent(id);
|
||||
notifications.show({ title: 'Success', message: 'Cached event replayed', color: 'green' });
|
||||
await refreshPage();
|
||||
} catch (err) {
|
||||
notifications.show({ title: 'Error', message: 'Failed to replay cached event', color: 'red' });
|
||||
console.error(err);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteEvent = async (id: string) => {
|
||||
if (!confirm('Delete this cached event?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setActionLoading(true);
|
||||
await apiClient.deleteCachedEvent(id);
|
||||
notifications.show({ title: 'Success', message: 'Cached event deleted', color: 'green' });
|
||||
await refreshPage();
|
||||
} catch (err) {
|
||||
notifications.show({ title: 'Error', message: 'Failed to delete cached event', color: 'red' });
|
||||
console.error(err);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReplayAll = async () => {
|
||||
if (!confirm('Replay all cached events now?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setActionLoading(true);
|
||||
const result = await apiClient.replayAllCachedEvents();
|
||||
notifications.show({
|
||||
title: 'Replay complete',
|
||||
message: `Replayed ${result.replayed} events (${result.delivered} delivered, ${result.failed} failed)`,
|
||||
color: 'green',
|
||||
});
|
||||
await refreshPage();
|
||||
} catch (err) {
|
||||
notifications.show({ title: 'Error', message: 'Failed to replay cached events', color: 'red' });
|
||||
console.error(err);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearAll = async () => {
|
||||
if (!confirm('Clear all cached events? This cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setActionLoading(true);
|
||||
const result = await apiClient.clearMessageCache();
|
||||
notifications.show({ title: 'Success', message: `Cleared ${result.cleared} cached events`, color: 'green' });
|
||||
await refreshPage();
|
||||
} catch (err) {
|
||||
notifications.show({ title: 'Error', message: 'Failed to clear message cache', color: 'red' });
|
||||
console.error(err);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Container size="xl" py="xl">
|
||||
<Center h={400}>
|
||||
<Loader size="lg" />
|
||||
</Center>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Container size="xl" py="xl">
|
||||
<Alert icon={<IconAlertCircle size={16} />} title="Error" color="red" mb="md">
|
||||
{error}
|
||||
</Alert>
|
||||
<Button onClick={refreshPage}>Retry</Button>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size="xl" py="xl">
|
||||
<Group justify="space-between" mb="xl" align="flex-start">
|
||||
<div>
|
||||
<Title order={2}>Message Cache</Title>
|
||||
<Text c="dimmed" size="sm">Browse and manage cached webhook events with paged loading</Text>
|
||||
<Text c="dimmed" size="sm" mt={4}>
|
||||
Cache status: {stats?.enabled ? 'enabled' : 'disabled'}
|
||||
{typeof stats?.total_count === 'number' ? ` • Total: ${stats.total_count}` : ''}
|
||||
</Text>
|
||||
</div>
|
||||
<Group>
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<IconRefresh size={16} />}
|
||||
onClick={refreshPage}
|
||||
loading={actionLoading}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
color="blue"
|
||||
leftSection={<IconPlayerPlay size={16} />}
|
||||
onClick={handleReplayAll}
|
||||
loading={actionLoading}
|
||||
disabled={cachedEvents.length === 0}
|
||||
>
|
||||
Replay All
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
variant="light"
|
||||
leftSection={<IconTrashX size={16} />}
|
||||
onClick={handleClearAll}
|
||||
loading={actionLoading}
|
||||
disabled={cachedEvents.length === 0}
|
||||
>
|
||||
Clear Cache
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Group mb="md">
|
||||
<TextInput
|
||||
placeholder="Filter by event type (e.g. message.received)"
|
||||
leftSection={<IconSearch size={16} />}
|
||||
value={eventTypeQuery}
|
||||
onChange={(e) => setEventTypeQuery(e.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Table highlightOnHover withTableBorder withColumnBorders>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Cached At</Table.Th>
|
||||
<Table.Th>Event Type</Table.Th>
|
||||
<Table.Th>Reason</Table.Th>
|
||||
<Table.Th>Attempts</Table.Th>
|
||||
<Table.Th>Last Attempt</Table.Th>
|
||||
<Table.Th>Details</Table.Th>
|
||||
<Table.Th>Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{cachedEvents.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={7}>
|
||||
<Center h={200}>
|
||||
<Stack align="center">
|
||||
<IconDatabase size={48} stroke={1.5} color="gray" />
|
||||
<Text c="dimmed">
|
||||
{debouncedEventTypeQuery ? 'No cached events match this filter' : 'No cached events'}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : (
|
||||
cachedEvents.map((cachedEvent) => (
|
||||
<Table.Tr key={cachedEvent.id}>
|
||||
<Table.Td>
|
||||
<Text size="sm">{new Date(cachedEvent.timestamp).toLocaleString()}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light">{cachedEvent.event.type}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{cachedEvent.reason || '-'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={cachedEvent.attempts > 0 ? 'yellow' : 'gray'} variant="light">
|
||||
{cachedEvent.attempts}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">
|
||||
{cachedEvent.last_attempt
|
||||
? new Date(cachedEvent.last_attempt).toLocaleString()
|
||||
: '-'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Code
|
||||
component="button"
|
||||
onClick={() => openDetailsModal(cachedEvent)}
|
||||
style={{ cursor: 'pointer', border: 'none' }}
|
||||
>
|
||||
View
|
||||
</Code>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Tooltip label="Replay event">
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="blue"
|
||||
onClick={() => handleReplayEvent(cachedEvent.id)}
|
||||
loading={actionLoading}
|
||||
>
|
||||
<IconPlayerPlay size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Delete event">
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={() => handleDeleteEvent(cachedEvent.id)}
|
||||
loading={actionLoading}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
<div ref={sentinelRef} />
|
||||
|
||||
{loadingMore && (
|
||||
<Center mt="lg">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
)}
|
||||
|
||||
<Group justify="space-between" mt="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{totalFilteredCount !== null
|
||||
? `Showing ${cachedEvents.length} of ${totalFilteredCount} cached events`
|
||||
: `Showing ${cachedEvents.length} cached events`}
|
||||
</Text>
|
||||
{debouncedEventTypeQuery && (
|
||||
<Text size="sm" c="dimmed">Filtered by: "{debouncedEventTypeQuery}"</Text>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Modal
|
||||
opened={dataModalOpened}
|
||||
onClose={closeDataModal}
|
||||
title={modalTitle}
|
||||
fullScreen
|
||||
>
|
||||
<Code
|
||||
component="pre"
|
||||
block
|
||||
style={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
minHeight: '90vh',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{modalContent}
|
||||
</Code>
|
||||
</Modal>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
21
web/src/pages/SwaggerPage.tsx
Normal file
21
web/src/pages/SwaggerPage.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Container, Stack, Title, Text } from "@mantine/core";
|
||||
import SwaggerUI from "swagger-ui-react";
|
||||
import "swagger-ui-react/swagger-ui.css";
|
||||
|
||||
export default function SwaggerPage() {
|
||||
return (
|
||||
<Container size="xl" py="xl">
|
||||
<Stack gap="sm" mb="md">
|
||||
<Title order={2}>Swagger</Title>
|
||||
<Text c="dimmed" size="sm">
|
||||
API documentation and live request testing.
|
||||
</Text>
|
||||
</Stack>
|
||||
<SwaggerUI
|
||||
url={`${import.meta.env.BASE_URL}openapi.json`}
|
||||
deepLinking
|
||||
tryItOutEnabled
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -34,7 +34,9 @@ export interface WhatsAppAccount {
|
||||
phone_number: string;
|
||||
display_name?: string;
|
||||
account_type: 'whatsmeow' | 'business-api';
|
||||
status: 'connected' | 'disconnected' | 'connecting';
|
||||
status: 'connected' | 'disconnected' | 'connecting' | 'pairing';
|
||||
show_qr?: boolean;
|
||||
business_api?: BusinessAPIConfig;
|
||||
config?: string; // JSON string
|
||||
session_path?: string;
|
||||
last_connected_at?: string;
|
||||
@@ -197,9 +199,20 @@ export interface WhatsAppAccountConfig {
|
||||
session_path?: string;
|
||||
show_qr?: boolean;
|
||||
disabled?: boolean;
|
||||
status?: 'connected' | 'disconnected' | 'connecting' | 'pairing';
|
||||
connected?: boolean;
|
||||
qr_available?: boolean;
|
||||
business_api?: BusinessAPIConfig;
|
||||
}
|
||||
|
||||
export interface WhatsAppAccountRuntimeStatus {
|
||||
account_id: string;
|
||||
type: string;
|
||||
status: 'connected' | 'disconnected' | 'connecting' | 'pairing';
|
||||
connected: boolean;
|
||||
qr_available: boolean;
|
||||
}
|
||||
|
||||
export interface EventLog {
|
||||
id: string;
|
||||
user_id?: string;
|
||||
@@ -215,6 +228,50 @@ export interface EventLog {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface MessageCacheEventPayload {
|
||||
type: string;
|
||||
timestamp: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface MessageCacheEvent {
|
||||
id: string;
|
||||
event: MessageCacheEventPayload;
|
||||
timestamp: string;
|
||||
reason: string;
|
||||
attempts: number;
|
||||
last_attempt?: string;
|
||||
}
|
||||
|
||||
export interface MessageCacheListResponse {
|
||||
cached_events: MessageCacheEvent[];
|
||||
count: number;
|
||||
filtered_count: number;
|
||||
returned_count: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface MessageCacheStats {
|
||||
enabled: boolean;
|
||||
count?: number;
|
||||
total_count?: number;
|
||||
by_event_type?: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface SystemStats {
|
||||
go_memory_bytes: number;
|
||||
go_memory_mb: number;
|
||||
go_sys_memory_bytes: number;
|
||||
go_sys_memory_mb: number;
|
||||
go_goroutines: number;
|
||||
go_cpu_percent: number;
|
||||
network_rx_bytes: number;
|
||||
network_tx_bytes: number;
|
||||
network_total_bytes: number;
|
||||
network_bytes_per_sec: number;
|
||||
}
|
||||
|
||||
export interface APIKey {
|
||||
id: string;
|
||||
user_id: string;
|
||||
|
||||
1
web/src/types/swagger-ui-react.d.ts
vendored
Normal file
1
web/src/types/swagger-ui-react.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module "swagger-ui-react";
|
||||
Reference in New Issue
Block a user