Skip to main content
Glama
SuperAdminAsyncJobPage.tsx12.8 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import { Button, Group, LoadingOverlay, Stack, Table, Tabs, Title } from '@mantine/core'; import { showNotification } from '@mantine/notifications'; import type { SearchRequest, WithId } from '@medplum/core'; import { forbidden, formatSearchQuery, normalizeErrorString, Operator } from '@medplum/core'; import type { AsyncJob, Resource } from '@medplum/fhirtypes'; import { Container, MedplumLink, OperationOutcomeAlert, Panel, SearchControl, useMedplum } from '@medplum/react'; import { IconMinus, IconPlus, IconRefresh } from '@tabler/icons-react'; import type { JSX } from 'react'; import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router'; import { startAsyncJobAsync } from './SuperAdminStartAsyncJob'; const SYSTEM_ASYNCJOB = 'System AsyncJob'; const POSTDEPLOY_MIGRATIONS = 'Post-deploy Migrations'; const TABS = [POSTDEPLOY_MIGRATIONS, SYSTEM_ASYNCJOB]; export function SuperAdminAsyncDashboardPage(): JSX.Element { const medplum = useMedplum(); const [currentTab, setCurrentTab] = useState(TABS[0]); function onTabChange(newTabName: string | null): void { newTabName ||= TABS[0]; if (TABS.includes(newTabName)) { setCurrentTab(newTabName); } } if (!medplum.isLoading() && !medplum.isSuperAdmin()) { return <OperationOutcomeAlert outcome={forbidden} />; } return ( <Container miw="1600" maw="2000"> <Panel> <Title order={1}>AsyncJob Dashboard</Title> <Tabs keepMounted={false} value={currentTab} onChange={onTabChange}> <Tabs.List style={{ whiteSpace: 'nowrap', flexWrap: 'nowrap' }}> {TABS.map((t) => ( <Tabs.Tab key={t} value={t}> {t} </Tabs.Tab> ))} </Tabs.List> <Tabs.Panel value={SYSTEM_ASYNCJOB} pt="md"> <AsyncJobs /> </Tabs.Panel> <Tabs.Panel value={POSTDEPLOY_MIGRATIONS} pt="md"> <PostDeployMigrations /> </Tabs.Panel> </Tabs> </Panel> </Container> ); } const SYSTEM_ASYNCJOB_SEARCH: SearchRequest = { resourceType: 'AsyncJob', fields: ['id', '_lastUpdated', 'request', 'status', 'type'], filters: [ /* { code: 'status', operator: 'in', value: 'accepted,completed' } */ { code: '_project', operator: Operator.MISSING, value: 'true' }, ], sortRules: [{ code: '_lastUpdated', descending: true }], }; function AsyncJobs(): JSX.Element { const navigate = useNavigate(); const [search] = useState<SearchRequest>(SYSTEM_ASYNCJOB_SEARCH); return ( <Stack> <MedplumLink to={`/${search.resourceType}/${formatSearchQuery(search)}`}>Show in search page</MedplumLink> <SearchControl checkboxesEnabled={false} search={search} onClick={(e) => navigate(getResourceUrl(e.resource))?.catch(console.error)} onAuxClick={(e) => window.open(getResourceUrl(e.resource), '_blank')} /> </Stack> ); } const POSTDEPLOY_MIGRATIONS_SEARCH: SearchRequest = { resourceType: 'AsyncJob', fields: ['id', '_lastUpdated', 'request', 'status', 'type'], filters: [ { code: '_project', operator: Operator.MISSING, value: 'true' }, { code: 'type', operator: Operator.EQUALS, value: 'data-migration' }, ], count: 1000, sortRules: [{ code: '_lastUpdated', descending: true }], }; type MigrationInfo = { postDeployMigrations: number[]; pendingPostDeployMigration: number; }; function PostDeployMigrations(): JSX.Element { const medplum = useMedplum(); const [migrationInfo, setMigrationInfo] = useState<MigrationInfo>(); const [pdmAsyncJobs, setPDMAsyncJobs] = useState<WithId<AsyncJob>[] | undefined>(); const [showAllVersions, setShowAllVersions] = useState<number[]>([]); const [refreshCounter, setRefreshCounter] = useState(0); const [loadingItems, setLoadingItems] = useState<string[]>([]); useEffect(() => { const loadingName = 'info'; setLoadingItems((prev) => (prev.includes(loadingName) ? prev : [...prev, loadingName])); medplum .get('admin/super/migrations', { cache: 'no-cache' }) .then((res: MigrationInfo) => { res.postDeployMigrations.sort((a, b) => b - a); setMigrationInfo(res); }) .catch((err) => { showNotification({ color: 'red', message: normalizeErrorString(err), autoClose: false }); }) .finally(() => setLoadingItems((prev) => prev.filter((item) => item !== loadingName))); }, [medplum, refreshCounter]); useEffect(() => { const loadingName = 'jobs'; setLoadingItems((prev) => (prev.includes(loadingName) ? prev : [...prev, loadingName])); medplum .requestSchema('AsyncJob') .then(() => medplum .search( 'AsyncJob', formatSearchQuery({ ...POSTDEPLOY_MIGRATIONS_SEARCH, total: 'accurate', fields: undefined }), { cache: 'no-cache' } ) .then((asyncJobResults) => { setPDMAsyncJobs(asyncJobResults.entry?.map((entry) => entry.resource as WithId<AsyncJob>)); }) ) .catch((err) => { setPDMAsyncJobs(undefined); showNotification({ color: 'red', message: normalizeErrorString(err), autoClose: false }); }) .finally(() => setLoadingItems((prev) => prev.filter((item) => item !== loadingName))); }, [medplum, refreshCounter]); function triggerRefresh(): void { setRefreshCounter((prev) => prev + 1); } function runPendingDataMigration(version: number): void { startAsyncJobAsync(medplum, 'Run pending post-deploy migration', 'admin/super/migrate', { dataVersion: version }) .finally(() => { triggerRefresh(); }) .catch(() => {}); triggerRefresh(); } const isLoading = loadingItems.length > 0; function renderTable(callback: () => React.ReactNode, { isLoading }: { isLoading: boolean }): JSX.Element { return ( <div style={{ position: 'relative' }}> <LoadingOverlay visible={isLoading} /> <Table> <Table.Thead> <Table.Tr> <Table.Th>Version</Table.Th> <Table.Th>Action</Table.Th> <Table.Th>Status</Table.Th> <Table.Th>AsyncJob</Table.Th> <Table.Th>Last Updated</Table.Th> <Table.Th>Request Time</Table.Th> <Table.Th>Duration</Table.Th> <Table.Th>Attempts</Table.Th> </Table.Tr> </Table.Thead> <Table.Tbody>{callback()}</Table.Tbody> </Table> </div> ); } return ( <> <Button size="xs" mb="xs" variant="subtle" leftSection={<IconRefresh size={14} />} onClick={triggerRefresh} loading={isLoading} > Refresh </Button> {renderTable( () => { if (!isLoading) { if (!migrationInfo) { return ( <Table.Tr> <Table.Td colSpan={6}>Failed to load migration info</Table.Td> </Table.Tr> ); } if (!pdmAsyncJobs) { return ( <Table.Tr> <Table.Td colSpan={6}>Failed to load async jobs</Table.Td> </Table.Tr> ); } } if (!migrationInfo || !pdmAsyncJobs) { return null; } return migrationInfo.postDeployMigrations?.map((version) => { const { status, asyncJob, asyncJobs } = getPostDeployMigrationStatus( version, pdmAsyncJobs ?? [], migrationInfo?.pendingPostDeployMigration ); const showAll = showAllVersions.includes(version); const showStartButton = version === migrationInfo?.pendingPostDeployMigration && (!asyncJob || asyncJob?.status === 'error'); const toShow = [ <Table.Tr key={version}> <Table.Td>{version}</Table.Td> <Table.Td> {showStartButton && ( <Button color="green" size="xs" onClick={() => runPendingDataMigration(version)}> Start </Button> )} </Table.Td> <Table.Td>{status}</Table.Td> <Table.Td> {asyncJob && <MedplumLink to={`/AsyncJob/${asyncJob?.id}`}>{asyncJob?.id}</MedplumLink>} </Table.Td> <Table.Td>{asyncJob?.meta?.lastUpdated}</Table.Td> <Table.Td>{asyncJob?.requestTime}</Table.Td> <Table.Td>{getDuration(asyncJob)}</Table.Td> <Table.Td display="flex"> <Group gap="xs"> {asyncJobs.length ? asyncJobs.length : ''} {showAll && asyncJobs.length > 1 && ( <Button leftSection={<IconMinus size={14} />} variant="subtle" color="blue" size="xs" radius="xs" onClick={() => setShowAllVersions((prev) => prev.includes(version) ? prev.filter((v) => v !== version) : prev ) } > Hide all </Button> )} {!showAll && asyncJobs.length > 1 && ( <Button leftSection={<IconPlus size={14} />} variant="subtle" color="blue" size="xs" radius="xs" onClick={() => setShowAllVersions((prev) => (prev.includes(version) ? prev : [...prev, version])) } > Show all </Button> )} </Group> </Table.Td> </Table.Tr>, ]; if (showAll) { toShow.push( ...asyncJobs .filter((aj) => aj.id !== asyncJob?.id) .map((aj) => ( <Table.Tr key={aj.id} bg="gray.1"> <Table.Td>{version}</Table.Td> <Table.Td></Table.Td> <Table.Td>{aj.status}</Table.Td> <Table.Td> <MedplumLink to={`/AsyncJob/${aj.id}`}>{aj.id}</MedplumLink> </Table.Td> <Table.Td>{aj.meta?.lastUpdated}</Table.Td> <Table.Td>{aj.requestTime}</Table.Td> <Table.Td>{getDuration(aj)}</Table.Td> <Table.Td></Table.Td> </Table.Tr> )) ); } return toShow; }); }, { isLoading } )} </> ); } function getPostDeployMigrationStatus( version: number, sortedAsyncJobs: WithId<AsyncJob>[], pendingPostDeployMigration: number ): { status: string; asyncJob: WithId<AsyncJob> | undefined; asyncJobs: WithId<AsyncJob>[] } { const asyncJobs = sortedAsyncJobs.filter((aj) => aj.dataVersion === version); const asyncJob = asyncJobs.find((aj) => aj.status === 'completed') ?? asyncJobs[0]; let status: string; if (pendingPostDeployMigration === 0) { status = asyncJob?.status ?? 'completed'; } else if (version > pendingPostDeployMigration) { status = asyncJob?.status ?? 'pending'; } else if (version === pendingPostDeployMigration) { status = asyncJob?.status ?? 'next'; } else { status = asyncJob?.status ?? 'completed'; } return { status, asyncJob, asyncJobs }; } function getResourceUrl<T extends Resource>(resource: T): string { return `/${resource.resourceType}/${resource.id}`; } function getDuration(asyncJob: WithId<AsyncJob> | undefined): string { const start = asyncJob?.requestTime; const end = asyncJob?.transactionTime ?? asyncJob?.meta?.lastUpdated; if (!start || !end) { return ''; } const startTime = new Date(start); const endTime = new Date(end); const diffInMs = endTime.getTime() - startTime.getTime(); if (diffInMs < 1000) { return `${diffInMs.toFixed(0)}ms`; } const diffInS = diffInMs / 1000; if (diffInS < 60) { return `${diffInS.toFixed(2)}s`; } if (diffInS < 3600) { return `${(diffInS / 60).toFixed(2)}m`; } return `${(diffInS / 3600).toFixed(2)}h`; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/medplum/medplum'

If you have feedback or need assistance with the MCP directory API, please join our Discord server