feat(migrations): Rename indexes and update global integration settings fields for improved clarity and functionality
feat(admin): Add API monitoring, debug console, and system health templates for enhanced admin interface docs: Add AI system cleanup summary and audit report detailing architecture, token management, and recommendations docs: Introduce credits and tokens system guide outlining configuration, data flow, and monitoring strategies
This commit is contained in:
@@ -5,7 +5,6 @@ import AppLayout from "./layout/AppLayout";
|
||||
import { ScrollToTop } from "./components/common/ScrollToTop";
|
||||
import ProtectedRoute from "./components/auth/ProtectedRoute";
|
||||
import ModuleGuard from "./components/common/ModuleGuard";
|
||||
import { AwsAdminGuard } from "./components/auth/AwsAdminGuard";
|
||||
import GlobalErrorDisplay from "./components/common/GlobalErrorDisplay";
|
||||
import LoadingStateMonitor from "./components/common/LoadingStateMonitor";
|
||||
import { useAuthStore } from "./store/authStore";
|
||||
@@ -68,9 +67,6 @@ const AccountSettingsPage = lazy(() => import("./pages/account/AccountSettingsPa
|
||||
const TeamManagementPage = lazy(() => import("./pages/account/TeamManagementPage"));
|
||||
const UsageAnalyticsPage = lazy(() => import("./pages/account/UsageAnalyticsPage"));
|
||||
|
||||
// Admin Module - Only dashboard for aws-admin users
|
||||
const AdminSystemDashboard = lazy(() => import("./pages/admin/AdminSystemDashboard"));
|
||||
|
||||
// Reference Data - Lazy loaded
|
||||
const SeedKeywords = lazy(() => import("./pages/Reference/SeedKeywords"));
|
||||
const ReferenceIndustries = lazy(() => import("./pages/Reference/Industries"));
|
||||
@@ -270,13 +266,6 @@ export default function App() {
|
||||
<Route path="/account/team" element={<TeamManagementPage />} />
|
||||
<Route path="/account/usage" element={<UsageAnalyticsPage />} />
|
||||
|
||||
{/* Admin Routes - Only Dashboard for aws-admin users */}
|
||||
<Route path="/admin/dashboard" element={
|
||||
<AwsAdminGuard>
|
||||
<AdminSystemDashboard />
|
||||
</AwsAdminGuard>
|
||||
} />
|
||||
|
||||
{/* Reference Data */}
|
||||
<Route path="/reference/seed-keywords" element={<SeedKeywords />} />
|
||||
<Route path="/planner/keyword-opportunities" element={<KeywordOpportunities />} />
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
|
||||
interface AwsAdminGuardProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route guard that only allows access to users of the aws-admin account
|
||||
* Used for the single remaining admin dashboard page
|
||||
*/
|
||||
export const AwsAdminGuard: React.FC<AwsAdminGuardProps> = ({ children }) => {
|
||||
const { user, loading } = useAuthStore();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user belongs to aws-admin account
|
||||
const isAwsAdmin = user?.account?.slug === 'aws-admin';
|
||||
|
||||
if (!isAwsAdmin) {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
@@ -32,14 +32,6 @@ interface ImageQueueModalProps {
|
||||
model?: string;
|
||||
provider?: string;
|
||||
onUpdateQueue?: (queue: ImageQueueItem[]) => void;
|
||||
onLog?: (log: {
|
||||
timestamp: string;
|
||||
type: 'request' | 'success' | 'error' | 'step';
|
||||
action: string;
|
||||
data: any;
|
||||
stepName?: string;
|
||||
percentage?: number;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export default function ImageQueueModal({
|
||||
@@ -51,7 +43,6 @@ export default function ImageQueueModal({
|
||||
model,
|
||||
provider,
|
||||
onUpdateQueue,
|
||||
onLog,
|
||||
}: ImageQueueModalProps) {
|
||||
const [localQueue, setLocalQueue] = useState<ImageQueueItem[]>(queue);
|
||||
// Track smooth progress animation for each item
|
||||
@@ -250,43 +241,6 @@ export default function ImageQueueModal({
|
||||
console.log(`[ImageQueueModal] Task completed with state:`, taskState);
|
||||
clearInterval(pollInterval);
|
||||
|
||||
// Log completion status
|
||||
if (onLog) {
|
||||
if (taskState === 'SUCCESS') {
|
||||
const result = data.result || (data.meta && data.meta.result);
|
||||
const completed = result?.completed || 0;
|
||||
const failed = result?.failed || 0;
|
||||
const total = result?.total_images || totalImages;
|
||||
|
||||
onLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
type: failed > 0 ? 'error' : 'success',
|
||||
action: 'generate_images',
|
||||
stepName: 'Task Completed',
|
||||
data: {
|
||||
state: 'SUCCESS',
|
||||
completed,
|
||||
failed,
|
||||
total,
|
||||
results: result?.results || []
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// FAILURE
|
||||
onLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'error',
|
||||
action: 'generate_images',
|
||||
stepName: 'Task Failed',
|
||||
data: {
|
||||
state: 'FAILURE',
|
||||
error: data.error || data.meta?.error || 'Task failed',
|
||||
meta: data.meta
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update final state
|
||||
if (taskState === 'SUCCESS' && data.result) {
|
||||
console.log(`[ImageQueueModal] Updating queue from result:`, data.result);
|
||||
|
||||
@@ -1,435 +0,0 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { API_BASE_URL } from '../../services/api';
|
||||
|
||||
interface RequestMetrics {
|
||||
request_id: string;
|
||||
path: string;
|
||||
method: string;
|
||||
elapsed_time_ms: number;
|
||||
cpu: {
|
||||
user_time_ms: number;
|
||||
system_time_ms: number;
|
||||
total_time_ms: number;
|
||||
system_percent: number;
|
||||
};
|
||||
memory: {
|
||||
delta_bytes: number;
|
||||
delta_mb: number;
|
||||
final_rss_mb: number;
|
||||
system_used_percent: number;
|
||||
};
|
||||
io: {
|
||||
read_bytes: number;
|
||||
read_mb: number;
|
||||
write_bytes: number;
|
||||
write_mb: number;
|
||||
};
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface ResourceDebugOverlayProps {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export default function ResourceDebugOverlay({ enabled }: ResourceDebugOverlayProps) {
|
||||
const { user } = useAuthStore();
|
||||
const [metrics, setMetrics] = useState<RequestMetrics[]>([]);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [pageLoadStart, setPageLoadStart] = useState<number | null>(null);
|
||||
const requestIdRef = useRef<string | null>(null);
|
||||
const metricsRef = useRef<RequestMetrics[]>([]);
|
||||
const originalFetchRef = useRef<typeof fetch | null>(null);
|
||||
const nativeFetchRef = useRef<typeof fetch | null>(null); // Store native fetch separately
|
||||
|
||||
// Check if user is admin/developer
|
||||
const isAdminOrDeveloper = user?.role === 'admin' || user?.role === 'developer';
|
||||
|
||||
// Track page load start and intercept fetch requests
|
||||
useEffect(() => {
|
||||
if (!enabled || !isAdminOrDeveloper) {
|
||||
// Restore native fetch if disabled
|
||||
if (nativeFetchRef.current) {
|
||||
window.fetch = nativeFetchRef.current;
|
||||
nativeFetchRef.current = null;
|
||||
originalFetchRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setPageLoadStart(performance.now());
|
||||
|
||||
// Store native fetch and create bound version
|
||||
if (!nativeFetchRef.current) {
|
||||
nativeFetchRef.current = window.fetch; // Store actual native fetch
|
||||
originalFetchRef.current = window.fetch.bind(window); // Create bound version for calling
|
||||
}
|
||||
|
||||
// Intercept fetch requests to track API calls
|
||||
window.fetch = async function(...args) {
|
||||
const startTime = performance.now();
|
||||
const [url, options = {}] = args;
|
||||
|
||||
// Don't intercept our own metrics fetch calls to avoid infinite loops
|
||||
const urlString = typeof url === 'string' ? url : url.toString();
|
||||
if (urlString.includes('/request-metrics/')) {
|
||||
// Use native fetch directly for metrics calls
|
||||
return nativeFetchRef.current!.apply(window, args as [RequestInfo | URL, RequestInit?]);
|
||||
}
|
||||
|
||||
// Add debug header to enable tracking
|
||||
const headers = new Headers(options.headers || {});
|
||||
headers.set('X-Debug-Resource-Tracking', 'true');
|
||||
|
||||
// Use bound fetch to preserve context
|
||||
const response = await originalFetchRef.current!(url, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
const endTime = performance.now();
|
||||
|
||||
// Get request ID from response header
|
||||
const requestId = response.headers.get('X-Resource-Tracking-ID');
|
||||
if (requestId) {
|
||||
requestIdRef.current = requestId;
|
||||
// Fetch metrics after a delay to ensure backend has stored them
|
||||
// Use a slightly longer delay to avoid race conditions
|
||||
setTimeout(() => fetchRequestMetrics(requestId), 300);
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
return () => {
|
||||
// Restore native fetch on cleanup
|
||||
if (nativeFetchRef.current) {
|
||||
window.fetch = nativeFetchRef.current;
|
||||
nativeFetchRef.current = null;
|
||||
originalFetchRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [enabled, isAdminOrDeveloper]);
|
||||
|
||||
// Fetch metrics for a request - use fetchAPI to get proper authentication handling
|
||||
const fetchRequestMetrics = async (requestId: string, retryCount = 0) => {
|
||||
try {
|
||||
// Use fetchAPI which handles token refresh and authentication properly
|
||||
// But we need to use native fetch to avoid interception loop
|
||||
const nativeFetch = nativeFetchRef.current || window.fetch;
|
||||
const { token } = useAuthStore.getState();
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
// Add JWT token if available
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// Silently handle 404s and other errors - metrics might not exist for all requests
|
||||
try {
|
||||
const response = await nativeFetch.call(window, `${API_BASE_URL}/v1/system/request-metrics/${requestId}/`, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
credentials: 'include', // Include session cookies for authentication
|
||||
});
|
||||
|
||||
// Silently ignore 404s - metrics endpoint might not exist for all requests
|
||||
if (response.status === 404) {
|
||||
return; // Don't log or retry 404s
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
const responseData = await response.json();
|
||||
// Extract data from unified API response format: {success: true, data: {...}}
|
||||
const data = responseData?.data || responseData;
|
||||
// Only log in debug mode to reduce console noise
|
||||
if (import.meta.env.DEV) {
|
||||
console.debug('Fetched metrics for request:', requestId, data);
|
||||
}
|
||||
// Validate data structure before adding
|
||||
if (data && typeof data === 'object' && data.request_id) {
|
||||
metricsRef.current = [...metricsRef.current, data];
|
||||
setMetrics([...metricsRef.current]);
|
||||
}
|
||||
} else if (response.status === 401) {
|
||||
// Token might be expired - try to refresh and retry once
|
||||
try {
|
||||
await useAuthStore.getState().refreshToken();
|
||||
const newToken = useAuthStore.getState().token;
|
||||
if (newToken) {
|
||||
const retryHeaders: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${newToken}`,
|
||||
};
|
||||
const retryResponse = await nativeFetch.call(window, `${API_BASE_URL}/v1/system/request-metrics/${requestId}/`, {
|
||||
method: 'GET',
|
||||
headers: retryHeaders,
|
||||
credentials: 'include',
|
||||
});
|
||||
if (retryResponse.ok) {
|
||||
const responseData = await retryResponse.json();
|
||||
// Extract data from unified API response format: {success: true, data: {...}}
|
||||
const data = responseData?.data || responseData;
|
||||
// Validate data structure before adding
|
||||
if (data && typeof data === 'object' && data.request_id) {
|
||||
metricsRef.current = [...metricsRef.current, data];
|
||||
setMetrics([...metricsRef.current]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (refreshError) {
|
||||
// Refresh failed - silently ignore
|
||||
}
|
||||
// Silently ignore 401 errors - user might not be authenticated
|
||||
} else if (response.status === 404) {
|
||||
// Metrics not found - silently ignore (metrics might not exist for all requests)
|
||||
return;
|
||||
} else {
|
||||
// Other errors - silently ignore
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently ignore all fetch errors (network errors, etc.)
|
||||
// Metrics are optional and not critical for functionality
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently ignore all errors
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate page load time
|
||||
const pageLoadTime = pageLoadStart ? performance.now() - pageLoadStart : null;
|
||||
|
||||
// Calculate totals - with null safety checks
|
||||
const totals = metrics.reduce((acc, m) => {
|
||||
// Safely access nested properties with defaults
|
||||
const elapsed = m?.elapsed_time_ms || 0;
|
||||
const cpuTotal = m?.cpu?.total_time_ms || 0;
|
||||
const memoryDelta = m?.memory?.delta_mb || 0;
|
||||
const ioRead = m?.io?.read_mb || 0;
|
||||
const ioWrite = m?.io?.write_mb || 0;
|
||||
|
||||
return {
|
||||
elapsed_time_ms: acc.elapsed_time_ms + elapsed,
|
||||
cpu_total_ms: acc.cpu_total_ms + cpuTotal,
|
||||
memory_delta_mb: acc.memory_delta_mb + memoryDelta,
|
||||
io_read_mb: acc.io_read_mb + ioRead,
|
||||
io_write_mb: acc.io_write_mb + ioWrite,
|
||||
};
|
||||
}, {
|
||||
elapsed_time_ms: 0,
|
||||
cpu_total_ms: 0,
|
||||
memory_delta_mb: 0,
|
||||
io_read_mb: 0,
|
||||
io_write_mb: 0,
|
||||
});
|
||||
|
||||
// Find the slowest request - with null safety
|
||||
const slowestRequest = metrics.length > 0
|
||||
? metrics.reduce((prev, current) => {
|
||||
const prevTime = prev?.elapsed_time_ms || 0;
|
||||
const currentTime = current?.elapsed_time_ms || 0;
|
||||
return (currentTime > prevTime) ? current : prev;
|
||||
})
|
||||
: null;
|
||||
|
||||
// Find the request with highest CPU usage - with null safety
|
||||
const highestCpuRequest = metrics.length > 0
|
||||
? metrics.reduce((prev, current) => {
|
||||
const prevCpu = prev?.cpu?.total_time_ms || 0;
|
||||
const currentCpu = current?.cpu?.total_time_ms || 0;
|
||||
return (currentCpu > prevCpu) ? current : prev;
|
||||
})
|
||||
: null;
|
||||
|
||||
// Find the request with highest memory usage - with null safety
|
||||
const highestMemoryRequest = metrics.length > 0
|
||||
? metrics.reduce((prev, current) => {
|
||||
const prevMemory = prev?.memory?.delta_mb || 0;
|
||||
const currentMemory = current?.memory?.delta_mb || 0;
|
||||
return (currentMemory > prevMemory) ? current : prev;
|
||||
})
|
||||
: null;
|
||||
|
||||
if (!enabled || !isAdminOrDeveloper) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Toggle Button - Fixed position */}
|
||||
<button
|
||||
onClick={() => setIsVisible(!isVisible)}
|
||||
className="fixed bottom-4 right-4 z-[99999] bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg shadow-lg text-sm font-medium flex items-center gap-2"
|
||||
title="Toggle Resource Debug Overlay"
|
||||
>
|
||||
<span>🔍</span>
|
||||
<span>Debug ({metrics.length})</span>
|
||||
</button>
|
||||
|
||||
{/* Overlay */}
|
||||
{isVisible && (
|
||||
<div className="fixed bottom-20 right-4 z-[99998] bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-lg shadow-2xl w-[500px] max-h-[85vh] overflow-auto">
|
||||
<div className="sticky top-0 bg-gray-100 dark:bg-gray-800 px-4 py-3 border-b border-gray-300 dark:border-gray-700 flex justify-between items-center">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">Resource Debug</h3>
|
||||
<button
|
||||
onClick={() => setIsVisible(false)}
|
||||
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Page Load Summary */}
|
||||
{pageLoadTime && (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 p-3 rounded border border-blue-200 dark:border-blue-800">
|
||||
<h4 className="font-semibold text-blue-900 dark:text-blue-200 mb-2">Page Load Time</h4>
|
||||
<div className="text-sm text-blue-800 dark:text-blue-300">
|
||||
{pageLoadTime.toFixed(2)} ms
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Performance Summary - Highlight Culprits */}
|
||||
{metrics.length > 0 && (
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 p-3 rounded border border-yellow-200 dark:border-yellow-800">
|
||||
<h4 className="font-semibold text-yellow-900 dark:text-yellow-200 mb-2">⚠️ Performance Culprits</h4>
|
||||
<div className="text-xs space-y-2 text-yellow-800 dark:text-yellow-300">
|
||||
{slowestRequest && (
|
||||
<div>
|
||||
<span className="font-semibold">Slowest Request:</span> {slowestRequest.method || 'N/A'} {slowestRequest.path || 'N/A'}
|
||||
<br />
|
||||
<span className="ml-4">Time: {(slowestRequest.elapsed_time_ms || 0).toFixed(2)} ms</span>
|
||||
</div>
|
||||
)}
|
||||
{highestCpuRequest && highestCpuRequest.cpu && (highestCpuRequest.cpu.total_time_ms || 0) > 100 && (
|
||||
<div>
|
||||
<span className="font-semibold">Highest CPU:</span> {highestCpuRequest.method || 'N/A'} {highestCpuRequest.path || 'N/A'}
|
||||
<br />
|
||||
<span className="ml-4">CPU: {(highestCpuRequest.cpu.total_time_ms || 0).toFixed(2)} ms (System: {(highestCpuRequest.cpu.system_percent || 0).toFixed(1)}%)</span>
|
||||
</div>
|
||||
)}
|
||||
{highestMemoryRequest && highestMemoryRequest.memory && (highestMemoryRequest.memory.delta_mb || 0) > 1 && (
|
||||
<div>
|
||||
<span className="font-semibold">Highest Memory:</span> {highestMemoryRequest.method || 'N/A'} {highestMemoryRequest.path || 'N/A'}
|
||||
<br />
|
||||
<span className="ml-4">Memory: {(highestMemoryRequest.memory.delta_mb || 0) > 0 ? '+' : ''}{(highestMemoryRequest.memory.delta_mb || 0).toFixed(2)} MB</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Totals */}
|
||||
{metrics.length > 0 && (
|
||||
<div className="bg-gray-50 dark:bg-gray-800 p-3 rounded border border-gray-200 dark:border-gray-700">
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white mb-2">Request Totals</h4>
|
||||
<div className="text-xs space-y-1 text-gray-700 dark:text-gray-300">
|
||||
<div>Total Requests: {metrics.length}</div>
|
||||
<div>Total Time: {totals.elapsed_time_ms.toFixed(2)} ms</div>
|
||||
<div>Total CPU Time: {totals.cpu_total_ms.toFixed(2)} ms</div>
|
||||
<div>Total Memory Delta: {totals.memory_delta_mb > 0 ? '+' : ''}{totals.memory_delta_mb.toFixed(2)} MB</div>
|
||||
<div>Total I/O Read: {totals.io_read_mb.toFixed(2)} MB</div>
|
||||
<div>Total I/O Write: {totals.io_write_mb.toFixed(2)} MB</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Individual Requests - Detailed View */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white">All Requests (Detailed)</h4>
|
||||
{metrics.length === 0 ? (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
No requests tracked yet. Navigate to trigger API calls.
|
||||
<br />
|
||||
<span className="text-xs">Make sure debug toggle is enabled in header.</span>
|
||||
</div>
|
||||
) : (
|
||||
metrics.map((m, idx) => {
|
||||
// Safely access properties with defaults
|
||||
const elapsedTime = m?.elapsed_time_ms || 0;
|
||||
const cpuTotal = m?.cpu?.total_time_ms || 0;
|
||||
const memoryDelta = m?.memory?.delta_mb || 0;
|
||||
|
||||
const isSlow = elapsedTime > 1000;
|
||||
const isHighCpu = cpuTotal > 100;
|
||||
const isHighMemory = memoryDelta > 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`p-3 rounded border text-xs ${
|
||||
isSlow || isHighCpu || isHighMemory
|
||||
? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800'
|
||||
: 'bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold text-gray-900 dark:text-white mb-2 flex items-center gap-2">
|
||||
<span>{m.method}</span>
|
||||
<span className="text-gray-600 dark:text-gray-400 truncate">{m.path}</span>
|
||||
{(isSlow || isHighCpu || isHighMemory) && (
|
||||
<span className="text-red-600 dark:text-red-400 text-xs">⚠️</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1 text-gray-700 dark:text-gray-300">
|
||||
<div className={isSlow ? 'font-semibold text-red-700 dark:text-red-300' : ''}>
|
||||
⏱️ Time: {elapsedTime.toFixed(2)} ms
|
||||
</div>
|
||||
{m?.cpu && (
|
||||
<div className={isHighCpu ? 'font-semibold text-red-700 dark:text-red-300' : ''}>
|
||||
🔥 CPU: {cpuTotal.toFixed(2)} ms
|
||||
<span className="text-gray-500"> (User: {(m.cpu.user_time_ms || 0).toFixed(2)}ms, System: {(m.cpu.system_time_ms || 0).toFixed(2)}ms)</span>
|
||||
<br />
|
||||
<span className="ml-4 text-gray-500">System CPU: {(m.cpu.system_percent || 0).toFixed(1)}%</span>
|
||||
</div>
|
||||
)}
|
||||
{m?.memory && (
|
||||
<div className={isHighMemory ? 'font-semibold text-red-700 dark:text-red-300' : ''}>
|
||||
💾 Memory: {memoryDelta > 0 ? '+' : ''}{memoryDelta.toFixed(2)} MB
|
||||
<span className="text-gray-500"> (Final RSS: {(m.memory.final_rss_mb || 0).toFixed(2)} MB)</span>
|
||||
<br />
|
||||
<span className="ml-4 text-gray-500">System Memory: {(m.memory.system_used_percent || 0).toFixed(1)}%</span>
|
||||
</div>
|
||||
)}
|
||||
{m?.io?.read_mb > 0 && (
|
||||
<div>
|
||||
📖 I/O Read: {m.io.read_mb.toFixed(2)} MB ({(m.io.read_bytes || 0).toLocaleString()} bytes)
|
||||
</div>
|
||||
)}
|
||||
{m?.io?.write_mb > 0 && (
|
||||
<div>
|
||||
📝 I/O Write: {m.io.write_mb.toFixed(2)} MB ({(m.io.write_bytes || 0).toLocaleString()} bytes)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Clear Button */}
|
||||
{metrics.length > 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setMetrics([]);
|
||||
metricsRef.current = [];
|
||||
setPageLoadStart(performance.now());
|
||||
}}
|
||||
className="w-full bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-900 dark:text-white px-3 py-2 rounded text-sm"
|
||||
>
|
||||
Clear Metrics
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
|
||||
export default function ResourceDebugToggle() {
|
||||
const { user } = useAuthStore();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
|
||||
const isAdminOrDeveloper = user?.role === 'admin' || user?.role === 'developer';
|
||||
|
||||
// Load saved state from localStorage
|
||||
useEffect(() => {
|
||||
if (isAdminOrDeveloper) {
|
||||
const saved = localStorage.getItem('debug_resource_tracking_enabled');
|
||||
setEnabled(saved === 'true');
|
||||
}
|
||||
}, [isAdminOrDeveloper]);
|
||||
|
||||
const toggle = () => {
|
||||
const newValue = !enabled;
|
||||
setEnabled(newValue);
|
||||
localStorage.setItem('debug_resource_tracking_enabled', String(newValue));
|
||||
// Dispatch event for overlay component
|
||||
window.dispatchEvent(new CustomEvent('debug-resource-tracking-toggle', { detail: newValue }));
|
||||
};
|
||||
|
||||
if (!isAdminOrDeveloper) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggle}
|
||||
className={`flex items-center justify-center w-10 h-10 rounded-lg transition-colors ${
|
||||
enabled
|
||||
? 'bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400'
|
||||
: 'text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
title={enabled ? 'Disable Resource Debug' : 'Enable Resource Debug'}
|
||||
>
|
||||
🔍
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Hook to check if Resource Debug is enabled
|
||||
* This controls both Resource Debug overlay and AI Function Logs
|
||||
*/
|
||||
export function useResourceDebug(): boolean {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Load initial state
|
||||
const saved = localStorage.getItem('debug_resource_tracking_enabled');
|
||||
setEnabled(saved === 'true');
|
||||
|
||||
// Listen for toggle changes
|
||||
const handleToggle = (e: Event) => {
|
||||
const customEvent = e as CustomEvent;
|
||||
setEnabled(customEvent.detail);
|
||||
};
|
||||
|
||||
window.addEventListener('debug-resource-tracking-toggle', handleToggle);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('debug-resource-tracking-toggle', handleToggle);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return enabled;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { ThemeToggleButton } from "../components/common/ThemeToggleButton";
|
||||
import NotificationDropdown from "../components/header/NotificationDropdown";
|
||||
import UserDropdown from "../components/header/UserDropdown";
|
||||
import { HeaderMetrics } from "../components/header/HeaderMetrics";
|
||||
import ResourceDebugToggle from "../components/debug/ResourceDebugToggle";
|
||||
|
||||
const AppHeader: React.FC = () => {
|
||||
const [isApplicationMenuOpen, setApplicationMenuOpen] = useState(false);
|
||||
@@ -163,8 +162,6 @@ const AppHeader: React.FC = () => {
|
||||
<HeaderMetrics />
|
||||
{/* <!-- Dark Mode Toggler --> */}
|
||||
<ThemeToggleButton />
|
||||
{/* <!-- Resource Debug Toggle (Admin only) --> */}
|
||||
<ResourceDebugToggle />
|
||||
{/* <!-- Notification Menu Area --> */}
|
||||
<NotificationDropdown />
|
||||
{/* <!-- Notification Menu Area --> */}
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useBillingStore } from "../store/billingStore";
|
||||
import { useHeaderMetrics } from "../context/HeaderMetricsContext";
|
||||
import { useErrorHandler } from "../hooks/useErrorHandler";
|
||||
import { trackLoading } from "../components/common/LoadingStateMonitor";
|
||||
import ResourceDebugOverlay from "../components/debug/ResourceDebugOverlay";
|
||||
import PendingPaymentBanner from "../components/billing/PendingPaymentBanner";
|
||||
|
||||
const LayoutContent: React.FC = () => {
|
||||
@@ -22,7 +21,6 @@ const LayoutContent: React.FC = () => {
|
||||
const { addError } = useErrorHandler('AppLayout');
|
||||
const hasLoadedSite = useRef(false);
|
||||
const isLoadingSite = useRef(false);
|
||||
const [debugEnabled, setDebugEnabled] = useState(false);
|
||||
const lastUserRefresh = useRef<number>(0);
|
||||
|
||||
// Initialize site store on mount - only once, but only if authenticated
|
||||
@@ -145,22 +143,6 @@ const LayoutContent: React.FC = () => {
|
||||
}]);
|
||||
}, [balance, isAuthenticated, setMetrics]);
|
||||
|
||||
// Listen for debug toggle changes
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem('debug_resource_tracking_enabled');
|
||||
setDebugEnabled(saved === 'true');
|
||||
|
||||
const handleToggle = (e: Event) => {
|
||||
const customEvent = e as CustomEvent;
|
||||
setDebugEnabled(customEvent.detail);
|
||||
};
|
||||
|
||||
window.addEventListener('debug-resource-tracking-toggle', handleToggle);
|
||||
return () => {
|
||||
window.removeEventListener('debug-resource-tracking-toggle', handleToggle);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen xl:flex">
|
||||
<div>
|
||||
@@ -178,8 +160,6 @@ const LayoutContent: React.FC = () => {
|
||||
<div className="p-4 pb-20 md:p-6 md:pb-24">
|
||||
<Outlet />
|
||||
</div>
|
||||
{/* Resource Debug Overlay - Only visible when enabled by admin */}
|
||||
<ResourceDebugOverlay enabled={debugEnabled} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
FileIcon,
|
||||
UserIcon,
|
||||
UserCircleIcon,
|
||||
BoxCubeIcon,
|
||||
} from "../icons";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import SidebarWidget from "./SidebarWidget";
|
||||
@@ -29,6 +30,7 @@ type NavItem = {
|
||||
icon: React.ReactNode;
|
||||
path?: string;
|
||||
subItems?: { name: string; path: string; pro?: boolean; new?: boolean }[];
|
||||
adminOnly?: boolean;
|
||||
};
|
||||
|
||||
type MenuSection = {
|
||||
@@ -42,9 +44,6 @@ const AppSidebar: React.FC = () => {
|
||||
const { user, isAuthenticated } = useAuthStore();
|
||||
const { moduleEnableSettings, isModuleEnabled: checkModuleEnabled, loadModuleEnableSettings, loading: settingsLoading } = useSettingsStore();
|
||||
|
||||
// Show admin menu only for aws-admin account users
|
||||
const isAwsAdminAccount = Boolean(user?.account?.slug === 'aws-admin');
|
||||
|
||||
// Helper to check if module is enabled - memoized to prevent infinite loops
|
||||
const moduleEnabled = useCallback((moduleName: string): boolean => {
|
||||
if (!moduleEnableSettings) return true; // Default to enabled if not loaded
|
||||
@@ -224,6 +223,30 @@ const AppSidebar: React.FC = () => {
|
||||
path: "/settings/integration",
|
||||
adminOnly: true,
|
||||
},
|
||||
// Global Settings - Admin only, dropdown with global config pages
|
||||
{
|
||||
icon: <BoxCubeIcon />,
|
||||
name: "Global Settings",
|
||||
adminOnly: true,
|
||||
subItems: [
|
||||
{
|
||||
name: "Platform API Keys",
|
||||
path: "/admin/system/globalintegrationsettings/",
|
||||
},
|
||||
{
|
||||
name: "Global Prompts",
|
||||
path: "/admin/system/globalaiprompt/",
|
||||
},
|
||||
{
|
||||
name: "Global Author Profiles",
|
||||
path: "/admin/system/globalauthorprofile/",
|
||||
},
|
||||
{
|
||||
name: "Global Strategies",
|
||||
path: "/admin/system/globalstrategy/",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <PageIcon />,
|
||||
name: "Publishing",
|
||||
@@ -249,32 +272,10 @@ const AppSidebar: React.FC = () => {
|
||||
];
|
||||
}, [moduleEnabled]);
|
||||
|
||||
// Admin section - only shown for aws-admin account users
|
||||
const adminSection: MenuSection = useMemo(() => ({
|
||||
label: "ADMIN",
|
||||
items: [
|
||||
{
|
||||
icon: <GridIcon />,
|
||||
name: "System Dashboard",
|
||||
path: "/admin/dashboard",
|
||||
},
|
||||
],
|
||||
}), []);
|
||||
|
||||
// Combine all sections, including admin if user is in aws-admin account
|
||||
// Combine all sections
|
||||
const allSections = useMemo(() => {
|
||||
const baseSections = menuSections.map(section => {
|
||||
// Filter adminOnly items for non-system users
|
||||
const filteredItems = section.items.filter((item: any) => {
|
||||
if ((item as any).adminOnly && !isAwsAdminAccount) return false;
|
||||
return true;
|
||||
});
|
||||
return { ...section, items: filteredItems };
|
||||
});
|
||||
return isAwsAdminAccount
|
||||
? [...baseSections, adminSection]
|
||||
: baseSections;
|
||||
}, [isAwsAdminAccount, menuSections, adminSection]);
|
||||
return menuSections;
|
||||
}, [menuSections]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentPath = location.pathname;
|
||||
@@ -355,7 +356,15 @@ const AppSidebar: React.FC = () => {
|
||||
|
||||
const renderMenuItems = (items: NavItem[], sectionIndex: number) => (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{items.map((nav, itemIndex) => (
|
||||
{items
|
||||
.filter((nav) => {
|
||||
// Filter out admin-only items for non-admin users
|
||||
if (nav.adminOnly && user?.role !== 'admin' && !user?.is_staff) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((nav, itemIndex) => (
|
||||
<li key={nav.name}>
|
||||
{nav.subItems ? (
|
||||
<button
|
||||
|
||||
@@ -31,7 +31,6 @@ import { getDifficultyLabelFromNumber, getDifficultyRange } from '../../utils/di
|
||||
import FormModal from '../../components/common/FormModal';
|
||||
import ProgressModal from '../../components/common/ProgressModal';
|
||||
import { useProgressModal } from '../../hooks/useProgressModal';
|
||||
import { useResourceDebug } from '../../hooks/useResourceDebug';
|
||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
import { ArrowUpIcon, PlusIcon, ListIcon, DownloadIcon, GroupIcon, BoltIcon } from '../../icons';
|
||||
import { useKeywordsImportExport } from '../../config/import-export.config';
|
||||
@@ -90,37 +89,6 @@ export default function Keywords() {
|
||||
const progressModal = useProgressModal();
|
||||
const hasReloadedRef = useRef(false);
|
||||
|
||||
// Resource Debug toggle - controls AI Function Logs
|
||||
const resourceDebugEnabled = useResourceDebug();
|
||||
|
||||
// AI Function Logs state
|
||||
const [aiLogs, setAiLogs] = useState<Array<{
|
||||
timestamp: string;
|
||||
type: 'request' | 'success' | 'error' | 'step';
|
||||
action: string;
|
||||
data: any;
|
||||
stepName?: string;
|
||||
percentage?: number;
|
||||
}>>([]);
|
||||
|
||||
// Track last logged step to avoid duplicates
|
||||
const lastLoggedStepRef = useRef<string | null>(null);
|
||||
const lastLoggedPercentageRef = useRef<number>(-1);
|
||||
|
||||
// Helper function to add log entry (only if Resource Debug is enabled)
|
||||
const addAiLog = useCallback((log: {
|
||||
timestamp: string;
|
||||
type: 'request' | 'success' | 'error' | 'step';
|
||||
action: string;
|
||||
data: any;
|
||||
stepName?: string;
|
||||
percentage?: number;
|
||||
}) => {
|
||||
if (resourceDebugEnabled) {
|
||||
setAiLogs(prev => [...prev, log]);
|
||||
}
|
||||
}, [resourceDebugEnabled]);
|
||||
|
||||
// Load sectors for active site using sector store
|
||||
useEffect(() => {
|
||||
if (activeSite) {
|
||||
@@ -332,21 +300,6 @@ export default function Keywords() {
|
||||
const numIds = ids.map(id => parseInt(id));
|
||||
const sectorId = activeSector?.id;
|
||||
const selectedKeywords = keywords.filter(k => numIds.includes(k.id));
|
||||
const requestData = {
|
||||
ids: numIds,
|
||||
keyword_count: numIds.length,
|
||||
keyword_names: selectedKeywords.map(k => k.keyword),
|
||||
sector_id: sectorId,
|
||||
};
|
||||
|
||||
// Log request (only if Resource Debug is enabled)
|
||||
addAiLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'request',
|
||||
action: 'auto_cluster (Bulk Action)',
|
||||
data: requestData,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await autoClusterKeywords(numIds, sectorId);
|
||||
|
||||
@@ -354,43 +307,17 @@ export default function Keywords() {
|
||||
if (result && result.success === false) {
|
||||
// Error response from API
|
||||
const errorMsg = result.error || 'Failed to cluster keywords';
|
||||
// Log error
|
||||
addAiLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'error',
|
||||
action: 'auto_cluster (Bulk Action)',
|
||||
data: { error: errorMsg, keyword_count: numIds.length },
|
||||
});
|
||||
toast.error(errorMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result && result.success) {
|
||||
if (result.task_id) {
|
||||
// Log success with task_id
|
||||
addAiLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'success',
|
||||
action: 'auto_cluster (Bulk Action)',
|
||||
data: { task_id: result.task_id, message: result.message, keyword_count: numIds.length },
|
||||
});
|
||||
// Async task - open progress modal
|
||||
hasReloadedRef.current = false;
|
||||
progressModal.openModal(result.task_id, 'Auto-Clustering Keywords', 'ai-auto-cluster-01');
|
||||
// Don't show toast - progress modal will show status
|
||||
} else {
|
||||
// Log success with results
|
||||
addAiLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'success',
|
||||
action: 'auto_cluster (Bulk Action)',
|
||||
data: {
|
||||
clusters_created: result.clusters_created || 0,
|
||||
keywords_updated: result.keywords_updated || 0,
|
||||
keyword_count: numIds.length,
|
||||
message: result.message,
|
||||
},
|
||||
});
|
||||
// Synchronous completion
|
||||
toast.success(`Clustering complete: ${result.clusters_created || 0} clusters created, ${result.keywords_updated || 0} keywords updated`);
|
||||
if (!hasReloadedRef.current) {
|
||||
@@ -401,13 +328,6 @@ export default function Keywords() {
|
||||
} else {
|
||||
// Unexpected response format - show error
|
||||
const errorMsg = result?.error || 'Unexpected response format';
|
||||
// Log error
|
||||
addAiLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'error',
|
||||
action: 'auto_cluster (Bulk Action)',
|
||||
data: { error: errorMsg, keyword_count: numIds.length },
|
||||
});
|
||||
toast.error(errorMsg);
|
||||
}
|
||||
} catch (error: any) {
|
||||
@@ -420,13 +340,6 @@ export default function Keywords() {
|
||||
errorMsg = error.message;
|
||||
}
|
||||
}
|
||||
// Log error
|
||||
addAiLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'error',
|
||||
action: 'auto_cluster (Bulk Action)',
|
||||
data: { error: errorMsg, keyword_count: numIds.length },
|
||||
});
|
||||
toast.error(errorMsg);
|
||||
}
|
||||
} else {
|
||||
@@ -434,96 +347,9 @@ export default function Keywords() {
|
||||
}
|
||||
}, [toast, activeSector, loadKeywords, progressModal, keywords]);
|
||||
|
||||
// Log AI function progress steps
|
||||
useEffect(() => {
|
||||
if (!progressModal.taskId || !progressModal.isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const progress = progressModal.progress;
|
||||
const currentStep = progress.details?.phase || '';
|
||||
const currentPercentage = progress.percentage;
|
||||
const currentMessage = progress.message;
|
||||
const currentStatus = progress.status;
|
||||
|
||||
// Log step changes
|
||||
if (currentStep && currentStep !== lastLoggedStepRef.current) {
|
||||
const stepType = currentStatus === 'error' ? 'error' :
|
||||
currentStatus === 'completed' ? 'success' : 'step';
|
||||
|
||||
addAiLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
type: stepType,
|
||||
action: progressModal.title || 'AI Function',
|
||||
stepName: currentStep,
|
||||
percentage: currentPercentage,
|
||||
data: {
|
||||
step: currentStep,
|
||||
message: currentMessage,
|
||||
percentage: currentPercentage,
|
||||
status: currentStatus,
|
||||
details: progress.details,
|
||||
},
|
||||
});
|
||||
|
||||
lastLoggedStepRef.current = currentStep;
|
||||
lastLoggedPercentageRef.current = currentPercentage;
|
||||
}
|
||||
// Log percentage changes for same step (if significant change)
|
||||
else if (currentStep && Math.abs(currentPercentage - lastLoggedPercentageRef.current) >= 10) {
|
||||
const stepType = currentStatus === 'error' ? 'error' :
|
||||
currentStatus === 'completed' ? 'success' : 'step';
|
||||
|
||||
addAiLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
type: stepType,
|
||||
action: progressModal.title || 'AI Function',
|
||||
stepName: currentStep,
|
||||
percentage: currentPercentage,
|
||||
data: {
|
||||
step: currentStep,
|
||||
message: currentMessage,
|
||||
percentage: currentPercentage,
|
||||
status: currentStatus,
|
||||
details: progress.details,
|
||||
},
|
||||
});
|
||||
|
||||
lastLoggedPercentageRef.current = currentPercentage;
|
||||
}
|
||||
// Log status changes (error, completed)
|
||||
else if (currentStatus === 'error' || currentStatus === 'completed') {
|
||||
// Only log if we haven't already logged this status for this step
|
||||
if (currentStep !== lastLoggedStepRef.current ||
|
||||
(currentStatus === 'error' && lastLoggedStepRef.current !== 'error') ||
|
||||
(currentStatus === 'completed' && lastLoggedStepRef.current !== 'completed')) {
|
||||
const stepType = currentStatus === 'error' ? 'error' : 'success';
|
||||
|
||||
addAiLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
type: stepType,
|
||||
action: progressModal.title || 'AI Function',
|
||||
stepName: currentStep || 'Final',
|
||||
percentage: currentPercentage,
|
||||
data: {
|
||||
step: currentStep || 'Final',
|
||||
message: currentMessage,
|
||||
percentage: currentPercentage,
|
||||
status: currentStatus,
|
||||
details: progress.details,
|
||||
},
|
||||
});
|
||||
|
||||
lastLoggedStepRef.current = currentStep || currentStatus;
|
||||
}
|
||||
}
|
||||
}, [progressModal.progress, progressModal.taskId, progressModal.isOpen, progressModal.title, addAiLog]);
|
||||
|
||||
// Reset step tracking when modal closes or opens
|
||||
// Reset reload flag when modal closes or opens
|
||||
useEffect(() => {
|
||||
if (!progressModal.isOpen) {
|
||||
lastLoggedStepRef.current = null;
|
||||
lastLoggedPercentageRef.current = -1;
|
||||
hasReloadedRef.current = false; // Reset reload flag when modal closes
|
||||
} else {
|
||||
// Reset reload flag when modal opens for a new task
|
||||
@@ -984,74 +810,6 @@ export default function Keywords() {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* AI Function Logs - Display below table (only when Resource Debug is enabled) */}
|
||||
{resourceDebugEnabled && aiLogs.length > 0 && (
|
||||
<div className="mt-6 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
AI Function Logs
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setAiLogs([])}
|
||||
className="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
Clear Logs
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{aiLogs.slice().reverse().map((log, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`p-3 rounded border text-xs font-mono ${
|
||||
log.type === 'request'
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
|
||||
: log.type === 'success'
|
||||
? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800'
|
||||
: log.type === 'error'
|
||||
? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800'
|
||||
: 'bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`font-semibold ${
|
||||
log.type === 'request'
|
||||
? 'text-blue-700 dark:text-blue-300'
|
||||
: log.type === 'success'
|
||||
? 'text-green-700 dark:text-green-300'
|
||||
: log.type === 'error'
|
||||
? 'text-red-700 dark:text-red-300'
|
||||
: 'text-purple-700 dark:text-purple-300'
|
||||
}`}>
|
||||
[{log.type.toUpperCase()}]
|
||||
</span>
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
{log.action}
|
||||
</span>
|
||||
{log.stepName && (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
|
||||
{log.stepName}
|
||||
</span>
|
||||
)}
|
||||
{log.percentage !== undefined && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{log.percentage}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
{new Date(log.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
<pre className="text-xs text-gray-700 dark:text-gray-300 whitespace-pre-wrap break-words">
|
||||
{JSON.stringify(log.data, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -119,7 +119,6 @@ export default function Integration() {
|
||||
const validateIntegration = useCallback(async (
|
||||
integrationId: string,
|
||||
enabled: boolean,
|
||||
apiKey?: string,
|
||||
model?: string
|
||||
) => {
|
||||
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL || 'https://api.igny8.com/api';
|
||||
@@ -129,10 +128,8 @@ export default function Integration() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if integration is enabled and has API key configured
|
||||
const hasApiKey = apiKey && apiKey.trim() !== '';
|
||||
|
||||
if (!hasApiKey || !enabled) {
|
||||
// Check if integration is enabled
|
||||
if (!enabled) {
|
||||
// Not configured or disabled - set status accordingly
|
||||
setValidationStatuses(prev => ({
|
||||
...prev,
|
||||
@@ -147,12 +144,10 @@ export default function Integration() {
|
||||
[integrationId]: 'pending',
|
||||
}));
|
||||
|
||||
// Test connection asynchronously
|
||||
// Test connection asynchronously (uses platform API key)
|
||||
try {
|
||||
// Build request body based on integration type
|
||||
const requestBody: any = {
|
||||
apiKey: apiKey,
|
||||
};
|
||||
const requestBody: any = {};
|
||||
|
||||
// OpenAI needs model in config, Runware doesn't
|
||||
if (integrationId === 'openai') {
|
||||
@@ -195,11 +190,10 @@ export default function Integration() {
|
||||
if (!integration) return;
|
||||
|
||||
const enabled = integration.enabled === true;
|
||||
const apiKey = integration.apiKey;
|
||||
const model = integration.model;
|
||||
|
||||
// Validate with current state (fire and forget - don't await)
|
||||
validateIntegration(id, enabled, apiKey, model);
|
||||
validateIntegration(id, enabled, model);
|
||||
});
|
||||
|
||||
// Return unchanged - we're just reading state
|
||||
@@ -216,7 +210,7 @@ export default function Integration() {
|
||||
useEffect(() => {
|
||||
// Only validate if integrations have been loaded (not initial empty state)
|
||||
const hasLoadedData = Object.values(integrations).some(integ =>
|
||||
integ.apiKey !== undefined || integ.enabled !== undefined
|
||||
integ.enabled !== undefined
|
||||
);
|
||||
if (!hasLoadedData) return;
|
||||
|
||||
@@ -227,7 +221,7 @@ export default function Integration() {
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [integrations.openai.enabled, integrations.runware.enabled, integrations.openai.apiKey, integrations.runware.apiKey]);
|
||||
}, [integrations.openai.enabled, integrations.runware.enabled]);
|
||||
|
||||
const loadIntegrationSettings = async () => {
|
||||
try {
|
||||
@@ -294,12 +288,6 @@ export default function Integration() {
|
||||
}
|
||||
|
||||
const config = integrations[selectedIntegration];
|
||||
const apiKey = config.apiKey;
|
||||
|
||||
if (!apiKey) {
|
||||
toast.error('Please enter an API key first');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsTesting(true);
|
||||
|
||||
@@ -312,12 +300,12 @@ export default function Integration() {
|
||||
}
|
||||
|
||||
try {
|
||||
// Test uses platform API key (no apiKey parameter needed)
|
||||
// fetchAPI extracts data from unified format {success: true, data: {...}}
|
||||
// So data is the extracted response payload
|
||||
const data = await fetchAPI(`/v1/system/settings/integrations/${selectedIntegration}/test/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
apiKey,
|
||||
config: config,
|
||||
}),
|
||||
});
|
||||
@@ -477,20 +465,6 @@ export default function Integration() {
|
||||
|
||||
if (integrationId === 'openai') {
|
||||
return [
|
||||
{
|
||||
key: 'apiKey',
|
||||
label: 'OpenAI API Key',
|
||||
type: 'password',
|
||||
value: config.apiKey || '',
|
||||
onChange: (value) => {
|
||||
setIntegrations({
|
||||
...integrations,
|
||||
[integrationId]: { ...config, apiKey: value },
|
||||
});
|
||||
},
|
||||
placeholder: 'Enter your OpenAI API key',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'model',
|
||||
label: 'AI Model',
|
||||
@@ -513,20 +487,7 @@ export default function Integration() {
|
||||
];
|
||||
} else if (integrationId === 'runware') {
|
||||
return [
|
||||
{
|
||||
key: 'apiKey',
|
||||
label: 'Runware API Key',
|
||||
type: 'password',
|
||||
value: config.apiKey || '',
|
||||
onChange: (value) => {
|
||||
setIntegrations({
|
||||
...integrations,
|
||||
[integrationId]: { ...config, apiKey: value },
|
||||
});
|
||||
},
|
||||
placeholder: 'Enter your Runware API key',
|
||||
required: true,
|
||||
},
|
||||
// Runware doesn't have model selection, just using platform API key
|
||||
];
|
||||
} else if (integrationId === 'image_generation') {
|
||||
const service = config.service || 'openai';
|
||||
@@ -912,6 +873,13 @@ export default function Integration() {
|
||||
<PageMeta title="API Integration - IGNY8" description="External integrations" />
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* Platform API Keys Info */}
|
||||
<Alert
|
||||
variant="info"
|
||||
title="Platform API Keys"
|
||||
message="API keys are managed at the platform level by administrators. You can customize which AI models and parameters to use for your account. Free plan users can view settings but cannot customize them."
|
||||
/>
|
||||
|
||||
{/* Integration Cards with Validation Cards */}
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{/* OpenAI Integration + Validation */}
|
||||
@@ -924,12 +892,10 @@ export default function Integration() {
|
||||
integrationId="openai"
|
||||
onToggleSuccess={(enabled, data) => {
|
||||
// Refresh status circle when toggle changes
|
||||
// Use API key from hook's data (most up-to-date) or fallback to integrations state
|
||||
const apiKey = data?.apiKey || integrations.openai.apiKey;
|
||||
const model = data?.model || integrations.openai.model;
|
||||
|
||||
// Validate with current enabled state and API key
|
||||
validateIntegration('openai', enabled, apiKey, model);
|
||||
// Validate with current enabled state and model
|
||||
validateIntegration('openai', enabled, model);
|
||||
}}
|
||||
onSettings={() => handleSettings('openai')}
|
||||
onDetails={() => handleDetails('openai')}
|
||||
@@ -965,11 +931,8 @@ export default function Integration() {
|
||||
}
|
||||
onToggleSuccess={(enabled, data) => {
|
||||
// Refresh status circle when toggle changes
|
||||
// Use API key from hook's data (most up-to-date) or fallback to integrations state
|
||||
const apiKey = data?.apiKey || integrations.runware.apiKey;
|
||||
|
||||
// Validate with current enabled state and API key
|
||||
validateIntegration('runware', enabled, apiKey);
|
||||
// Validate with current enabled state
|
||||
validateIntegration('runware', enabled);
|
||||
}}
|
||||
onSettings={() => handleSettings('runware')}
|
||||
onDetails={() => handleDetails('runware')}
|
||||
@@ -1003,11 +966,7 @@ export default function Integration() {
|
||||
<Alert
|
||||
variant="info"
|
||||
title="AI Integration & Image Generation Testing"
|
||||
message="Configure and test your AI integrations on this page.
|
||||
Set up OpenAI and Runware API keys, validate connections, and test image generation with different models and parameters.
|
||||
Before you start, please read the documentation for each integration.
|
||||
|
||||
Make sure to use the correct API keys and models for each integration."
|
||||
message="Test your AI integrations and image generation on this page. The platform provides API keys - you can customize model preferences and parameters based on your plan. Test connections to verify everything is working correctly."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1093,7 +1052,7 @@ export default function Integration() {
|
||||
onClick={() => {
|
||||
handleTestConnection();
|
||||
}}
|
||||
disabled={isTesting || isSaving || !integrations[selectedIntegration]?.apiKey}
|
||||
disabled={isTesting || isSaving}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{isTesting ? 'Testing...' : 'Test Connection'}
|
||||
|
||||
@@ -61,9 +61,6 @@ export default function IndustriesSectorsKeywords() {
|
||||
const [countryFilter, setCountryFilter] = useState('');
|
||||
const [difficultyFilter, setDifficultyFilter] = useState('');
|
||||
|
||||
// Check if user is admin/superuser (role is 'admin' or 'developer')
|
||||
const isAdmin = user?.role === 'admin' || user?.role === 'developer';
|
||||
|
||||
// Import modal state
|
||||
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
@@ -706,18 +703,6 @@ export default function IndustriesSectorsKeywords() {
|
||||
}
|
||||
}}
|
||||
bulkActions={pageConfig.bulkActions}
|
||||
customActions={
|
||||
isAdmin ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleImportClick}
|
||||
>
|
||||
<PlusIcon className="w-4 h-4 mr-2" />
|
||||
Import Keywords
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
@@ -733,8 +718,7 @@ export default function IndustriesSectorsKeywords() {
|
||||
selectedIds,
|
||||
onSelectionChange: setSelectedIds,
|
||||
}}
|
||||
// Only show row actions for admin users
|
||||
onEdit={isAdmin ? undefined : undefined}
|
||||
onEdit={undefined}
|
||||
onDelete={undefined}
|
||||
/>
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ import { FileIcon, DownloadIcon, BoltIcon, TaskIcon, ImageIcon, CheckCircleIcon
|
||||
import { createImagesPageConfig } from '../../config/pages/images.config';
|
||||
import ImageQueueModal, { ImageQueueItem } from '../../components/common/ImageQueueModal';
|
||||
import SingleRecordStatusUpdateModal from '../../components/common/SingleRecordStatusUpdateModal';
|
||||
import { useResourceDebug } from '../../hooks/useResourceDebug';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import ModuleNavigationTabs from '../../components/navigation/ModuleNavigationTabs';
|
||||
import { Modal } from '../../components/ui/modal';
|
||||
@@ -30,33 +29,6 @@ import { Modal } from '../../components/ui/modal';
|
||||
export default function Images() {
|
||||
const toast = useToast();
|
||||
|
||||
// Resource Debug toggle - controls AI Function Logs
|
||||
const resourceDebugEnabled = useResourceDebug();
|
||||
|
||||
// AI Function Logs state
|
||||
const [aiLogs, setAiLogs] = useState<Array<{
|
||||
timestamp: string;
|
||||
type: 'request' | 'success' | 'error' | 'step';
|
||||
action: string;
|
||||
data: any;
|
||||
stepName?: string;
|
||||
percentage?: number;
|
||||
}>>([]);
|
||||
|
||||
// Helper function to add log entry (only if Resource Debug is enabled)
|
||||
const addAiLog = useCallback((log: {
|
||||
timestamp: string;
|
||||
type: 'request' | 'success' | 'error' | 'step';
|
||||
action: string;
|
||||
data: any;
|
||||
stepName?: string;
|
||||
percentage?: number;
|
||||
}) => {
|
||||
if (resourceDebugEnabled) {
|
||||
setAiLogs(prev => [...prev, log]);
|
||||
}
|
||||
}, [resourceDebugEnabled]);
|
||||
|
||||
// Data state
|
||||
const [images, setImages] = useState<ContentImagesGroup[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -373,36 +345,16 @@ export default function Images() {
|
||||
console.log('[Generate Images] Max in-article images from settings:', maxInArticleImages);
|
||||
|
||||
// STAGE 2: Start actual generation
|
||||
addAiLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'request',
|
||||
action: 'generate_images',
|
||||
data: { imageIds, contentId, totalImages: imageIds.length }
|
||||
});
|
||||
|
||||
const result = await generateImages(imageIds, contentId);
|
||||
|
||||
if (result.success && result.task_id) {
|
||||
// Task started successfully - polling will be handled by ImageQueueModal
|
||||
setTaskId(result.task_id);
|
||||
console.log('[Generate Images] Stage 2: Task started with ID:', result.task_id);
|
||||
addAiLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'step',
|
||||
action: 'generate_images',
|
||||
stepName: 'Task Queued',
|
||||
data: { task_id: result.task_id, message: 'Image generation task queued' }
|
||||
});
|
||||
} else {
|
||||
toast.error(result.error || 'Failed to start image generation');
|
||||
setIsQueueModalOpen(false);
|
||||
setTaskId(null);
|
||||
addAiLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'error',
|
||||
action: 'generate_images',
|
||||
data: { error: result.error || 'Failed to start image generation' }
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
@@ -581,7 +533,6 @@ export default function Images() {
|
||||
model={imageModel || undefined}
|
||||
provider={imageProvider || undefined}
|
||||
onUpdateQueue={setImageQueue}
|
||||
onLog={addAiLog}
|
||||
/>
|
||||
|
||||
{/* Status Update Modal */}
|
||||
@@ -623,74 +574,6 @@ export default function Images() {
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* AI Function Logs - Display below table (only when Resource Debug is enabled) */}
|
||||
{resourceDebugEnabled && aiLogs.length > 0 && (
|
||||
<div className="mt-6 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
AI Function Logs
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setAiLogs([])}
|
||||
className="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
Clear Logs
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{aiLogs.slice().reverse().map((log, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`p-3 rounded border text-xs font-mono ${
|
||||
log.type === 'request'
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
|
||||
: log.type === 'success'
|
||||
? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800'
|
||||
: log.type === 'error'
|
||||
? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800'
|
||||
: 'bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`font-semibold ${
|
||||
log.type === 'request'
|
||||
? 'text-blue-700 dark:text-blue-300'
|
||||
: log.type === 'success'
|
||||
? 'text-green-700 dark:text-green-300'
|
||||
: log.type === 'error'
|
||||
? 'text-red-700 dark:text-red-300'
|
||||
: 'text-purple-700 dark:text-purple-300'
|
||||
}`}>
|
||||
[{log.type.toUpperCase()}]
|
||||
</span>
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
{log.action}
|
||||
</span>
|
||||
{log.stepName && (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
|
||||
{log.stepName}
|
||||
</span>
|
||||
)}
|
||||
{log.percentage !== undefined && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{log.percentage}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
{new Date(log.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
<pre className="text-xs text-gray-700 dark:text-gray-300 whitespace-pre-wrap break-words">
|
||||
{JSON.stringify(log.data, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
import FormModal from '../../components/common/FormModal';
|
||||
import ProgressModal from '../../components/common/ProgressModal';
|
||||
import { useProgressModal } from '../../hooks/useProgressModal';
|
||||
import { useResourceDebug } from '../../hooks/useResourceDebug';
|
||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
import { TaskIcon, PlusIcon, DownloadIcon, FileIcon, ImageIcon, CheckCircleIcon } from '../../icons';
|
||||
import { createTasksPageConfig } from '../../config/pages/tasks.config';
|
||||
@@ -139,36 +138,11 @@ export default function Tasks() {
|
||||
}, [tasks, totalCount]);
|
||||
|
||||
// AI Function Logs state
|
||||
const [aiLogs, setAiLogs] = useState<Array<{
|
||||
timestamp: string;
|
||||
type: 'request' | 'success' | 'error' | 'step';
|
||||
action: string;
|
||||
data: any;
|
||||
stepName?: string;
|
||||
percentage?: number;
|
||||
}>>([]);
|
||||
|
||||
// Resource Debug toggle - controls AI Function Logs
|
||||
const resourceDebugEnabled = useResourceDebug();
|
||||
|
||||
// Track last logged step to avoid duplicates
|
||||
const lastLoggedStepRef = useRef<string | null>(null);
|
||||
const lastLoggedPercentageRef = useRef<number>(-1);
|
||||
|
||||
const hasReloadedRef = useRef<boolean>(false);
|
||||
|
||||
// Helper function to add log entry (only if Resource Debug is enabled)
|
||||
const addAiLog = useCallback((log: {
|
||||
timestamp: string;
|
||||
type: 'request' | 'success' | 'error' | 'step';
|
||||
action: string;
|
||||
data: any;
|
||||
stepName?: string;
|
||||
percentage?: number;
|
||||
}) => {
|
||||
if (resourceDebugEnabled) {
|
||||
setAiLogs(prev => [...prev, log]);
|
||||
}
|
||||
}, [resourceDebugEnabled]);
|
||||
|
||||
|
||||
// Load clusters for filter dropdown
|
||||
useEffect(() => {
|
||||
@@ -311,65 +285,23 @@ export default function Tasks() {
|
||||
// return;
|
||||
// }
|
||||
|
||||
const requestData = {
|
||||
ids: [row.id],
|
||||
task_title: row.title,
|
||||
task_id: row.id,
|
||||
};
|
||||
|
||||
// Log request
|
||||
addAiLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'request',
|
||||
action: 'generate_content (Row Action)',
|
||||
data: requestData,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await autoGenerateContent([row.id]);
|
||||
|
||||
if (result.success) {
|
||||
if (result.task_id) {
|
||||
// Log success with task_id
|
||||
addAiLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'success',
|
||||
action: 'generate_content (Row Action)',
|
||||
data: { task_id: result.task_id, message: result.message },
|
||||
});
|
||||
// Async task - show progress modal
|
||||
progressModal.openModal(result.task_id, 'Generating Content', 'ai-generate-content-03');
|
||||
toast.success('Content generation started');
|
||||
} else {
|
||||
// Log success with results
|
||||
addAiLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'success',
|
||||
action: 'generate_content (Row Action)',
|
||||
data: { tasks_updated: result.tasks_updated || 0, message: result.message },
|
||||
});
|
||||
// Synchronous completion
|
||||
toast.success(`Content generated successfully: ${result.tasks_updated || 0} article generated`);
|
||||
await loadTasks();
|
||||
}
|
||||
} else {
|
||||
// Log error
|
||||
addAiLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'error',
|
||||
action: 'generate_content (Row Action)',
|
||||
data: { error: result.error || 'Failed to generate content' },
|
||||
});
|
||||
toast.error(result.error || 'Failed to generate content');
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Log error
|
||||
addAiLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'error',
|
||||
action: 'generate_content (Row Action)',
|
||||
data: { error: error.message || 'Unknown error occurred' },
|
||||
});
|
||||
toast.error(`Failed to generate content: ${error.message}`);
|
||||
}
|
||||
}
|
||||
@@ -389,64 +321,23 @@ export default function Tasks() {
|
||||
}
|
||||
const numIds = ids.map(id => parseInt(id));
|
||||
const selectedTasks = tasks.filter(t => numIds.includes(t.id));
|
||||
const requestData = {
|
||||
ids: numIds,
|
||||
task_count: numIds.length,
|
||||
task_titles: selectedTasks.map(t => t.title),
|
||||
};
|
||||
|
||||
// Log request
|
||||
addAiLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'request',
|
||||
action: 'generate_images (Bulk Action)',
|
||||
data: requestData,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await autoGenerateImages(numIds);
|
||||
if (result.success) {
|
||||
if (result.task_id) {
|
||||
// Log success with task_id
|
||||
addAiLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'success',
|
||||
action: 'generate_images (Bulk Action)',
|
||||
data: { task_id: result.task_id, message: result.message, task_count: numIds.length },
|
||||
});
|
||||
// Async task - show progress modal
|
||||
progressModal.openModal(result.task_id, 'Generating Images');
|
||||
toast.success('Image generation started');
|
||||
} else {
|
||||
// Log success with results
|
||||
addAiLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'success',
|
||||
action: 'generate_images (Bulk Action)',
|
||||
data: { images_created: result.images_created || 0, message: result.message, task_count: numIds.length },
|
||||
});
|
||||
// Synchronous completion
|
||||
toast.success(`Image generation complete: ${result.images_created || 0} images generated`);
|
||||
await loadTasks();
|
||||
}
|
||||
} else {
|
||||
// Log error
|
||||
addAiLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'error',
|
||||
action: 'generate_images (Bulk Action)',
|
||||
data: { error: result.error || 'Failed to generate images', task_count: numIds.length },
|
||||
});
|
||||
toast.error(result.error || 'Failed to generate images');
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Log error
|
||||
addAiLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'error',
|
||||
action: 'generate_images (Bulk Action)',
|
||||
data: { error: error.message || 'Unknown error occurred', task_count: numIds.length },
|
||||
});
|
||||
toast.error(`Failed to generate images: ${error.message}`);
|
||||
}
|
||||
} else {
|
||||
@@ -454,96 +345,9 @@ export default function Tasks() {
|
||||
}
|
||||
}, [toast, loadTasks, progressModal, tasks]);
|
||||
|
||||
// Log AI function progress steps
|
||||
useEffect(() => {
|
||||
if (!progressModal.taskId || !progressModal.isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const progress = progressModal.progress;
|
||||
const currentStep = progress.details?.phase || '';
|
||||
const currentPercentage = progress.percentage;
|
||||
const currentMessage = progress.message;
|
||||
const currentStatus = progress.status;
|
||||
|
||||
// Log step changes
|
||||
if (currentStep && currentStep !== lastLoggedStepRef.current) {
|
||||
const stepType = currentStatus === 'error' ? 'error' :
|
||||
currentStatus === 'completed' ? 'success' : 'step';
|
||||
|
||||
addAiLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
type: stepType,
|
||||
action: progressModal.title || 'AI Function',
|
||||
stepName: currentStep,
|
||||
percentage: currentPercentage,
|
||||
data: {
|
||||
step: currentStep,
|
||||
message: currentMessage,
|
||||
percentage: currentPercentage,
|
||||
status: currentStatus,
|
||||
details: progress.details,
|
||||
},
|
||||
});
|
||||
|
||||
lastLoggedStepRef.current = currentStep;
|
||||
lastLoggedPercentageRef.current = currentPercentage;
|
||||
}
|
||||
// Log percentage changes for same step (if significant change)
|
||||
else if (currentStep && Math.abs(currentPercentage - lastLoggedPercentageRef.current) >= 10) {
|
||||
const stepType = currentStatus === 'error' ? 'error' :
|
||||
currentStatus === 'completed' ? 'success' : 'step';
|
||||
|
||||
addAiLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
type: stepType,
|
||||
action: progressModal.title || 'AI Function',
|
||||
stepName: currentStep,
|
||||
percentage: currentPercentage,
|
||||
data: {
|
||||
step: currentStep,
|
||||
message: currentMessage,
|
||||
percentage: currentPercentage,
|
||||
status: currentStatus,
|
||||
details: progress.details,
|
||||
},
|
||||
});
|
||||
|
||||
lastLoggedPercentageRef.current = currentPercentage;
|
||||
}
|
||||
// Log status changes (error, completed)
|
||||
else if (currentStatus === 'error' || currentStatus === 'completed') {
|
||||
// Only log if we haven't already logged this status for this step
|
||||
if (currentStep !== lastLoggedStepRef.current ||
|
||||
(currentStatus === 'error' && lastLoggedStepRef.current !== 'error') ||
|
||||
(currentStatus === 'completed' && lastLoggedStepRef.current !== 'completed')) {
|
||||
const stepType = currentStatus === 'error' ? 'error' : 'success';
|
||||
|
||||
addAiLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
type: stepType,
|
||||
action: progressModal.title || 'AI Function',
|
||||
stepName: currentStep || 'Final',
|
||||
percentage: currentPercentage,
|
||||
data: {
|
||||
step: currentStep || 'Final',
|
||||
message: currentMessage,
|
||||
percentage: currentPercentage,
|
||||
status: currentStatus,
|
||||
details: progress.details,
|
||||
},
|
||||
});
|
||||
|
||||
lastLoggedStepRef.current = currentStep || currentStatus;
|
||||
}
|
||||
}
|
||||
}, [progressModal.progress, progressModal.taskId, progressModal.isOpen, progressModal.title, addAiLog]);
|
||||
|
||||
// Reset step tracking when modal closes or opens
|
||||
// Reset reload flag when modal closes or opens
|
||||
useEffect(() => {
|
||||
if (!progressModal.isOpen) {
|
||||
lastLoggedStepRef.current = null;
|
||||
lastLoggedPercentageRef.current = -1;
|
||||
hasReloadedRef.current = false; // Reset reload flag when modal closes
|
||||
} else {
|
||||
// Reset reload flag when modal opens for a new task
|
||||
@@ -804,74 +608,6 @@ export default function Tasks() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* AI Function Logs - Display below table (only when Resource Debug is enabled) */}
|
||||
{resourceDebugEnabled && aiLogs.length > 0 && (
|
||||
<div className="mt-6 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
AI Function Logs
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setAiLogs([])}
|
||||
className="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
Clear Logs
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{aiLogs.slice().reverse().map((log, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`p-3 rounded border text-xs font-mono ${
|
||||
log.type === 'request'
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
|
||||
: log.type === 'success'
|
||||
? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800'
|
||||
: log.type === 'error'
|
||||
? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800'
|
||||
: 'bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`font-semibold ${
|
||||
log.type === 'request'
|
||||
? 'text-blue-700 dark:text-blue-300'
|
||||
: log.type === 'success'
|
||||
? 'text-green-700 dark:text-green-300'
|
||||
: log.type === 'error'
|
||||
? 'text-red-700 dark:text-red-300'
|
||||
: 'text-purple-700 dark:text-purple-300'
|
||||
}`}>
|
||||
[{log.type.toUpperCase()}]
|
||||
</span>
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
{log.action}
|
||||
</span>
|
||||
{log.stepName && (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
|
||||
{log.stepName}
|
||||
</span>
|
||||
)}
|
||||
{log.percentage !== undefined && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{log.percentage}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
{new Date(log.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
<pre className="text-xs text-gray-700 dark:text-gray-300 whitespace-pre-wrap break-words">
|
||||
{JSON.stringify(log.data, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
<FormModal
|
||||
isOpen={isModalOpen}
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
/**
|
||||
* Admin System Dashboard
|
||||
* Overview page with stats, alerts, revenue, active accounts, pending approvals
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Users,
|
||||
CheckCircle,
|
||||
DollarSign,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
Activity,
|
||||
Loader2,
|
||||
ExternalLink,
|
||||
Globe,
|
||||
Database,
|
||||
Folder,
|
||||
Server,
|
||||
GitBranch,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import Badge from '../../components/ui/badge/Badge';
|
||||
import { getAdminBillingStats } from '../../services/billing.api';
|
||||
|
||||
export default function AdminSystemDashboard() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [stats, setStats] = useState<any>(null);
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
const totalUsers = Number(stats?.total_users ?? 0);
|
||||
const activeUsers = Number(stats?.active_users ?? 0);
|
||||
const issuedCredits = Number(stats?.total_credits_issued ?? stats?.credits_issued_30d ?? 0);
|
||||
const usedCredits = Number(stats?.total_credits_used ?? stats?.credits_used_30d ?? 0);
|
||||
const creditScale = Math.max(issuedCredits, usedCredits, 1);
|
||||
const issuedPct = Math.min(100, Math.round((issuedCredits / creditScale) * 100));
|
||||
const usedPct = Math.min(100, Math.round((usedCredits / creditScale) * 100));
|
||||
|
||||
const adminLinks = [
|
||||
{ label: 'Marketing Site', url: 'https://igny8.com', icon: <Globe className="w-5 h-5 text-blue-600" />, note: 'Public marketing site' },
|
||||
{ label: 'IGNY8 App', url: 'https://app.igny8.com', icon: <Globe className="w-5 h-5 text-green-600" />, note: 'Main SaaS UI' },
|
||||
{ label: 'Django Admin', url: 'https://api.igny8.com/admin', icon: <Server className="w-5 h-5 text-indigo-600" />, note: 'Backend admin UI' },
|
||||
{ label: 'PgAdmin', url: 'http://31.97.144.105:5050/', icon: <Database className="w-5 h-5 text-amber-600" />, note: 'Postgres console' },
|
||||
{ label: 'File Manager', url: 'https://files.igny8.com', icon: <Folder className="w-5 h-5 text-teal-600" />, note: 'File manager UI' },
|
||||
{ label: 'Portainer', url: 'http://31.97.144.105:9443', icon: <Server className="w-5 h-5 text-purple-600" />, note: 'Container management' },
|
||||
{ label: 'API Docs (Swagger)', url: 'https://api.igny8.com/api/docs/', icon: <FileText className="w-5 h-5 text-orange-600" />, note: 'Swagger UI' },
|
||||
{ label: 'API Docs (ReDoc)', url: 'https://api.igny8.com/api/redoc/', icon: <FileText className="w-5 h-5 text-rose-600" />, note: 'ReDoc docs' },
|
||||
{ label: 'Gitea Repo', url: 'https://git.igny8.com/salman/igny8', icon: <GitBranch className="w-5 h-5 text-gray-700" />, note: 'Source control' },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
}, []);
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await getAdminBillingStats();
|
||||
setStats(data);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load system stats');
|
||||
console.error('Admin stats load error:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">System Dashboard</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Overview of system health and billing activity
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg flex items-center gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-600" />
|
||||
<p className="text-red-800 dark:text-red-200">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 mb-1">Total Users</div>
|
||||
<div className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{totalUsers.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<Users className="w-12 h-12 text-blue-600 opacity-50" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 mb-1">Active Users</div>
|
||||
<div className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{activeUsers.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<CheckCircle className="w-12 h-12 text-green-600 opacity-50" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 mb-1">Credits Issued</div>
|
||||
<div className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{issuedCredits.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mt-1">lifetime total</div>
|
||||
</div>
|
||||
<DollarSign className="w-12 h-12 text-green-600 opacity-50" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 mb-1">Credits Used</div>
|
||||
<div className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{usedCredits.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mt-1">lifetime total</div>
|
||||
</div>
|
||||
<Clock className="w-12 h-12 text-yellow-600 opacity-50" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* System Health */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<Card className="p-6">
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Activity className="w-5 h-5" />
|
||||
System Health
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-700 dark:text-gray-300">API Status</span>
|
||||
<Badge variant="light" color="success">Operational</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-700 dark:text-gray-300">Database</span>
|
||||
<Badge variant="light" color="success">Healthy</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-700 dark:text-gray-300">Background Jobs</span>
|
||||
<Badge variant="light" color="success">Running</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-700 dark:text-gray-300">Last Check</span>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{stats?.system_health?.last_check || 'Just now'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Credit Usage</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">Issued (30 days)</span>
|
||||
<span className="font-semibold">{issuedCredits.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div className="bg-blue-600 h-2 rounded-full" style={{ width: `${issuedPct}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">Used (30 days)</span>
|
||||
<span className="font-semibold">{usedCredits.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div className="bg-green-600 h-2 rounded-full" style={{ width: `${usedPct}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Admin Quick Access */}
|
||||
<Card className="p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Admin Quick Access</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Open common admin tools directly</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{adminLinks.map((link) => (
|
||||
<a
|
||||
key={link.url}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-start justify-between rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-4 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5">{link.icon}</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-white">{link.label}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{link.note}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ExternalLink className="w-4 h-4 text-gray-400" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ interface User {
|
||||
email: string;
|
||||
username: string;
|
||||
role: string;
|
||||
is_staff?: boolean;
|
||||
account?: {
|
||||
id: number;
|
||||
name: string;
|
||||
|
||||
Reference in New Issue
Block a user