feat: dataset cache for hold/resource history + slow connection migration

Two changes combined:

1. historical-query-slow-connection: Migrate all historical query pages
   to read_sql_df_slow with semaphore concurrency control (max 3),
   raise DB slow timeout to 300s, gunicorn timeout to 360s, and
   unify frontend timeouts to 360s for all historical pages.

2. hold-resource-history-dataset-cache: Convert hold-history and
   resource-history from multi-query to single-query + dataset cache
   pattern (L1 ProcessLevelCache + L2 Redis parquet/base64, TTL=900s).
   Replace old GET endpoints with POST /query + GET /view two-phase
   API. Frontend auto-retries on 410 cache_expired.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
egg
2026-02-25 13:15:02 +08:00
parent cd061e0cfd
commit 71c8102de6
64 changed files with 3806 additions and 1442 deletions

View File

@@ -132,7 +132,7 @@ export function useExcelQueryData() {
try {
const formData = new FormData();
formData.append('file', file);
const payload = await apiUpload('/api/excel-query/upload', formData, { timeout: 120000, silent: true });
const payload = await apiUpload('/api/excel-query/upload', formData, { timeout: 360000, silent: true });
excelColumns.value = Array.isArray(payload?.columns) ? payload.columns : [];
excelPreview.value = Array.isArray(payload?.preview) ? payload.preview : [];
uploadState.fileName = String(file.name || '');
@@ -252,7 +252,7 @@ export function useExcelQueryData() {
date_from: filters.dateFrom || undefined,
date_to: filters.dateTo || undefined,
},
{ timeout: 120000, silent: true },
{ timeout: 360000, silent: true },
);
queryResult.rows = Array.isArray(payload?.data) ? payload.data : [];
queryResult.columns = Array.isArray(payload?.columns) ? payload.columns : filters.returnColumns;

View File

@@ -1,7 +1,7 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue';
import { apiGet } from '../core/api.js';
import { apiGet, apiPost } from '../core/api.js';
import { replaceRuntimeHistory } from '../core/shell-navigation.js';
import DailyTrend from './components/DailyTrend.vue';
@@ -13,7 +13,7 @@ import RecordTypeFilter from './components/RecordTypeFilter.vue';
import ReasonPareto from './components/ReasonPareto.vue';
import SummaryCards from './components/SummaryCards.vue';
const API_TIMEOUT = 60000;
const API_TIMEOUT = 360000;
const DEFAULT_PER_PAGE = 50;
const filterBar = reactive({
@@ -22,6 +22,7 @@ const filterBar = reactive({
holdType: 'quality',
});
const queryId = ref('');
const reasonFilter = ref('');
const durationFilter = ref('');
const recordType = ref(['new']);
@@ -158,74 +159,23 @@ function updateUrlState() {
replaceRuntimeHistory(`/hold-history?${params.toString()}`);
}
function commonParams({
includeHoldType = true,
includeReason = false,
includeRecordType = false,
includeDuration = false,
} = {}) {
const params = {
start_date: filterBar.startDate,
end_date: filterBar.endDate,
};
function recordTypeCsv() {
const rt = Array.isArray(recordType.value) ? recordType.value : [recordType.value];
return rt.join(',');
}
if (includeHoldType) {
params.hold_type = filterBar.holdType;
function applyViewResult(result, { listOnly = false } = {}) {
if (!listOnly) {
trendData.value = result.trend || trendData.value;
reasonParetoData.value = result.reason_pareto || reasonParetoData.value;
durationData.value = result.duration || durationData.value;
}
if (includeRecordType) {
const rt = Array.isArray(recordType.value) ? recordType.value : [recordType.value];
params.record_type = rt.join(',');
}
if (includeReason && reasonFilter.value) {
params.reason = reasonFilter.value;
}
if (includeDuration && durationFilter.value) {
params.duration_range = durationFilter.value;
}
return params;
detailData.value = normalizeListPayload(result.list);
}
async function fetchTrend() {
const response = await apiGet('/api/hold-history/trend', {
params: commonParams({ includeHoldType: false }),
timeout: API_TIMEOUT,
});
return unwrapApiResult(response, '載入 trend 資料失敗');
}
// ---- Primary query (POST /query -> Oracle -> cache) ----
async function fetchReasonPareto() {
const response = await apiGet('/api/hold-history/reason-pareto', {
params: commonParams({ includeHoldType: true, includeRecordType: true }),
timeout: API_TIMEOUT,
});
return unwrapApiResult(response, '載入 pareto 資料失敗');
}
async function fetchDuration() {
const response = await apiGet('/api/hold-history/duration', {
params: commonParams({ includeHoldType: true, includeRecordType: true }),
timeout: API_TIMEOUT,
});
return unwrapApiResult(response, '載入 duration 資料失敗');
}
async function fetchList() {
const response = await apiGet('/api/hold-history/list', {
params: {
...commonParams({ includeHoldType: true, includeReason: true, includeRecordType: true, includeDuration: true }),
page: page.value,
per_page: DEFAULT_PER_PAGE,
},
timeout: API_TIMEOUT,
});
return unwrapApiResult(response, '載入明細資料失敗');
}
async function loadAllData({ includeTrend = true, showOverlay = false } = {}) {
async function executePrimaryQuery({ showOverlay = false } = {}) {
const requestId = nextRequestId();
if (showOverlay) {
@@ -237,90 +187,90 @@ async function loadAllData({ includeTrend = true, showOverlay = false } = {}) {
errorMessage.value = '';
try {
const requests = [];
if (includeTrend) {
requests.push(fetchTrend());
}
requests.push(fetchReasonPareto(), fetchDuration(), fetchList());
const body = {
start_date: filterBar.startDate,
end_date: filterBar.endDate,
hold_type: filterBar.holdType,
record_type: recordTypeCsv(),
};
const responses = await Promise.all(requests);
if (isStaleRequest(requestId)) {
return;
}
const resp = await apiPost('/api/hold-history/query', body, { timeout: API_TIMEOUT });
if (isStaleRequest(requestId)) return;
let cursor = 0;
if (includeTrend) {
trendData.value = responses[cursor] || { days: [] };
cursor += 1;
}
const result = unwrapApiResult(resp, '主查詢執行失敗');
reasonParetoData.value = responses[cursor] || { items: [] };
cursor += 1;
durationData.value = responses[cursor] || { items: [] };
cursor += 1;
detailData.value = normalizeListPayload(responses[cursor]);
queryId.value = result.query_id;
applyViewResult(result);
updateUrlState();
} catch (error) {
if (isStaleRequest(requestId)) {
return;
if (isStaleRequest(requestId)) return;
if (error?.name === 'AbortError') {
errorMessage.value = '查詢逾時,請縮短日期範圍後重試';
} else {
errorMessage.value = error?.message || '主查詢執行失敗';
}
errorMessage.value = error?.message || '載入資料失敗';
} finally {
if (isStaleRequest(requestId)) {
return;
}
if (isStaleRequest(requestId)) return;
loading.global = false;
loading.list = false;
initialLoading.value = false;
}
}
async function loadReasonDependents() {
// ---- View refresh (GET /view -> read cache -> filter) ----
async function refreshView({ listOnly = false } = {}) {
if (!queryId.value) return;
const requestId = nextRequestId();
if (!listOnly) {
loading.global = true;
}
loading.list = true;
errorMessage.value = '';
try {
const list = await fetchList();
if (isStaleRequest(requestId)) {
const params = {
query_id: queryId.value,
hold_type: filterBar.holdType,
record_type: recordTypeCsv(),
page: page.value,
per_page: DEFAULT_PER_PAGE,
};
if (reasonFilter.value) {
params.reason = reasonFilter.value;
}
if (durationFilter.value) {
params.duration_range = durationFilter.value;
}
const resp = await apiGet('/api/hold-history/view', {
params,
timeout: API_TIMEOUT,
});
if (isStaleRequest(requestId)) return;
// Cache expired -> auto re-execute primary query
if (resp?.success === false && resp?.error === 'cache_expired') {
await executePrimaryQuery();
return;
}
detailData.value = normalizeListPayload(list);
const result = unwrapApiResult(resp, '視圖查詢失敗');
applyViewResult(result, { listOnly });
} catch (error) {
if (isStaleRequest(requestId)) {
return;
}
errorMessage.value = error?.message || '載入明細資料失敗';
if (isStaleRequest(requestId)) return;
errorMessage.value = error?.message || '載入資料失敗';
} finally {
if (isStaleRequest(requestId)) {
return;
}
if (isStaleRequest(requestId)) return;
loading.global = false;
loading.list = false;
}
}
async function loadListOnly() {
const requestId = nextRequestId();
loading.list = true;
errorMessage.value = '';
try {
const list = await fetchList();
if (isStaleRequest(requestId)) {
return;
}
detailData.value = normalizeListPayload(list);
} catch (error) {
if (isStaleRequest(requestId)) {
return;
}
errorMessage.value = error?.message || '載入明細資料失敗';
} finally {
if (isStaleRequest(requestId)) {
return;
}
loading.list = false;
}
}
// ---- Computed ----
function estimateAvgHoldHours(items) {
const bucketHours = {
@@ -400,6 +350,8 @@ const holdTypeLabel = computed(() => {
return '品質異常';
});
// ---- Event handlers ----
function handleFilterChange(next) {
const nextStartDate = next?.startDate || '';
const nextEndDate = next?.endDate || '';
@@ -421,7 +373,13 @@ function handleFilterChange(next) {
page.value = 1;
updateUrlState();
void loadAllData({ includeTrend: dateChanged, showOverlay: false });
if (dateChanged) {
// Date changed -> new primary query (Oracle)
void executePrimaryQuery();
} else {
// Only hold_type changed -> view refresh (cache only)
void refreshView();
}
}
function handleRecordTypeChange() {
@@ -429,7 +387,7 @@ function handleRecordTypeChange() {
durationFilter.value = '';
page.value = 1;
updateUrlState();
void loadAllData({ includeTrend: false, showOverlay: false });
void refreshView();
}
function handleReasonToggle(reason) {
@@ -441,7 +399,7 @@ function handleReasonToggle(reason) {
reasonFilter.value = reasonFilter.value === nextReason ? '' : nextReason;
page.value = 1;
updateUrlState();
void loadReasonDependents();
void refreshView();
}
function clearReasonFilter() {
@@ -451,7 +409,7 @@ function clearReasonFilter() {
reasonFilter.value = '';
page.value = 1;
updateUrlState();
void loadReasonDependents();
void refreshView();
}
function handleDurationToggle(range) {
@@ -463,7 +421,7 @@ function handleDurationToggle(range) {
durationFilter.value = durationFilter.value === nextRange ? '' : nextRange;
page.value = 1;
updateUrlState();
void loadReasonDependents();
void refreshView();
}
function clearDurationFilter() {
@@ -473,7 +431,7 @@ function clearDurationFilter() {
durationFilter.value = '';
page.value = 1;
updateUrlState();
void loadReasonDependents();
void refreshView();
}
function prevPage() {
@@ -482,7 +440,7 @@ function prevPage() {
}
page.value -= 1;
updateUrlState();
void loadListOnly();
void refreshView({ listOnly: true });
}
function nextPage() {
@@ -492,13 +450,15 @@ function nextPage() {
}
page.value += 1;
updateUrlState();
void loadListOnly();
void refreshView({ listOnly: true });
}
async function manualRefresh() {
page.value = 1;
reasonFilter.value = '';
durationFilter.value = '';
updateUrlState();
await loadAllData({ includeTrend: true, showOverlay: false });
await executePrimaryQuery();
}
onMounted(() => {
@@ -519,7 +479,7 @@ onMounted(() => {
page.value = parsedPage;
}
updateUrlState();
void loadAllData({ includeTrend: true, showOverlay: true });
void executePrimaryQuery({ showOverlay: true });
});
</script>

View File

@@ -174,7 +174,7 @@ export function useJobQueryData() {
loadingResources.value = true;
errorMessage.value = '';
try {
const payload = await apiGet('/api/job-query/resources', { timeout: 60000, silent: true });
const payload = await apiGet('/api/job-query/resources', { timeout: 360000, silent: true });
resources.value = Array.isArray(payload?.data) ? payload.data : [];
} catch (error) {
errorMessage.value = error?.message || '載入設備清單失敗';
@@ -206,7 +206,7 @@ export function useJobQueryData() {
start_date: filters.startDate,
end_date: filters.endDate,
},
{ timeout: 60000, silent: true },
{ timeout: 360000, silent: true },
);
jobs.value = Array.isArray(payload?.data) ? payload.data : [];
return true;
@@ -229,7 +229,7 @@ export function useJobQueryData() {
errorMessage.value = '';
try {
const payload = await apiGet(`/api/job-query/txn/${encodeURIComponent(id)}`, {
timeout: 60000,
timeout: 360000,
silent: true,
});
txnRows.value = Array.isArray(payload?.data) ? payload.data : [];

View File

@@ -16,7 +16,7 @@ import SuspectContextPanel from './components/SuspectContextPanel.vue';
ensureMesApiAvailable();
const API_TIMEOUT = 120000;
const API_TIMEOUT = 360000;
const PAGE_SIZE = 200;
const SESSION_CACHE_KEY = 'msd:cache';
const SESSION_CACHE_TTL = 5 * 60 * 1000; // 5 min, matches backend Redis TTL

View File

@@ -135,7 +135,7 @@ async function loadTxn(jobId) {
try {
const payload = await apiGet(`/api/job-query/txn/${encodeURIComponent(id)}`, {
timeout: 60000,
timeout: 360000,
silent: true,
});
txnRows.value = Array.isArray(payload?.data) ? payload.data : [];

View File

@@ -120,7 +120,7 @@ export function useEquipmentQuery(initial = {}) {
const payload = await apiPost(
'/api/query-tool/equipment-period',
buildQueryPayload(queryType),
{ timeout: 120000, silent: true },
{ timeout: 360000, silent: true },
);
return Array.isArray(payload?.data) ? payload.data : [];
@@ -132,7 +132,7 @@ export function useEquipmentQuery(initial = {}) {
try {
const payload = await apiGet('/api/query-tool/equipment-list', {
timeout: 60000,
timeout: 360000,
silent: true,
});
equipmentOptions.value = Array.isArray(payload?.data) ? payload.data : [];

View File

@@ -164,7 +164,7 @@ export function useLotDetail(initial = {}) {
try {
const payload = await apiGet('/api/query-tool/workcenter-groups', {
timeout: 60000,
timeout: 360000,
silent: true,
});
@@ -205,7 +205,7 @@ export function useLotDetail(initial = {}) {
}
const payload = await apiGet(`/api/query-tool/lot-history?${params.toString()}`, {
timeout: 120000,
timeout: 360000,
silent: true,
});
@@ -263,7 +263,7 @@ export function useLotDetail(initial = {}) {
params.set('time_end', timeRange.time_end);
const payload = await apiGet(`/api/query-tool/lot-associations?${params.toString()}`, {
timeout: 120000,
timeout: 360000,
silent: true,
});
@@ -279,7 +279,7 @@ export function useLotDetail(initial = {}) {
params.set('type', associationType);
const payload = await apiGet(`/api/query-tool/lot-associations?${params.toString()}`, {
timeout: 120000,
timeout: 360000,
silent: true,
});

View File

@@ -205,7 +205,7 @@ export function useLotLineage(initial = {}) {
profile: 'query_tool',
container_ids: containerIds,
},
{ timeout: 60000, silent: true },
{ timeout: 360000, silent: true },
);
} catch (error) {
const status = Number(error?.status || 0);

View File

@@ -134,7 +134,7 @@ export function useLotResolve(initial = {}) {
input_type: inputType.value,
values,
},
{ timeout: 60000, silent: true },
{ timeout: 360000, silent: true },
);
resolvedLots.value = Array.isArray(payload?.data) ? payload.data : [];

View File

@@ -216,7 +216,7 @@ export function useReverseLineage(initial = {}) {
profile: 'query_tool_reverse',
container_ids: containerIds,
},
{ timeout: 60000, silent: true },
{ timeout: 360000, silent: true },
);
} catch (error) {
const status = Number(error?.status || 0);

View File

@@ -15,7 +15,7 @@ import ParetoSection from './components/ParetoSection.vue';
import SummaryCards from './components/SummaryCards.vue';
import TrendChart from './components/TrendChart.vue';
const API_TIMEOUT = 60000;
const API_TIMEOUT = 360000;
const DEFAULT_PER_PAGE = 50;
// ---- Primary query form state ----
@@ -214,7 +214,11 @@ async function executePrimaryQuery() {
updateUrlState();
} catch (error) {
if (isStaleRequest(requestId)) return;
errorMessage.value = error?.message || '主查詢執行失敗';
if (error?.name === 'AbortError') {
errorMessage.value = '查詢逾時,請縮短日期範圍後重試';
} else {
errorMessage.value = error?.message || '主查詢執行失敗';
}
} finally {
if (isStaleRequest(requestId)) return;
loading.querying = false;
@@ -264,7 +268,11 @@ async function refreshView() {
updateUrlState();
} catch (error) {
if (isStaleRequest(requestId)) return;
errorMessage.value = error?.message || '視圖查詢失敗';
if (error?.name === 'AbortError') {
errorMessage.value = '查詢逾時,請縮短日期範圍後重試';
} else {
errorMessage.value = error?.message || '視圖查詢失敗';
}
} finally {
if (isStaleRequest(requestId)) return;
loading.list = false;
@@ -360,7 +368,9 @@ async function fetchDimensionPareto(dim) {
} catch (err) {
if (myId !== activeDimRequestId) return;
dimensionParetoItems.value = [];
errorMessage.value = err.message || '查詢維度 Pareto 失敗';
if (err?.name !== 'AbortError') {
errorMessage.value = err.message || '查詢維度 Pareto 失敗';
}
} finally {
if (myId === activeDimRequestId) {
dimensionParetoLoading.value = false;

View File

@@ -1,7 +1,7 @@
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { apiGet, ensureMesApiAvailable } from '../core/api.js';
import { apiGet, apiPost, ensureMesApiAvailable } from '../core/api.js';
import { buildResourceKpiFromHours } from '../core/compute.js';
import {
buildResourceHistoryQueryParams,
@@ -22,7 +22,7 @@ import TrendChart from './components/TrendChart.vue';
ensureMesApiAvailable();
const API_TIMEOUT = 60000;
const API_TIMEOUT = 360000;
const MAX_QUERY_DAYS = 730;
function createDefaultFilters() {
@@ -66,6 +66,9 @@ const detailWarning = ref('');
const exportMessage = ref('');
const autoPruneHint = ref('');
const queryId = ref('');
const lastPrimarySnapshot = ref('');
const draftWatchReady = ref(false);
let suppressDraftPrune = false;
@@ -330,7 +333,47 @@ watch(pruneSignature, () => {
pruneDraftSelections({ showHint: true });
});
async function executeCommittedQuery() {
// ─── Two-phase helpers ──────────────────────────────────────
/**
* Build a snapshot string of "primary" params (those that require a new Oracle query).
* Granularity is NOT primary — it can be re-derived from cache.
*/
function buildPrimarySnapshot(filters) {
const p = buildResourceHistoryQueryParams(filters);
return JSON.stringify({
start_date: p.start_date,
end_date: p.end_date,
workcenter_groups: p.workcenter_groups || [],
families: p.families || [],
resource_ids: p.resource_ids || [],
is_production: p.is_production || '',
is_key: p.is_key || '',
is_monitor: p.is_monitor || '',
});
}
function applyViewResult(result) {
const summary = result.summary || {};
summaryData.value = {
kpi: mergeComputedKpi(summary.kpi || {}),
trend: (summary.trend || []).map((item) => mergeComputedKpi(item || {})),
heatmap: summary.heatmap || [],
workcenter_comparison: summary.workcenter_comparison || [],
};
const detail = result.detail || {};
detailData.value = Array.isArray(detail.data) ? detail.data : [];
resetHierarchyState();
if (detail.truncated) {
detailWarning.value = `明細資料超過 ${detail.max_records} 筆,僅顯示前 ${detail.max_records} 筆。`;
} else {
detailWarning.value = '';
}
}
async function executePrimaryQuery() {
const validationError = validateDateRange(committedFilters);
if (validationError) {
queryError.value = validationError;
@@ -344,46 +387,37 @@ async function executeCommittedQuery() {
exportMessage.value = '';
try {
const queryString = buildQueryStringFromFilters(committedFilters);
updateUrlState();
const [summaryResponse, detailResponse] = await Promise.all([
apiGet(`/api/resource/history/summary?${queryString}`, {
timeout: API_TIMEOUT,
silent: true,
}),
apiGet(`/api/resource/history/detail?${queryString}`, {
timeout: API_TIMEOUT,
silent: true,
}),
]);
const summaryPayload = unwrapApiResult(summaryResponse, '查詢摘要失敗');
const detailPayload = unwrapApiResult(detailResponse, '查詢明細失敗');
const rawSummary = summaryPayload.data || {};
summaryData.value = {
...rawSummary,
kpi: mergeComputedKpi(rawSummary.kpi || {}),
trend: (rawSummary.trend || []).map((item) => mergeComputedKpi(item || {})),
heatmap: rawSummary.heatmap || [],
workcenter_comparison: rawSummary.workcenter_comparison || [],
const queryParams = buildResourceHistoryQueryParams(committedFilters);
const body = {
start_date: queryParams.start_date,
end_date: queryParams.end_date,
granularity: queryParams.granularity,
workcenter_groups: queryParams.workcenter_groups || [],
families: queryParams.families || [],
resource_ids: queryParams.resource_ids || [],
is_production: !!queryParams.is_production,
is_key: !!queryParams.is_key,
is_monitor: !!queryParams.is_monitor,
};
detailData.value = Array.isArray(detailPayload.data) ? detailPayload.data : [];
resetHierarchyState();
const response = await apiPost('/api/resource/history/query', body, {
timeout: API_TIMEOUT,
silent: true,
});
if (detailPayload.truncated) {
detailWarning.value = `明細資料超過 ${detailPayload.max_records} 筆,僅顯示前 ${detailPayload.max_records} 筆。`;
}
const payload = unwrapApiResult(response, '查詢失敗');
queryId.value = payload.query_id || '';
lastPrimarySnapshot.value = buildPrimarySnapshot(committedFilters);
applyViewResult(payload);
} catch (error) {
queryError.value = error?.message || '查詢失敗';
summaryData.value = {
kpi: {},
trend: [],
heatmap: [],
workcenter_comparison: [],
};
if (error?.name === 'AbortError') {
queryError.value = '查詢逾時,請縮小日期範圍或資源篩選後重試';
} else {
queryError.value = error?.message || '查詢失敗';
}
summaryData.value = { kpi: {}, trend: [], heatmap: [], workcenter_comparison: [] };
detailData.value = [];
resetHierarchyState();
} finally {
@@ -392,6 +426,47 @@ async function executeCommittedQuery() {
}
}
async function refreshView() {
if (!queryId.value) {
await executePrimaryQuery();
return;
}
loading.querying = true;
queryError.value = '';
try {
updateUrlState();
const response = await apiGet('/api/resource/history/view', {
timeout: API_TIMEOUT,
silent: true,
params: {
query_id: queryId.value,
granularity: committedFilters.granularity || 'day',
},
});
if (response?.success === false && response?.error === 'cache_expired') {
await executePrimaryQuery();
return;
}
const payload = unwrapApiResult(response, '查詢失敗');
applyViewResult(payload);
} catch (error) {
if (error?.message === 'cache_expired' || error?.status === 410) {
await executePrimaryQuery();
return;
}
queryError.value = error?.message || '查詢失敗';
} finally {
loading.querying = false;
}
}
// ─── Filter actions ─────────────────────────────────────────
async function applyFilters() {
const validationError = validateDateRange(draftFilters);
if (validationError) {
@@ -400,7 +475,13 @@ async function applyFilters() {
}
pruneDraftSelections({ showHint: true });
assignFilterState(committedFilters, draftFilters);
await executeCommittedQuery();
const currentPrimary = buildPrimarySnapshot(committedFilters);
if (queryId.value && currentPrimary === lastPrimarySnapshot.value) {
await refreshView();
} else {
await executePrimaryQuery();
}
}
async function clearFilters() {
@@ -409,7 +490,7 @@ async function clearFilters() {
});
autoPruneHint.value = '';
assignFilterState(committedFilters, draftFilters);
await executeCommittedQuery();
await executePrimaryQuery();
}
function updateFilters(nextFilters) {
@@ -462,7 +543,7 @@ async function initPage() {
}
draftWatchReady.value = true;
await executeCommittedQuery();
await executePrimaryQuery();
}
onMounted(() => {

View File

@@ -4,7 +4,7 @@ import { apiPost, ensureMesApiAvailable } from '../core/api.js';
ensureMesApiAvailable();
const DEFAULT_STAGE_TIMEOUT_MS = 60000;
const DEFAULT_STAGE_TIMEOUT_MS = 360000;
const PROFILE_DOMAINS = Object.freeze({
query_tool: ['history', 'materials', 'rejects', 'holds', 'jobs'],
mid_section_defect: ['upstream_history', 'materials'],