migrate frontend to use new celery api and improve ux around spinners whilst loading

This commit is contained in:
Viktor Barzin 2025-06-22 21:20:42 +00:00
parent 1ad8a12f3d
commit 9a164ddfdc
No known key found for this signature in database
GPG key ID: 4056458DBDBF8863
3 changed files with 135 additions and 76 deletions

View file

@ -8,6 +8,7 @@ import AlertError from './components/AlertError';
import LoginModal from './components/LoginModal'; import LoginModal from './components/LoginModal';
import { Map } from './components/Map'; import { Map } from './components/Map';
import { Parameters, type ParameterValues } from './components/Parameters'; import { Parameters, type ParameterValues } from './components/Parameters';
import { Spinner } from './components/Spinner';
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from './components/ui/breadcrumb'; import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from './components/ui/breadcrumb';
import { Button } from './components/ui/button'; import { Button } from './components/ui/button';
import { Separator } from './components/ui/separator'; import { Separator } from './components/ui/separator';
@ -64,6 +65,7 @@ function App() {
const [queryParameters, setQueryParameters] = useState<ParameterValues | null>(null); const [queryParameters, setQueryParameters] = useState<ParameterValues | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null); const [submitError, setSubmitError] = useState<string | null>(null);
const [alertDialogIsOpen, setAlertDialogIsOpen] = useState(false); const [alertDialogIsOpen, setAlertDialogIsOpen] = useState(false);
const [spinnerText, setSpinnerText] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
// Check if this is a callback from Authentik (after login) // Check if this is a callback from Authentik (after login)
@ -88,27 +90,31 @@ function App() {
setIsParametersModalOpen(false) setIsParametersModalOpen(false)
let data = null; let data = null;
if (action === 'visualize') { if (action === 'visualize') {
setSpinnerText("Loading data for visualization...")
try { try {
data = await fetchData(user, "/api/listing_geojson", parameters); data = await fetchData(user, "/api/listing_geojson", parameters);
} catch (error) { } catch (error) {
// @ts-expect-error // @ts-expect-error
setSubmitError(error.message) setSubmitError(error.message)
setAlertDialogIsOpen(true) setAlertDialogIsOpen(true)
} finally {
setSpinnerText(null)
} }
if (data) { if (data) {
setListingData(data); setListingData(data);
} }
} else if (action === 'fetch-data') { } else if (action === 'fetch-data') {
setSpinnerText("Submitting query to refresh listings...")
try { try {
data = await fetchData(user, "/api/refresh_listings", parameters, 'POST'); data = await fetchData(user, "/api/refresh_listings", parameters, 'POST');
// @ts-expect-error
setTaskID(data.task_id)
} catch (error) { } catch (error) {
// @ts-expect-error // @ts-expect-error
setSubmitError(error.message) setSubmitError(error.message)
setAlertDialogIsOpen(true) setAlertDialogIsOpen(true)
} } finally {
if (data) { setSpinnerText(null)
// @ts-expect-error
setTaskID(data.task_id)
} }
} }
console.log(data) console.log(data)
@ -146,6 +152,11 @@ function App() {
<Parameters onSubmit={onSubmit} isOpen={isParametersModalOpen} setIsOpen={setIsParametersModalOpen} /> <Parameters onSubmit={onSubmit} isOpen={isParametersModalOpen} setIsOpen={setIsParametersModalOpen} />
<ActiveQuery taskID={taskID} /> <ActiveQuery taskID={taskID} />
<AlertError message={submitError} open={alertDialogIsOpen} setIsOpen={setAlertDialogIsOpen} /> <AlertError message={submitError} open={alertDialogIsOpen} setIsOpen={setAlertDialogIsOpen} />
<Spinner show={spinnerText !== null} >
<span >{spinnerText}</span>
</Spinner>
</div> </div>
{Object.keys(listingData).length > 0 && {Object.keys(listingData).length > 0 &&
<div className="flex-1 w-full relative" style={{ minHeight: 0, marginBottom: '8rem' }}> <div className="flex-1 w-full relative" style={{ minHeight: 0, marginBottom: '8rem' }}>
@ -153,9 +164,6 @@ function App() {
</div> </div>
} }
</div> </div>
</SidebarInset> </SidebarInset>
</SidebarProvider> </SidebarProvider>
</> </>

View file

@ -9,7 +9,7 @@ interface ModalProps {
taskID: string | null; taskID: string | null;
} }
const fetchTaskStatus = async (user: User, taskID: string) => { const fetchTaskStatusData = async (user: User, taskID: string) => {
const accessToken = user?.access_token; const accessToken = user?.access_token;
const response = await fetch(`/api/task_status?task_id=${taskID}`, { const response = await fetch(`/api/task_status?task_id=${taskID}`, {
method: 'GET', method: 'GET',
@ -27,40 +27,41 @@ const fetchTaskStatus = async (user: User, taskID: string) => {
return data; return data;
}; };
enum TaskStatus { type TaskStatus = string
QUEUED = 'queued', // enum TaskStatus {
PROCESSING = 'processing', // QUEUED = 'queued',
COMPLETED = 'completed', // PROCESSING = 'processing',
FAILED = 'failed', // COMPLETED = 'completed',
} // FAILED = 'failed',
// }
const taskStatusToProgress = (taskStatus: TaskStatus): number => { // const taskStatusToProgress = (taskStatus: TaskStatus): number => {
switch (taskStatus) { // switch (taskStatus) {
case TaskStatus.QUEUED: // case TaskStatus.QUEUED:
return 0.33; // Queued status // return 0.33; // Queued status
case TaskStatus.PROCESSING: // case TaskStatus.PROCESSING:
return 0.66; // Processing status // return 0.66; // Processing status
case TaskStatus.COMPLETED: // case TaskStatus.COMPLETED:
return 1.0; // Completed status // return 1.0; // Completed status
default: // default:
throw new Error('Unknown task status: ' + status); // throw new Error('Unknown task status: ' + status);
} // }
} // }
const getTaskStatus = (status: string): TaskStatus => { // const getTaskStatus = (status: string): TaskStatus => {
switch (status.toLowerCase()) { // switch (status.toLowerCase()) {
case 'queued': // case 'queued':
return TaskStatus.QUEUED; // return TaskStatus.QUEUED;
case 'processing': // case 'processing':
return TaskStatus.PROCESSING; // return TaskStatus.PROCESSING;
case 'completed': // case 'completed':
return TaskStatus.COMPLETED; // return TaskStatus.COMPLETED;
case 'failed': // case 'failed':
return TaskStatus.FAILED; // return TaskStatus.FAILED;
default: // default:
throw new Error('Unknown task status: ' + status); // throw new Error('Unknown task status: ' + status);
} // }
}; // };
const ActiveQuery: React.FC<ModalProps> = ({ const ActiveQuery: React.FC<ModalProps> = ({
taskID taskID
@ -71,50 +72,54 @@ const ActiveQuery: React.FC<ModalProps> = ({
}, []); }, []);
const [progressPercentage, setProgressPercentage] = useState<number>(0); const [progressPercentage, setProgressPercentage] = useState<number>(0);
const [taskStatus, setTaskStatus] = useState<TaskStatus | null>(TaskStatus.QUEUED); const [taskStatus, setTaskStatus] = useState<TaskStatus | null>("PENDING");
const [lastUpdateTime, setLastUpdateTime] = useState<Date>(new Date()); const [lastUpdateTime, setLastUpdateTime] = useState<Date>(new Date());
const [fetchStatusError, setFetchStatusError] = useState<string | null>(null); const [fetchStatusError, setFetchStatusError] = useState<string | null>(null);
const [alertDialogIsOpen, setAlertDialogIsOpen] = useState(false); const [alertDialogIsOpen, setAlertDialogIsOpen] = useState(false);
const fetchTaskStatus = async (interval: NodeJS.Timeout) => {
if (!user || !taskID) {
return;
}
let data = null
try {
data = await fetchTaskStatusData(user, taskID);
} catch (error: any) {
clearInterval(interval);
setTaskStatus("FAILURE")
setAlertDialogIsOpen(true)
if (error instanceof Error) {
setFetchStatusError(error.message)
} else {
setFetchStatusError('Failed to update task status: ' + error.toString())
}
}
if (!data) {
clearInterval(interval);
return;
}
setLastUpdateTime(new Date());
// const taskStatus = getTaskStatus(data.status);
const taskStatus = data.status;
if (taskStatus === "FAILURE") {
clearInterval(interval);
throw new Error('Task failed');
}
setTaskStatus(taskStatus);
// const progress = taskStatusToProgress(taskStatus);
const parsedResult = JSON.parse(data.result)
setProgressPercentage(parsedResult.progress * 100);
if (taskStatus === "SUCCESS") {
clearInterval(interval);
return;
}
};
// fetch status periodically // fetch status periodically
// maybe move to ws one day // maybe move to ws one day
useEffect(() => { useEffect(() => {
const interval = setInterval const interval = setInterval
(async () => { (() => fetchTaskStatus(interval), 5000); // every 5 seconds
if (!user || !taskID) {
return;
}
let data = null
try {
data = await fetchTaskStatus(user, taskID);
} catch (error: any) {
clearInterval(interval);
setTaskStatus(TaskStatus.FAILED)
setAlertDialogIsOpen(true)
if (error instanceof Error) {
setFetchStatusError(error.message)
} else {
setFetchStatusError('Failed to update task status: ' + error.toString())
}
}
if (!data) {
clearInterval(interval);
return;
}
setLastUpdateTime(new Date());
const taskStatus = getTaskStatus(data.status);
if (taskStatus === TaskStatus.FAILED) {
clearInterval(interval);
throw new Error('Task failed');
}
setTaskStatus(taskStatus);
const progress = taskStatusToProgress(taskStatus);
setProgressPercentage(progress * 100);
if (taskStatus === TaskStatus.COMPLETED) {
clearInterval(interval);
return;
}
}, 5000); // every 5 seconds
return () => clearInterval(interval); return () => clearInterval(interval);
}, [taskID]); }, [taskID]);

View file

@ -0,0 +1,46 @@
import { cn } from '@/lib/utils';
import { type VariantProps, cva } from 'class-variance-authority';
import { Loader2 } from 'lucide-react';
import React from 'react';
const spinnerVariants = cva('flex-col items-center justify-center', {
variants: {
show: {
true: 'flex',
false: 'hidden',
},
},
defaultVariants: {
show: true,
},
});
const loaderVariants = cva('animate-spin text-primary', {
variants: {
size: {
small: 'size-6',
medium: 'size-8',
large: 'size-12',
},
},
defaultVariants: {
size: 'medium',
},
});
interface SpinnerContentProps
extends VariantProps<typeof spinnerVariants>,
VariantProps<typeof loaderVariants> {
className?: string;
children?: React.ReactNode;
}
export function Spinner({ size, show, children, className }: SpinnerContentProps) {
return (
<span className={spinnerVariants({ show })}>
<Loader2 className={cn(loaderVariants({ size }), className)} />
{/* <span className="text-red-400">Loading with custom style</span> */}
{children}
</span>
);
}