feat(ui): add message cache management page and dashboard enhancements
Some checks failed
CI / Test (1.23) (push) Failing after -30m37s
CI / Test (1.22) (push) Failing after -30m33s
CI / Build (push) Failing after -30m45s
CI / Lint (push) Failing after -30m39s

- 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:
2026-03-05 00:32:57 +02:00
parent 4b44340c58
commit 1490e0b596
47 changed files with 4430 additions and 611 deletions

1
web/.gitignore vendored
View File

@@ -22,3 +22,4 @@ dist-ssr
*.njsproj
*.sln
*.sw?
.gocache/

View File

@@ -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>

View File

@@ -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

File diff suppressed because it is too large Load Diff

BIN
web/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

BIN
web/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

View 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

View File

@@ -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>

View File

@@ -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 */}

View File

@@ -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"}

View File

@@ -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();

View File

@@ -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>
);
}

View File

@@ -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>

View 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>
);
}

View 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>
);
}

View File

@@ -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
View File

@@ -0,0 +1 @@
declare module "swagger-ui-react";