Skip to content

Commit

Permalink
Provider Activity Logs (#678)
Browse files Browse the repository at this point in the history
* feat(provider): added logs per task both streaming and static

* fix(provider): closing stream on status change

* fix: fixed any types in logs query
  • Loading branch information
jigar-arc10 authored Jan 28, 2025
1 parent 08811f5 commit 9723093
Show file tree
Hide file tree
Showing 5 changed files with 373 additions and 118 deletions.
2 changes: 2 additions & 0 deletions apps/provider-console/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
149 changes: 143 additions & 6 deletions apps/provider-console/src/components/shared/ActivityLogDetails.tsx
Original file line number Diff line number Diff line change
@@ -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<boolean[]>([]);
const [taskLogs, setTaskLogs] = useState<TaskLogs>({});
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<StaticLogsResponse, StaticLogsResponse>(`/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 (
<div className="mt-4 flex items-center justify-center" style={{ height: 200 }}>
<Spinner className="text-blue-500" />
</div>
);
}

if (!logs) {
return (
<div className="mt-4 flex items-center justify-center text-gray-500" style={{ height: 200 }}>
No logs recorded for this task or this task is more than 7 days old.
</div>
);
}

return (
<div className="mt-4" style={{ height: 200 }}>
<ScrollFollow
startFollowing={true}
render={({ follow, onScroll }) => (
<LazyLog
text={logs}
follow={follow}
onScroll={onScroll}
highlight={[]}
extraLines={1}
ansi
caseInsensitive
selectableLines
enableLineNumbers={false}
containerStyle={{
maxHeight: "200px",
borderRadius: "0.375rem"
}}
/>
)}
/>
</div>
);
};

if (actionId === null) {
return (
<div className="flex w-full flex-col items-center pt-10">
Expand Down Expand Up @@ -52,8 +187,8 @@ export const ActivityLogDetails: React.FC<{ actionId: string | null }> = ({ acti
<Separator />
<p className="text-sm text-gray-500">{actionDetails?.id}</p>
<p className="text-sm text-gray-500">
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)}`}
</p>
</div>

Expand All @@ -63,7 +198,7 @@ export const ActivityLogDetails: React.FC<{ actionId: string | null }> = ({ acti
<div key={index}>
<div
className="flex cursor-pointer items-center justify-between p-4 hover:bg-gray-50 dark:hover:bg-gray-600/50"
onClick={() => toggleAccordion(index)}
onClick={() => handleAccordionToggle(index, task)}
>
<div className="flex items-center">
{openAccordions[index] ? <ArrowDown className="mr-2 h-5 w-5" /> : <ArrowRight className="mr-2 h-5 w-5" />}
Expand All @@ -90,6 +225,8 @@ export const ActivityLogDetails: React.FC<{ actionId: string | null }> = ({ acti
<p className="text-sm">{task.description}</p>
{task.start_time && <p className="text-xs text-gray-500">Started: {formatLocalTime(task.start_time)}</p>}
{task.end_time && <p className="text-xs text-gray-500">Ended: {formatLocalTime(task.end_time)}</p>}

{renderLogs(taskLogs[task.id], task.id)}
</div>
)}
{index < actionDetails?.tasks.length - 1 && <div className="border-t"></div>}
Expand Down
31 changes: 22 additions & 9 deletions apps/provider-console/src/queries/useProviderQuery.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<any>(`v1/deployment/${owner}/${dseq}`),
consoleClient.get<any>(`/v1/blocks`)
]);
const [response, latestBlocks]: any = await Promise.all([consoleClient.get<any>(`v1/deployment/${owner}/${dseq}`), consoleClient.get<any>(`/v1/blocks`)]);

const latestBlock = latestBlocks[0].height;
const totalAmtSpent = findTotalAmountSpentOnLeases(response.leases, latestBlock, true);
Expand Down Expand Up @@ -93,13 +90,29 @@ export const useProviderActions = () => {
};

export const useProviderActionStatus = (actionId: string | null) => {
return useQuery({
return useQuery<ActionStatus>({
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"]
}))
})
});
};

Expand Down
Loading

0 comments on commit 9723093

Please sign in to comment.