diff --git a/apps/provider-console/package.json b/apps/provider-console/package.json index 987693d29..f0155b20a 100644 --- a/apps/provider-console/package.json +++ b/apps/provider-console/package.json @@ -19,6 +19,7 @@ "@cosmos-kit/leap-extension": "^2.12.2", "@cosmos-kit/react": "^2.18.0", "@hookform/resolvers": "^3.9.0", + "@melloware/react-logviewer": "^6.1.2", "@mui/icons-material": "^5.11.11", "@mui/material": "^5.4.4", "@mui/material-nextjs": "^5.15.11", @@ -30,6 +31,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "date-fns": "^2.29.3", + "event-source-polyfill": "^1.0.31", "geist": "^1.3.0", "iconoir-react": "^7.9.0", "jotai": "^2.9.0", diff --git a/apps/provider-console/src/components/shared/ActivityLogDetails.tsx b/apps/provider-console/src/components/shared/ActivityLogDetails.tsx index 3249f82a4..cee2da697 100644 --- a/apps/provider-console/src/components/shared/ActivityLogDetails.tsx +++ b/apps/provider-console/src/components/shared/ActivityLogDetails.tsx @@ -1,29 +1,164 @@ "use client"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { Separator, Spinner } from "@akashnetwork/ui/components"; +import { LazyLog, ScrollFollow } from "@melloware/react-logviewer"; +import { EventSourcePolyfill } from "event-source-polyfill"; import { ArrowDown, ArrowRight, Check, Xmark } from "iconoir-react"; +import { browserEnvConfig } from "@src/config/browser-env.config"; import { useProviderActionStatus } from "@src/queries/useProviderQuery"; +import { StaticLog, StaticLogsResponse, Task, TaskLogs } from "@src/types/provider"; import { formatLocalTime, formatTimeLapse } from "@src/utils/dateUtils"; +import restClient from "@src/utils/restClient"; +import { checkAndRefreshToken } from "@src/utils/tokenUtils"; export const ActivityLogDetails: React.FC<{ actionId: string | null }> = ({ actionId }) => { const [openAccordions, setOpenAccordions] = useState([]); + const [taskLogs, setTaskLogs] = useState({}); + const [loadingLogs, setLoadingLogs] = useState<{ [taskId: string]: boolean }>({}); + const logStreams = useRef<{ [taskId: string]: EventSourcePolyfill | null }>({}); const { data: actionDetails, isLoading } = useProviderActionStatus(actionId); useEffect(() => { if (actionDetails) { - setOpenAccordions(new Array(actionDetails.tasks.length).fill(false)); + const initialAccordions = actionDetails.tasks.map(task => task.status === "in_progress"); + setOpenAccordions(initialAccordions); } }, [actionDetails]); - const toggleAccordion = (index: number) => { + useEffect(() => { + if (actionDetails?.tasks) { + Object.values(logStreams.current).forEach(stream => stream?.close()); + logStreams.current = {}; + + actionDetails.tasks.forEach((task, index) => { + if (task.status === "in_progress") { + setOpenAccordions(prev => { + const newState = [...prev]; + newState[index] = true; + return newState; + }); + setupLogStream(task.id); + } else { + if (logStreams.current[task.id]) { + logStreams.current[task.id]?.close(); + logStreams.current[task.id] = null; + } + } + }); + } + + return () => { + Object.values(logStreams.current).forEach(stream => stream?.close()); + logStreams.current = {}; + }; + }, [actionDetails?.tasks]); + + const fetchTaskLogs = async (taskId: string) => { + setLoadingLogs(prev => ({ ...prev, [taskId]: true })); + try { + const response = await restClient.get(`/tasks/logs/archive/${taskId}`); + setTaskLogs(prev => ({ + ...prev, + [taskId]: response.logs.map((log: StaticLog) => `${log.type === "stderr" ? "[ERROR] " : ""}${log.message}`).join("\n") + })); + } finally { + setLoadingLogs(prev => ({ ...prev, [taskId]: false })); + } + }; + + const setupLogStream = async (taskId: string) => { + if (logStreams.current[taskId]) return; + + const token = await checkAndRefreshToken(); + const eventSource = new EventSourcePolyfill(`${browserEnvConfig.NEXT_PUBLIC_API_BASE_URL}/tasks/logs/${taskId}`, { + headers: { Authorization: `Bearer ${token}` } + }); + logStreams.current[taskId] = eventSource; + + eventSource.onmessage = event => { + try { + const logData = JSON.parse(event.data); + const formattedMessage = `${logData.type === "stderr" ? "[ERROR] " : ""}${logData.message}`; + + setTaskLogs(prev => ({ + ...prev, + [taskId]: prev[taskId] ? `${prev[taskId]}\n${formattedMessage}` : formattedMessage + })); + } catch (error) { + console.error("Error parsing log message:", error); + } + }; + + eventSource.onerror = () => { + eventSource.close(); + logStreams.current[taskId] = null; + }; + }; + + const handleAccordionToggle = (index: number, task: Task) => { setOpenAccordions(prev => { const newState = [...prev]; newState[index] = !newState[index]; + + if (newState[index]) { + if (task.status === "in_progress") { + setupLogStream(task.id); + } else if (!taskLogs[task.id]) { + fetchTaskLogs(task.id); + } + } else { + logStreams.current[task.id]?.close(); + logStreams.current[task.id] = null; + } + return newState; }); }; + const renderLogs = (logs: string, taskId: string) => { + if (loadingLogs[taskId]) { + return ( +
+ +
+ ); + } + + if (!logs) { + return ( +
+ No logs recorded for this task or this task is more than 7 days old. +
+ ); + } + + return ( +
+ ( + + )} + /> +
+ ); + }; + if (actionId === null) { return (
@@ -52,8 +187,8 @@ export const ActivityLogDetails: React.FC<{ actionId: string | null }> = ({ acti

{actionDetails?.id}

- Started: {formatLocalTime(actionDetails?.start_time)} - {actionDetails?.end_time && ` | Ended: ${formatLocalTime(actionDetails?.end_time)}`} + Started: {formatLocalTime(actionDetails?.start_time ?? null)} + {actionDetails?.end_time && ` | Ended: ${formatLocalTime(actionDetails?.end_time ?? null)}`}

@@ -63,7 +198,7 @@ export const ActivityLogDetails: React.FC<{ actionId: string | null }> = ({ acti
toggleAccordion(index)} + onClick={() => handleAccordionToggle(index, task)} >
{openAccordions[index] ? : } @@ -90,6 +225,8 @@ export const ActivityLogDetails: React.FC<{ actionId: string | null }> = ({ acti

{task.description}

{task.start_time &&

Started: {formatLocalTime(task.start_time)}

} {task.end_time &&

Ended: {formatLocalTime(task.end_time)}

} + + {renderLogs(taskLogs[task.id], task.id)}
)} {index < actionDetails?.tasks.length - 1 &&
} diff --git a/apps/provider-console/src/queries/useProviderQuery.ts b/apps/provider-console/src/queries/useProviderQuery.ts index 7f6a42166..d47a88b6d 100644 --- a/apps/provider-console/src/queries/useProviderQuery.ts +++ b/apps/provider-console/src/queries/useProviderQuery.ts @@ -1,7 +1,7 @@ import { useQuery } from "react-query"; import { ControlMachineWithAddress } from "@src/types/controlMachine"; -import { PersistentStorageResponse, ProviderDashoard, ProviderDetails } from "@src/types/provider"; +import { ActionStatus, PersistentStorageResponse, ProviderDashoard, ProviderDetails, Task } from "@src/types/provider"; import consoleClient from "@src/utils/consoleClient"; import { findTotalAmountSpentOnLeases, totalDeploymentCost, totalDeploymentTimeLeft } from "@src/utils/deploymentUtils"; import restClient from "@src/utils/restClient"; @@ -39,10 +39,7 @@ export const useDeploymentDetails = (owner: string, dseq: string) => { return useQuery({ queryKey: ["deployment", owner, dseq], queryFn: async () => { - const [response, latestBlocks]: any = await Promise.all([ - consoleClient.get(`v1/deployment/${owner}/${dseq}`), - consoleClient.get(`/v1/blocks`) - ]); + const [response, latestBlocks]: any = await Promise.all([consoleClient.get(`v1/deployment/${owner}/${dseq}`), consoleClient.get(`/v1/blocks`)]); const latestBlock = latestBlocks[0].height; const totalAmtSpent = findTotalAmountSpentOnLeases(response.leases, latestBlock, true); @@ -93,13 +90,29 @@ export const useProviderActions = () => { }; export const useProviderActionStatus = (actionId: string | null) => { - return useQuery({ + return useQuery({ queryKey: ["providerActionStatus", actionId], queryFn: () => restClient.get(`/action/status/${actionId}`), enabled: !!actionId, - refetchInterval: (data: any) => - data?.status === "completed" || data?.status === "failed" ? false : 5000, - retry: 3 + refetchInterval: data => { + if (data?.tasks?.some(task => task.status === "in_progress")) { + return 1000; + } + return false; + }, + keepPreviousData: true, + refetchOnWindowFocus: query => { + const data = query.state.data; + return data?.tasks?.some(task => task.status === "in_progress") ?? false; + }, + retry: 3, + select: (data: ActionStatus) => ({ + ...data, + tasks: data.tasks.map(task => ({ + ...task, + status: task.status.toLowerCase() as Task["status"] + })) + }) }); }; diff --git a/apps/provider-console/src/types/provider.ts b/apps/provider-console/src/types/provider.ts index 9a49fbca4..6c9a723a9 100644 --- a/apps/provider-console/src/types/provider.ts +++ b/apps/provider-console/src/types/provider.ts @@ -1,131 +1,162 @@ export interface ProviderDetails { - owner: string; - name: string | null; - hostUri: string; - createdHeight: number; - email: string | null; - website: string; - lastCheckDate: string; - deploymentCount: number; - leaseCount: number; - cosmosSdkVersion: string | null; - akashVersion: string | null; - ipRegion: string; - ipRegionCode: string; - ipCountry: string; - ipCountryCode: string; - ipLat: string; - ipLon: string; - activeStats: Stats; - pendingStats: Stats; - availableStats: Stats; - gpuModels: string[]; - uptime1d: number; - uptime7d: number; - uptime30d: number; - isValidVersion: boolean; - isOnline: boolean; - lastOnlineDate: string; - isAudited: boolean; - attributes: Attribute[]; - host: string | null; - organization: string | null; - statusPage: string | null; - locationRegion: string[]; - country: string | null; - city: string | null; - timezone: string[]; - locationType: string[]; - hostingProvider: string | null; - hardwareCpu: string[]; - hardwareCpuArch: string[]; - hardwareGpuVendor: string[]; - hardwareGpuModels: string[]; - hardwareDisk: string[]; - featPersistentStorage: boolean; - featPersistentStorageType: string[]; - hardwareMemory: string[]; - networkProvider: string | null; - networkSpeedDown: number; - networkSpeedUp: number; - tier: string[]; - featEndpointCustomDomain: boolean; - workloadSupportChia: boolean; - workloadSupportChiaCapabilities: string[]; - featEndpointIp: boolean; - uptime: Uptime[]; + owner: string; + name: string | null; + hostUri: string; + createdHeight: number; + email: string | null; + website: string; + lastCheckDate: string; + deploymentCount: number; + leaseCount: number; + cosmosSdkVersion: string | null; + akashVersion: string | null; + ipRegion: string; + ipRegionCode: string; + ipCountry: string; + ipCountryCode: string; + ipLat: string; + ipLon: string; + activeStats: Stats; + pendingStats: Stats; + availableStats: Stats; + gpuModels: string[]; + uptime1d: number; + uptime7d: number; + uptime30d: number; + isValidVersion: boolean; + isOnline: boolean; + lastOnlineDate: string; + isAudited: boolean; + attributes: Attribute[]; + host: string | null; + organization: string | null; + statusPage: string | null; + locationRegion: string[]; + country: string | null; + city: string | null; + timezone: string[]; + locationType: string[]; + hostingProvider: string | null; + hardwareCpu: string[]; + hardwareCpuArch: string[]; + hardwareGpuVendor: string[]; + hardwareGpuModels: string[]; + hardwareDisk: string[]; + featPersistentStorage: boolean; + featPersistentStorageType: string[]; + hardwareMemory: string[]; + networkProvider: string | null; + networkSpeedDown: number; + networkSpeedUp: number; + tier: string[]; + featEndpointCustomDomain: boolean; + workloadSupportChia: boolean; + workloadSupportChiaCapabilities: string[]; + featEndpointIp: boolean; + uptime: Uptime[]; } interface Stats { - cpu: number; - gpu: number; - memory: number; - storage: number; + cpu: number; + gpu: number; + memory: number; + storage: number; } interface Attribute { - key: string; - value: string; - auditedBy: string[]; + key: string; + value: string; + auditedBy: string[]; } interface Uptime { - id: string; - isOnline: boolean; - checkDate: string; + id: string; + isOnline: boolean; + checkDate: string; } interface LeaseStats { - date: string; - height: number; - activeLeaseCount: number; - totalLeaseCount: number; - dailyLeaseCount: number; - totalUAktEarned: number; - dailyUAktEarned: number; - totalUUsdcEarned: number; - dailyUUsdcEarned: number; - totalUUsdEarned: number; - dailyUUsdEarned: number; - activeCPU: number; - activeGPU: number; - activeMemory: string; - activeEphemeralStorage: string; - activePersistentStorage: string; - activeStorage: string; + date: string; + height: number; + activeLeaseCount: number; + totalLeaseCount: number; + dailyLeaseCount: number; + totalUAktEarned: number; + dailyUAktEarned: number; + totalUUsdcEarned: number; + dailyUUsdcEarned: number; + totalUUsdEarned: number; + dailyUUsdEarned: number; + activeCPU: number; + activeGPU: number; + activeMemory: string; + activeEphemeralStorage: string; + activePersistentStorage: string; + activeStorage: string; } export interface ProviderDashoard { - current: LeaseStats; - previous: LeaseStats; + current: LeaseStats; + previous: LeaseStats; } export interface ProviderPricingType { - cpu: number; - memory: number; - storage: number; - persistentStorage: number; - gpu: number; - ipScalePrice: number; - endpointBidPrice: number; + cpu: number; + memory: number; + storage: number; + persistentStorage: number; + gpu: number; + ipScalePrice: number; + endpointBidPrice: number; } interface BlockDevice { - name: string; - size: number; - type: string; - fstype: string | null; - mountpoint: string | null; - rota: boolean; - storage_type: 'hdd' | 'ssd'; + name: string; + size: number; + type: string; + fstype: string | null; + mountpoint: string | null; + rota: boolean; + storage_type: "hdd" | "ssd"; } interface NodeDrives { - blockdevices: BlockDevice[]; + blockdevices: BlockDevice[]; } export interface PersistentStorageResponse { - unformatted_drives: { - [nodeName: string]: NodeDrives; - }; -} \ No newline at end of file + unformatted_drives: { + [nodeName: string]: NodeDrives; + }; +} + +export interface Task { + id: string; + title: string; + description: string; + status: "completed" | "in_progress" | "failed" | "not_started"; + start_time: string; + end_time: string; +} + +export interface ActionStatus { + id: string; + name: string; + status: "completed" | "in_progress" | "failed" | "not_started"; + start_time: string; + end_time: string; + tasks: Task[]; +} + +export interface TaskLogs { + [taskId: string]: string; +} + +export interface StaticLog { + type: string; + message: string; +} + +export interface StaticLogsResponse { + logs: StaticLog[]; +} diff --git a/package-lock.json b/package-lock.json index a23c8edb5..f389daf16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -574,6 +574,7 @@ "@cosmos-kit/leap-extension": "^2.12.2", "@cosmos-kit/react": "^2.18.0", "@hookform/resolvers": "^3.9.0", + "@melloware/react-logviewer": "^6.1.2", "@mui/icons-material": "^5.11.11", "@mui/material": "^5.4.4", "@mui/material-nextjs": "^5.15.11", @@ -585,6 +586,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "date-fns": "^2.29.3", + "event-source-polyfill": "^1.0.31", "geist": "^1.3.0", "iconoir-react": "^7.9.0", "jotai": "^2.9.0", @@ -8011,6 +8013,21 @@ "react-dom": "^17.0.2 || ^18.0.0 || ^19.0" } }, + "node_modules/@melloware/react-logviewer": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@melloware/react-logviewer/-/react-logviewer-6.1.2.tgz", + "integrity": "sha512-WDw3VIGqhoXxDn93HFDicwRhi4+FQyaKiVTB07bWerT82gTgyWV7bOciVV33z25N3WJrz62j5FKVzvFZCu17/A==", + "dependencies": { + "hotkeys-js": "3.13.9", + "mitt": "3.0.1", + "react-string-replace": "1.1.1", + "virtua": "0.39.3" + }, + "peerDependencies": { + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, "node_modules/@metamask/object-multiplex": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@metamask/object-multiplex/-/object-multiplex-1.3.0.tgz", @@ -22225,6 +22242,11 @@ "resolved": "https://registry.npmjs.org/event-lite/-/event-lite-0.1.3.tgz", "integrity": "sha512-8qz9nOz5VeD2z96elrEKD2U433+L3DWdUdDkOINLGOJvx1GsMBbMn0aCeu28y8/e85A6mCigBiFlYMnTBEGlSw==" }, + "node_modules/event-source-polyfill": { + "version": "1.0.31", + "resolved": "https://registry.npmjs.org/event-source-polyfill/-/event-source-polyfill-1.0.31.tgz", + "integrity": "sha512-4IJSItgS/41IxN5UVAVuAyczwZF7ZIEsM1XAoUzIHA6A+xzusEZUutdXz2Nr+MQPLxfTiCvqE79/C8HT8fKFvA==" + }, "node_modules/event-target-shim": { "version": "5.0.1", "license": "MIT", @@ -24202,6 +24224,14 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" }, + "node_modules/hotkeys-js": { + "version": "3.13.9", + "resolved": "https://registry.npmjs.org/hotkeys-js/-/hotkeys-js-3.13.9.tgz", + "integrity": "sha512-3TRCj9u9KUH6cKo25w4KIdBfdBfNRjfUwrljCLDC2XhmPDG0SjAZFcFZekpUZFmXzfYoGhFDcdx2gX/vUVtztQ==", + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "license": "MIT" @@ -30108,6 +30138,11 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==" + }, "node_modules/mixin-deep": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz", @@ -33647,6 +33682,14 @@ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0" } }, + "node_modules/react-string-replace": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/react-string-replace/-/react-string-replace-1.1.1.tgz", + "integrity": "sha512-26TUbLzLfHQ5jO5N7y3Mx88eeKo0Ml0UjCQuX4BMfOd/JX+enQqlKpL1CZnmjeBRvQE8TR+ds9j1rqx9CxhKHQ==", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/react-style-singleton": { "version": "2.2.1", "license": "MIT", @@ -39423,6 +39466,35 @@ "node": ">=12" } }, + "node_modules/virtua": { + "version": "0.39.3", + "resolved": "https://registry.npmjs.org/virtua/-/virtua-0.39.3.tgz", + "integrity": "sha512-Ep3aiJXSGPm1UUniThr5mGDfG0upAleP7pqQs5mvvCgM1wPhII1ZKa7eNCWAJRLkC+InpXKokKozyaaj/aMYOQ==", + "peerDependencies": { + "react": ">=16.14.0", + "react-dom": ">=16.14.0", + "solid-js": ">=1.0", + "svelte": ">=5.0", + "vue": ">=3.2" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "solid-js": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, "node_modules/vue-jscodeshift-adapter": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/vue-jscodeshift-adapter/-/vue-jscodeshift-adapter-2.2.1.tgz",