diff --git a/src/components/common/ProcessingChip.jsx b/src/components/common/ProcessingChip.jsx
new file mode 100644
index 00000000..3016f01f
--- /dev/null
+++ b/src/components/common/ProcessingChip.jsx
@@ -0,0 +1,41 @@
+import React from "react";
+import Chip from "@mui/material/Chip";
+import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
+import SyncOutlinedIcon from "@mui/icons-material/SyncOutlined";
+import { useTranslation } from "react-i18next";
+
+const ProcessingChip = ({
+ processed = false,
+ fading = false,
+ fadeDurationMs = 60000,
+ sx,
+ ...rest
+}) => {
+ const { t } = useTranslation();
+
+ return (
+
+ ) : (
+
+ )
+ }
+ label={processed ? t("common.processed") : t("common.processing")}
+ sx={[
+ {
+ opacity: fading ? 0 : 1,
+ transition: fading ? `opacity ${fadeDurationMs}ms linear` : undefined
+ },
+ ...(Array.isArray(sx) ? sx : sx ? [sx] : [])
+ ]}
+ {...rest}
+ />
+ );
+};
+
+export default ProcessingChip;
diff --git a/src/components/repos/CollectionVersionsTab.jsx b/src/components/repos/CollectionVersionsTab.jsx
index 9656f0c3..43f16c65 100644
--- a/src/components/repos/CollectionVersionsTab.jsx
+++ b/src/components/repos/CollectionVersionsTab.jsx
@@ -48,10 +48,14 @@ import {
} from "../../common/utils";
import { OperationsContext } from "../app/LayoutContext";
import DeleteEntityDialog from "../common/DeleteEntityDialog";
+import ProcessingChip from "../common/ProcessingChip";
import ExpansionForm from "./ExpansionForm";
import ExpansionDetailsDialog from "./ExpansionDetailsDialog";
import RebuildExpansionDialog from "./RebuildExpansionDialog";
+const PROCESSING_POLL_INTERVAL_MS = 10000;
+const PROCESSED_CHIP_FADE_MS = 6000;
+
const isHeadVersion = version => (version?.version || version?.id) === "HEAD";
const isStaleExpansion = expansion =>
@@ -62,6 +66,8 @@ const isStaleExpansion = expansion =>
expansion?.extras?.stale
);
+const isExpansionProcessing = expansion => Boolean(expansion?.is_processing);
+
const getVersionKey = version =>
version?.version_url || version?.url || version?.id;
@@ -214,6 +220,12 @@ const labelFromVersionUrl = url => {
return url;
};
+const parseProcessingValue = value => {
+ if (typeof value === "boolean") return value;
+ if (typeof value === "string") return value.toLowerCase() === "true";
+ return Boolean(value);
+};
+
const CollectionVersionsTab = ({
repo,
versions,
@@ -252,8 +264,13 @@ const CollectionVersionsTab = ({
});
const [repoUpdatesByExpansion, setRepoUpdatesByExpansion] = React.useState({});
const [dismissedUpdates, setDismissedUpdates] = React.useState(new Set());
+ const [processingStatusByExpansion, setProcessingStatusByExpansion] =
+ React.useState({});
const expansionRefs = React.useRef({});
const fetchedRepoUpdatesRef = React.useRef(new Set());
+ const processingStatusRef = React.useRef({});
+ const processingPollsRef = React.useRef({});
+ const processedCleanupRef = React.useRef({});
const hasAccess = currentUserHasAccess();
const baseRepoURL = dropVersion(repo?.version_url || repo?.url || "");
const searchParams = React.useMemo(
@@ -268,6 +285,76 @@ const CollectionVersionsTab = ({
searchParams.get("expansion_url") ||
searchParams.get("expansion") ||
searchParams.get("expansion_id");
+
+ React.useEffect(() => {
+ processingStatusRef.current = processingStatusByExpansion;
+ }, [processingStatusByExpansion]);
+
+ const clearProcessingPoll = React.useCallback(url => {
+ if (!processingPollsRef.current[url]) return;
+ window.clearInterval(processingPollsRef.current[url]);
+ delete processingPollsRef.current[url];
+ }, []);
+
+ const clearProcessedCleanup = React.useCallback(url => {
+ if (!processedCleanupRef.current[url]) return;
+ window.clearTimeout(processedCleanupRef.current[url]);
+ delete processedCleanupRef.current[url];
+ }, []);
+
+ const markExpansionProcessing = React.useCallback(
+ url => {
+ if (!url) return;
+
+ clearProcessedCleanup(url);
+ setProcessingStatusByExpansion(prev => {
+ if (prev[url]?.state === "processing") return prev;
+ return {
+ ...prev,
+ [url]: { state: "processing" }
+ };
+ });
+ },
+ [clearProcessedCleanup]
+ );
+
+ const markExpansionProcessed = React.useCallback(
+ (url, onlyIfTracked = false) => {
+ if (!url) return;
+
+ const currentState = processingStatusRef.current[url]?.state;
+ if (onlyIfTracked && currentState !== "processing") {
+ clearProcessingPoll(url);
+ return;
+ }
+
+ clearProcessingPoll(url);
+ clearProcessedCleanup(url);
+ setProcessingStatusByExpansion(prev => ({
+ ...prev,
+ [url]: { state: "processed", processedAt: Date.now() }
+ }));
+ processedCleanupRef.current[url] = window.setTimeout(() => {
+ setProcessingStatusByExpansion(prev => {
+ if (!prev[url]) return prev;
+ const next = { ...prev };
+ delete next[url];
+ return next;
+ });
+ delete processedCleanupRef.current[url];
+ }, PROCESSED_CHIP_FADE_MS);
+ },
+ [clearProcessedCleanup, clearProcessingPoll]
+ );
+
+ React.useEffect(
+ () => () => {
+ Object.keys(processingPollsRef.current).forEach(clearProcessingPoll);
+ Object.keys(processedCleanupRef.current).forEach(clearProcessedCleanup);
+ },
+ [clearProcessedCleanup, clearProcessingPoll]
+ );
+
React.useEffect(() => {
if (!baseRepoURL || isHeadVersion(repo)) {
setHeadVersion(repo);
@@ -474,9 +561,80 @@ const CollectionVersionsTab = ({
return versionExpansions.filter(isStaleExpansion).length;
};
- const refreshSelectedExpansions = version => {
- if (version) fetchExpansions(version, true);
- };
+ const refreshSelectedExpansions = React.useCallback(
+ version => {
+ if (version) fetchExpansions(version, true);
+ },
+ [fetchExpansions]
+ );
+
+ const pollExpansionProcessing = React.useCallback(
+ (version, expansion) => {
+ if (!expansion?.url) return;
+
+ APIService.new()
+ .overrideURL(expansion.url + "processing/")
+ .get(null, null, null, true)
+ .then(response => {
+ if (response?.status !== 200) return;
+
+ const isProcessing = parseProcessingValue(response?.data);
+ if (isProcessing) {
+ markExpansionProcessing(expansion.url);
+ return;
+ }
+
+ markExpansionProcessed(expansion.url, true);
+ refreshSelectedExpansions(version);
+ });
+ },
+ [markExpansionProcessed, markExpansionProcessing, refreshSelectedExpansions]
+ );
+
+ const ensureProcessingPoll = React.useCallback(
+ (version, expansion) => {
+ if (!expansion?.url || processingPollsRef.current[expansion.url]) return;
+
+ processingPollsRef.current[expansion.url] = window.setInterval(() => {
+ pollExpansionProcessing(version, expansion);
+ }, PROCESSING_POLL_INTERVAL_MS);
+ },
+ [pollExpansionProcessing]
+ );
+
+ React.useEffect(() => {
+ const activeProcessingUrls = new Set();
+
+ Object.entries(expansionsByVersion).forEach(([versionKey, versionExpansions]) => {
+ const version = find(displayVersions, item => getVersionKey(item) === versionKey);
+ if (!version) return;
+
+ versionExpansions.forEach(expansion => {
+ if (!expansion?.url) return;
+
+ if (isExpansionProcessing(expansion)) {
+ activeProcessingUrls.add(expansion.url);
+ markExpansionProcessing(expansion.url);
+ ensureProcessingPoll(version, expansion);
+ return;
+ }
+
+ clearProcessingPoll(expansion.url);
+ markExpansionProcessed(expansion.url, true);
+ });
+ });
+
+ Object.keys(processingPollsRef.current).forEach(url => {
+ if (!activeProcessingUrls.has(url)) clearProcessingPoll(url);
+ });
+ }, [
+ clearProcessingPoll,
+ displayVersions,
+ ensureProcessingPoll,
+ expansionsByVersion,
+ markExpansionProcessed,
+ markExpansionProcessing
+ ]);
const onMarkExpansionDefault = (version, expansion) => {
APIService.new()
@@ -799,6 +957,13 @@ const CollectionVersionsTab = ({
versionExpansions.map(expansion => {
const highlighted =
highlightedExpansion?.url === expansion.url;
+ const processingStatus =
+ processingStatusByExpansion[expansion.url];
+ const processingState =
+ processingStatus?.state ||
+ (isExpansionProcessing(expansion)
+ ? "processing"
+ : null);
const explicitRepoVersions = getExplicitRepoVersions(
expansion
);
@@ -863,6 +1028,13 @@ const CollectionVersionsTab = ({
icon={}
/>
)}
+ {processingState && (
+
+ )}
{expansion.canonical_url && (