import { reactive, ref } from 'vue'; import { apiGet, apiPost, ensureMesApiAvailable } from '../core/api.js'; ensureMesApiAvailable(); const DEFAULT_STAGE_TIMEOUT_MS = 360000; const JOB_POLL_INTERVAL_MS = 3000; const JOB_POLL_MAX_MS = 1800000; // 30 minutes const PROFILE_DOMAINS = Object.freeze({ query_tool: ['history', 'materials', 'rejects', 'holds', 'jobs'], mid_section_defect: ['upstream_history', 'materials'], mid_section_defect_forward: ['upstream_history', 'downstream_rejects'], }); function stageKey(stageName) { if (stageName === 'seed-resolve') return 'seed'; if (stageName === 'lineage') return 'lineage'; return 'events'; } function normalizeSeedContainerIds(seedPayload) { const rows = Array.isArray(seedPayload?.seeds) ? seedPayload.seeds : []; const seen = new Set(); const containerIds = []; rows.forEach((row) => { const id = String(row?.container_id || '').trim(); if (!id || seen.has(id)) { return; } seen.add(id); containerIds.push(id); }); return containerIds; } function collectAllContainerIds(seedContainerIds, lineagePayload, direction) { const seen = new Set(seedContainerIds); const merged = [...seedContainerIds]; if (direction === 'forward') { const childrenMap = lineagePayload?.children_map || {}; const queue = [...seedContainerIds]; while (queue.length > 0) { const current = queue.shift(); const children = childrenMap[current]; if (!Array.isArray(children)) continue; for (const child of children) { const id = String(child || '').trim(); if (!id || seen.has(id)) continue; seen.add(id); merged.push(id); queue.push(id); } } } else { const ancestors = lineagePayload?.ancestors || {}; Object.values(ancestors).forEach((values) => { if (!Array.isArray(values)) { return; } values.forEach((value) => { const id = String(value || '').trim(); if (!id || seen.has(id)) { return; } seen.add(id); merged.push(id); }); }); } return merged; } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Poll an async trace job until it completes or fails. * * @param {string} statusUrl - The job status endpoint URL * @param {object} options - { signal, onProgress } * @returns {Promise} The final status ('finished' or throws) */ async function pollJobUntilComplete(statusUrl, { signal, onProgress } = {}) { const started = Date.now(); while (true) { if (signal?.aborted) { throw new DOMException('Aborted', 'AbortError'); } const status = await apiGet(statusUrl, { timeout: 15000, signal }); if (typeof onProgress === 'function') { onProgress(status); } if (status.status === 'finished') { return 'finished'; } if (status.status === 'failed') { const error = new Error(status.error || '非同步查詢失敗'); error.errorCode = 'JOB_FAILED'; throw error; } if (Date.now() - started > JOB_POLL_MAX_MS) { const error = new Error('非同步查詢超時'); error.errorCode = 'JOB_POLL_TIMEOUT'; throw error; } await sleep(JOB_POLL_INTERVAL_MS); } } /** * Consume an NDJSON stream from the server, calling onChunk for each line. * * @param {string} url - The stream endpoint URL * @param {object} options - { signal, onChunk } * @returns {Promise} */ async function consumeNDJSONStream(url, { signal, onChunk } = {}) { const response = await fetch(url, { signal }); if (!response.ok) { const text = await response.text().catch(() => ''); const error = new Error(`Stream request failed: HTTP ${response.status} ${text}`); error.errorCode = 'STREAM_FAILED'; throw error; } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; try { while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop(); // keep incomplete last line for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; try { const chunk = JSON.parse(trimmed); if (typeof onChunk === 'function') onChunk(chunk); } catch { // skip malformed NDJSON lines } } } // process remaining buffer if (buffer.trim()) { try { const chunk = JSON.parse(buffer.trim()); if (typeof onChunk === 'function') onChunk(chunk); } catch { // skip malformed final line } } } finally { reader.releaseLock(); } } export function useTraceProgress({ profile } = {}) { const current_stage = ref(null); const completed_stages = ref([]); const is_running = ref(false); const stage_results = reactive({ seed: null, lineage: null, events: null, }); const stage_errors = reactive({ seed: null, lineage: null, events: null, }); // Async job progress (populated when events stage uses async path) const job_progress = reactive({ active: false, job_id: null, status: null, elapsed_seconds: 0, progress: '', }); let activeController = null; function reset() { completed_stages.value = []; current_stage.value = null; stage_results.seed = null; stage_results.lineage = null; stage_results.events = null; stage_errors.seed = null; stage_errors.lineage = null; stage_errors.events = null; job_progress.active = false; job_progress.job_id = null; job_progress.status = null; job_progress.elapsed_seconds = 0; job_progress.progress = ''; } function abort() { if (activeController) { activeController.abort(); activeController = null; } } async function execute(params = {}) { const direction = params.direction || 'backward'; const domainKey = profile === 'mid_section_defect' && direction === 'forward' ? 'mid_section_defect_forward' : profile; const domains = PROFILE_DOMAINS[domainKey]; if (!domains) { throw new Error(`Unsupported trace profile: ${profile}`); } abort(); reset(); is_running.value = true; const controller = new AbortController(); activeController = controller; try { current_stage.value = 'seed-resolve'; const seedPayload = await apiPost( '/api/trace/seed-resolve', { profile, params }, { timeout: DEFAULT_STAGE_TIMEOUT_MS, signal: controller.signal }, ); stage_results.seed = seedPayload; completed_stages.value = [...completed_stages.value, 'seed-resolve']; const seedContainerIds = normalizeSeedContainerIds(seedPayload); if (seedContainerIds.length === 0) { return stage_results; } current_stage.value = 'lineage'; const lineagePayload = await apiPost( '/api/trace/lineage', { profile, container_ids: seedContainerIds, cache_key: seedPayload?.cache_key || null, params, }, { timeout: DEFAULT_STAGE_TIMEOUT_MS, signal: controller.signal }, ); stage_results.lineage = lineagePayload; completed_stages.value = [...completed_stages.value, 'lineage']; const allContainerIds = collectAllContainerIds(seedContainerIds, lineagePayload, direction); current_stage.value = 'events'; const eventsPayload = await apiPost( '/api/trace/events', { profile, container_ids: allContainerIds, domains, cache_key: seedPayload?.cache_key || null, params, seed_container_ids: seedContainerIds, lineage: { ancestors: lineagePayload?.ancestors || {}, children_map: lineagePayload?.children_map || {}, seed_roots: lineagePayload?.seed_roots || {}, }, }, { timeout: DEFAULT_STAGE_TIMEOUT_MS, signal: controller.signal }, ); // Async path: server returned 202 with job_id if (eventsPayload?.async === true && eventsPayload?.status_url) { job_progress.active = true; job_progress.job_id = eventsPayload.job_id; job_progress.status = 'queued'; // Phase 1: poll until job finishes await pollJobUntilComplete(eventsPayload.status_url, { signal: controller.signal, onProgress: (status) => { job_progress.status = status.status; job_progress.elapsed_seconds = status.elapsed_seconds || 0; job_progress.progress = status.progress || ''; }, }); // Phase 2: stream result via NDJSON (or fall back to full result) const streamUrl = eventsPayload.stream_url; if (streamUrl) { job_progress.progress = 'streaming'; const streamedResult = { stage: 'events', results: {}, aggregation: null }; let totalRecords = 0; await consumeNDJSONStream(streamUrl, { signal: controller.signal, onChunk: (chunk) => { if (chunk.type === 'domain_start') { streamedResult.results[chunk.domain] = { data: [], count: 0, total: chunk.total }; } else if (chunk.type === 'records' && streamedResult.results[chunk.domain]) { const domainResult = streamedResult.results[chunk.domain]; domainResult.data.push(...chunk.data); domainResult.count = domainResult.data.length; totalRecords += chunk.data.length; job_progress.progress = `streaming: ${totalRecords} records`; } else if (chunk.type === 'aggregation') { streamedResult.aggregation = chunk.data; } else if (chunk.type === 'warning') { streamedResult.error = chunk.code; streamedResult.failed_domains = chunk.failed_domains; } else if (chunk.type === 'full_result') { // Legacy fallback: server sent full result as single chunk Object.assign(streamedResult, chunk.data); } }, }); stage_results.events = streamedResult; } else { // No stream_url: fall back to fetching full result const resultUrl = `${eventsPayload.status_url}/result`; stage_results.events = await apiGet(resultUrl, { timeout: DEFAULT_STAGE_TIMEOUT_MS, signal: controller.signal, }); } job_progress.active = false; } else { // Sync path stage_results.events = eventsPayload; } completed_stages.value = [...completed_stages.value, 'events']; return stage_results; } catch (error) { if (error?.name === 'AbortError') { return stage_results; } const key = stageKey(current_stage.value); stage_errors[key] = { code: error?.errorCode || null, message: error?.message || '追溯查詢失敗', }; return stage_results; } finally { if (activeController === controller) { activeController = null; } current_stage.value = null; is_running.value = false; job_progress.active = false; } } return { current_stage, completed_stages, stage_results, stage_errors, job_progress, is_running, execute, reset, abort, }; }