diff --git a/frontend/src/components/sidebar/ApiStatusIndicator.tsx b/frontend/src/components/sidebar/ApiStatusIndicator.tsx index fbb22159..74d41b60 100644 --- a/frontend/src/components/sidebar/ApiStatusIndicator.tsx +++ b/frontend/src/components/sidebar/ApiStatusIndicator.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { API_BASE_URL } from "../../services/api"; +import { useAuthStore } from "../../store/authStore"; interface GroupStatus { name: string; @@ -77,10 +78,19 @@ const endpointGroups = [ ]; export default function ApiStatusIndicator() { + const { user } = useAuthStore(); const [groupStatuses, setGroupStatuses] = useState([]); const [isChecking, setIsChecking] = useState(false); const intervalRef = useRef | null>(null); + // Only show and run for aws-admin accounts + const isAwsAdmin = user?.account?.slug === 'aws-admin'; + + // Return null if not aws-admin account + if (!isAwsAdmin) { + return null; + } + const checkEndpoint = useCallback(async (path: string, method: string): Promise<'healthy' | 'warning' | 'error'> => { try { const token = localStorage.getItem('auth_token') || @@ -135,8 +145,22 @@ export default function ApiStatusIndicator() { fetchOptions.body = JSON.stringify(body); } + // Suppress console errors for expected 400 responses (validation errors from test data) + // These are expected and indicate the endpoint is working + const isExpected400 = method === 'POST' && ( + path.includes('/login/') || + path.includes('/register/') || + path.includes('/bulk_') || + path.includes('/test/') + ); + const response = await fetch(`${API_BASE_URL}${path}`, fetchOptions); + // Suppress console errors for expected 400 responses + if (!isExpected400 || response.status !== 400) { + // Only log if it's not an expected 400 + } + if (actualMethod === 'OPTIONS') { if (response.status === 200) { return 'healthy'; @@ -158,7 +182,18 @@ export default function ApiStatusIndicator() { } return 'warning'; } else if (method === 'POST') { + // Suppress console errors for expected 400 responses (validation errors from test data) + const isExpected400 = path.includes('/login/') || + path.includes('/register/') || + path.includes('/bulk_') || + path.includes('/test/'); + if (response.status === 400) { + // 400 is expected for test requests - endpoint is working + if (!isExpected400) { + // Only log if it's unexpected + console.warn(`[ApiStatusIndicator] ${method} ${path}: 400 (unexpected)`); + } return 'healthy'; } else if (response.status >= 200 && response.status < 300) { return 'healthy'; @@ -170,6 +205,19 @@ export default function ApiStatusIndicator() { return 'error'; } return 'warning'; + } else if (method === 'PUT' || method === 'DELETE') { + // UPDATE/DELETE operations + if (response.status === 400 || response.status === 404) { + // 400/404 expected for test requests - endpoint is working + return 'healthy'; + } else if (response.status === 204 || (response.status >= 200 && response.status < 300)) { + return 'healthy'; + } else if (response.status === 401 || response.status === 403) { + return 'warning'; + } else if (response.status >= 500) { + return 'error'; + } + return 'warning'; } return 'warning'; diff --git a/frontend/src/pages/Billing/Credits.tsx b/frontend/src/pages/Billing/Credits.tsx index 32cf4512..1aeea780 100644 --- a/frontend/src/pages/Billing/Credits.tsx +++ b/frontend/src/pages/Billing/Credits.tsx @@ -52,7 +52,7 @@ export default function Credits() {

Current Balance

- {balance.credits.toLocaleString()} + {(balance.credits ?? 0).toLocaleString()}

Available credits

@@ -62,7 +62,7 @@ export default function Credits() {

Monthly Allocation

- {balance.plan_credits_per_month.toLocaleString()} + {(balance.plan_credits_per_month ?? 0).toLocaleString()}

Credits per month

@@ -72,7 +72,7 @@ export default function Credits() {

Used This Month

- {balance.credits_used_this_month.toLocaleString()} + {(balance.credits_used_this_month ?? 0).toLocaleString()}

Credits consumed

@@ -82,7 +82,7 @@ export default function Credits() {

Remaining

- {balance.credits_remaining.toLocaleString()} + {(balance.credits_remaining ?? 0).toLocaleString()}

Credits remaining

diff --git a/frontend/src/pages/Settings/ApiMonitor.tsx b/frontend/src/pages/Settings/ApiMonitor.tsx index bbef8e6d..e4368cc1 100644 --- a/frontend/src/pages/Settings/ApiMonitor.tsx +++ b/frontend/src/pages/Settings/ApiMonitor.tsx @@ -105,7 +105,7 @@ const endpointGroups: EndpointGroup[] = [ { path: "/v1/system/prompts/save/", method: "POST", description: "Save prompt" }, { path: "/v1/system/author-profiles/", method: "GET", description: "List author profiles" }, { path: "/v1/system/strategies/", method: "GET", description: "List strategies" }, - { path: "/v1/system/settings/integrations/1/test/", method: "POST", description: "Test integration" }, + { path: "/v1/system/settings/integrations/openai/test/", method: "POST", description: "Test integration (OpenAI)" }, { path: "/v1/system/settings/account/", method: "GET", description: "Account settings" }, { path: "/v1/billing/credits/balance/balance/", method: "GET", description: "Credit balance" }, { path: "/v1/billing/credits/usage/", method: "GET", description: "Usage logs" }, @@ -126,6 +126,46 @@ const endpointGroups: EndpointGroup[] = [ { path: "/v1/billing/credits/transactions/", method: "GET", description: "Transactions" }, ], }, + { + name: "CRUD Operations - Planner", + endpoints: [ + { path: "/v1/planner/keywords/", method: "GET", description: "List keywords (READ)" }, + { path: "/v1/planner/keywords/", method: "POST", description: "Create keyword (CREATE)" }, + { path: "/v1/planner/keywords/1/", method: "GET", description: "Get keyword (READ)" }, + { path: "/v1/planner/keywords/1/", method: "PUT", description: "Update keyword (UPDATE)" }, + { path: "/v1/planner/keywords/1/", method: "DELETE", description: "Delete keyword (DELETE)" }, + { path: "/v1/planner/clusters/", method: "GET", description: "List clusters (READ)" }, + { path: "/v1/planner/clusters/", method: "POST", description: "Create cluster (CREATE)" }, + { path: "/v1/planner/clusters/1/", method: "GET", description: "Get cluster (READ)" }, + { path: "/v1/planner/clusters/1/", method: "PUT", description: "Update cluster (UPDATE)" }, + { path: "/v1/planner/clusters/1/", method: "DELETE", description: "Delete cluster (DELETE)" }, + { path: "/v1/planner/ideas/", method: "GET", description: "List ideas (READ)" }, + { path: "/v1/planner/ideas/", method: "POST", description: "Create idea (CREATE)" }, + { path: "/v1/planner/ideas/1/", method: "GET", description: "Get idea (READ)" }, + { path: "/v1/planner/ideas/1/", method: "PUT", description: "Update idea (UPDATE)" }, + { path: "/v1/planner/ideas/1/", method: "DELETE", description: "Delete idea (DELETE)" }, + ], + }, + { + name: "CRUD Operations - Writer", + endpoints: [ + { path: "/v1/writer/tasks/", method: "GET", description: "List tasks (READ)" }, + { path: "/v1/writer/tasks/", method: "POST", description: "Create task (CREATE)" }, + { path: "/v1/writer/tasks/1/", method: "GET", description: "Get task (READ)" }, + { path: "/v1/writer/tasks/1/", method: "PUT", description: "Update task (UPDATE)" }, + { path: "/v1/writer/tasks/1/", method: "DELETE", description: "Delete task (DELETE)" }, + { path: "/v1/writer/content/", method: "GET", description: "List content (READ)" }, + { path: "/v1/writer/content/", method: "POST", description: "Create content (CREATE)" }, + { path: "/v1/writer/content/1/", method: "GET", description: "Get content (READ)" }, + { path: "/v1/writer/content/1/", method: "PUT", description: "Update content (UPDATE)" }, + { path: "/v1/writer/content/1/", method: "DELETE", description: "Delete content (DELETE)" }, + { path: "/v1/writer/images/", method: "GET", description: "List images (READ)" }, + { path: "/v1/writer/images/", method: "POST", description: "Create image (CREATE)" }, + { path: "/v1/writer/images/1/", method: "GET", description: "Get image (READ)" }, + { path: "/v1/writer/images/1/", method: "PUT", description: "Update image (UPDATE)" }, + { path: "/v1/writer/images/1/", method: "DELETE", description: "Delete image (DELETE)" }, + ], + }, ]; const getStatusColor = (status: string) => { @@ -245,9 +285,18 @@ export default function ApiMonitor() { body = { username: 'test', password: 'test' }; // Will fail validation but endpoint exists } else if (path.includes('/register/')) { body = { username: 'test', email: 'test@test.com', password: 'test' }; // Will fail validation but endpoint exists + } else if (path.includes('/bulk_')) { + // Bulk operations need ids array + body = { ids: [] }; + } else { + // CRUD CREATE operations - minimal valid body + body = {}; } fetchOptions.body = JSON.stringify(body); + } else if (method === 'PUT') { + // CRUD UPDATE operations - minimal valid body + fetchOptions.body = JSON.stringify({}); } const response = await fetch(`${API_BASE_URL}${path}`, fetchOptions); @@ -380,6 +429,39 @@ export default function ApiMonitor() { } else { status = 'warning'; } + } else if (method === 'PUT') { + // UPDATE operations + if (response.status === 400 || response.status === 404) { + // 400/404 expected for test requests (validation/not found) - endpoint is working + status = 'healthy'; + } else if (response.status >= 200 && response.status < 300) { + status = 'healthy'; + } else if (response.status === 429) { + status = 'warning'; // Rate limited + } else if (response.status === 401 || response.status === 403) { + status = 'warning'; // Needs authentication + } else if (response.status >= 500) { + status = 'error'; + } else { + status = 'warning'; + } + } else if (method === 'DELETE') { + // DELETE operations + if (response.status === 204 || response.status === 200) { + // 204 No Content or 200 OK - successful delete + status = 'healthy'; + } else if (response.status === 404) { + // 404 expected for test requests (resource not found) - endpoint is working + status = 'healthy'; + } else if (response.status === 429) { + status = 'warning'; // Rate limited + } else if (response.status === 401 || response.status === 403) { + status = 'warning'; // Needs authentication + } else if (response.status >= 500) { + status = 'error'; + } else { + status = 'warning'; + } } // Store API status diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index ea54abdc..7ec3061e 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -236,14 +236,15 @@ export async function fetchAPI(endpoint: string, options?: RequestInit & { timeo if (contentType.includes('application/json')) { try { errorData = JSON.parse(text); - errorMessage = errorData.error || errorData.message || errorData.detail || errorMessage; - // If the response has a success field set to false, return it as-is - // This allows callers to handle structured error responses - if (errorData.success === false && errorData.error) { - // Return the error response object instead of throwing - // This is a special case for structured error responses - return errorData; + // Handle unified error format: {success: false, error: "...", errors: {...}} + if (errorData.success === false) { + // Extract error message from unified format + errorMessage = errorData.error || errorData.message || errorData.detail || errorMessage; + // Keep errorData for structured error handling + } else { + // Old format or other error structure + errorMessage = errorData.error || errorData.message || errorData.detail || errorMessage; } // Classify error type @@ -314,12 +315,47 @@ export async function fetchAPI(endpoint: string, options?: RequestInit & { timeo } // Parse JSON response + let parsedResponse; try { - return JSON.parse(text); + parsedResponse = JSON.parse(text); } catch (e) { // If JSON parsing fails, return text return text; } + + // Handle unified API response format + // Paginated responses: {success: true, count: X, results: [...], next: ..., previous: ...} + // Single object/list responses: {success: true, data: {...}} + // Error responses: {success: false, error: "...", errors: {...}} + + // If it's a unified format response with success field + if (parsedResponse && typeof parsedResponse === 'object' && 'success' in parsedResponse) { + // For paginated responses, return as-is (results is at top level) + if ('results' in parsedResponse && 'count' in parsedResponse) { + return parsedResponse; + } + + // For single object/list responses, extract data field + if ('data' in parsedResponse) { + return parsedResponse.data; + } + + // Error responses should have been thrown already in !response.ok block above + // If we somehow get here with an error response (shouldn't happen), throw it + if (parsedResponse.success === false) { + const errorMsg = parsedResponse.error || parsedResponse.message || 'Request failed'; + const apiError = new Error(`API Error: ${errorMsg}`); + (apiError as any).response = parsedResponse; + (apiError as any).status = 400; + throw apiError; + } + + // If success is true but no data/results, return the whole response + return parsedResponse; + } + + // Not a unified format response, return as-is (backward compatibility) + return parsedResponse; } catch (error: any) { clearTimeout(timeoutId); @@ -1114,10 +1150,8 @@ export async function fetchContentImages(filters: ContentImagesFilters = {}): Pr if (filters.sector_id) params.append('sector_id', filters.sector_id.toString()); const queryString = params.toString(); - const response = await fetchAPI(`/v1/writer/images/content_images/${queryString ? `?${queryString}` : ''}`); - // Extract data field from unified API response format - // Response format: { success: true, data: { count: ..., results: [...] }, request_id: "..." } - return response?.data || response; + // fetchAPI automatically extracts data field from unified format + return fetchAPI(`/v1/writer/images/content_images/${queryString ? `?${queryString}` : ''}`); } export async function bulkUpdateImagesStatus(contentId: number, status: string): Promise<{ updated_count: number }> { @@ -1264,7 +1298,9 @@ export async function selectSectorsForSite( } export async function fetchSiteSectors(siteId: number): Promise { - return fetchAPI(`/v1/auth/sites/${siteId}/sectors/`); + const response = await fetchAPI(`/v1/auth/sites/${siteId}/sectors/`); + // fetchAPI automatically extracts data field from unified format + return Array.isArray(response) ? response : []; } // Industries API functions @@ -1291,7 +1327,20 @@ export interface IndustriesResponse { } export async function fetchIndustries(): Promise { - return fetchAPI('/v1/auth/industries/'); + const response = await fetchAPI('/v1/auth/industries/'); + // fetchAPI automatically extracts data field, but industries endpoint returns {industries: [...]} + // So we need to handle the nested structure + if (response && typeof response === 'object' && 'industries' in response) { + return { + success: true, + industries: response.industries || [] + }; + } + // If response is already an array or different format + return { + success: true, + industries: Array.isArray(response) ? response : [] + }; } // Sectors API functions @@ -1420,7 +1469,14 @@ export interface UsageSummary { } export async function fetchCreditBalance(): Promise { - return fetchAPI('/v1/billing/credits/balance/balance/'); + const response = await fetchAPI('/v1/billing/credits/balance/balance/'); + // fetchAPI automatically extracts data field from unified format + return response || { + credits: 0, + plan_credits_per_month: 0, + credits_used_this_month: 0, + credits_remaining: 0, + }; } export async function fetchCreditUsage(filters?: { @@ -1445,11 +1501,8 @@ export async function fetchUsageSummary(startDate?: string, endDate?: string): P if (endDate) params.append('end_date', endDate); const queryString = params.toString(); - const response = await fetchAPI(`/v1/billing/credits/usage/summary/${queryString ? `?${queryString}` : ''}`); - // Extract data field from unified API response format - // Response format: { success: true, data: {...}, request_id: "..." } - const summaryData = response?.data || response; - return summaryData; + // fetchAPI automatically extracts data field from unified format + return fetchAPI(`/v1/billing/credits/usage/summary/${queryString ? `?${queryString}` : ''}`); } export interface LimitCard {