Files
DashBoard/frontend/src/resource-history/App.vue
egg 71c8102de6 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>
2026-02-25 13:15:02 +08:00

600 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { apiGet, apiPost, ensureMesApiAvailable } from '../core/api.js';
import { buildResourceKpiFromHours } from '../core/compute.js';
import {
buildResourceHistoryQueryParams,
deriveResourceFamilyOptions,
deriveResourceMachineOptions,
pruneResourceFilterSelections,
toResourceFilterSnapshot,
} from '../core/resource-history-filters.js';
import { replaceRuntimeHistory } from '../core/shell-navigation.js';
import ComparisonChart from './components/ComparisonChart.vue';
import DetailSection from './components/DetailSection.vue';
import FilterBar from './components/FilterBar.vue';
import HeatmapChart from './components/HeatmapChart.vue';
import KpiCards from './components/KpiCards.vue';
import StackedChart from './components/StackedChart.vue';
import TrendChart from './components/TrendChart.vue';
ensureMesApiAvailable();
const API_TIMEOUT = 360000;
const MAX_QUERY_DAYS = 730;
function createDefaultFilters() {
return toResourceFilterSnapshot({
granularity: 'day',
workcenterGroups: [],
families: [],
machines: [],
isProduction: false,
isKey: false,
isMonitor: false,
});
}
const draftFilters = reactive(createDefaultFilters());
const committedFilters = reactive(createDefaultFilters());
const options = reactive({
workcenterGroups: [],
families: [],
resources: [],
});
const summaryData = ref({
kpi: {},
trend: [],
heatmap: [],
workcenter_comparison: [],
});
const detailData = ref([]);
const hierarchyState = reactive({});
const loading = reactive({
initial: true,
querying: false,
options: false,
});
const queryError = ref('');
const detailWarning = ref('');
const exportMessage = ref('');
const autoPruneHint = ref('');
const queryId = ref('');
const lastPrimarySnapshot = ref('');
const draftWatchReady = ref(false);
let suppressDraftPrune = false;
function runWithDraftPruneSuppressed(callback) {
suppressDraftPrune = true;
try {
callback();
} finally {
suppressDraftPrune = false;
}
}
function resetHierarchyState() {
Object.keys(hierarchyState).forEach((key) => {
delete hierarchyState[key];
});
}
function toDateString(value) {
return value.toISOString().slice(0, 10);
}
function setDefaultDates(target) {
const today = new Date();
const endDate = new Date(today);
endDate.setDate(endDate.getDate() - 1);
const startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - 6);
target.startDate = toDateString(startDate);
target.endDate = toDateString(endDate);
}
function assignFilterState(target, source) {
const snapshot = toResourceFilterSnapshot(source);
target.startDate = snapshot.startDate;
target.endDate = snapshot.endDate;
target.granularity = snapshot.granularity;
target.workcenterGroups = [...snapshot.workcenterGroups];
target.families = [...snapshot.families];
target.machines = [...snapshot.machines];
target.isProduction = snapshot.isProduction;
target.isKey = snapshot.isKey;
target.isMonitor = snapshot.isMonitor;
}
function resetToDefaultFilters(target) {
const defaults = createDefaultFilters();
setDefaultDates(defaults);
assignFilterState(target, defaults);
}
function unwrapApiResult(result, fallbackMessage) {
if (result?.success === true) {
return result;
}
if (result?.success === false) {
throw new Error(result.error || fallbackMessage);
}
return result;
}
function mergeComputedKpi(source) {
return {
...source,
...buildResourceKpiFromHours(source),
};
}
function appendArrayParams(params, key, values) {
for (const value of values || []) {
params.append(key, value);
}
}
function buildQueryStringFromFilters(filters) {
const queryParams = buildResourceHistoryQueryParams(filters);
const params = new URLSearchParams();
params.append('start_date', queryParams.start_date);
params.append('end_date', queryParams.end_date);
params.append('granularity', queryParams.granularity);
appendArrayParams(params, 'workcenter_groups', queryParams.workcenter_groups || []);
appendArrayParams(params, 'families', queryParams.families || []);
appendArrayParams(params, 'resource_ids', queryParams.resource_ids || []);
if (queryParams.is_production) {
params.append('is_production', queryParams.is_production);
}
if (queryParams.is_key) {
params.append('is_key', queryParams.is_key);
}
if (queryParams.is_monitor) {
params.append('is_monitor', queryParams.is_monitor);
}
return params.toString();
}
function readArrayParam(params, key) {
const repeated = params.getAll(key).map((value) => String(value || '').trim()).filter(Boolean);
if (repeated.length > 0) {
return repeated;
}
return String(params.get(key) || '')
.split(',')
.map((value) => value.trim())
.filter(Boolean);
}
function readBooleanParam(params, key) {
const value = String(params.get(key) || '').trim().toLowerCase();
return value === '1' || value === 'true' || value === 'yes';
}
function restoreCommittedFiltersFromUrl() {
const params = new URLSearchParams(window.location.search);
const next = {
startDate: String(params.get('start_date') || '').trim(),
endDate: String(params.get('end_date') || '').trim(),
granularity: String(params.get('granularity') || '').trim(),
workcenterGroups: readArrayParam(params, 'workcenter_groups'),
families: readArrayParam(params, 'families'),
machines: readArrayParam(params, 'resource_ids'),
isProduction: readBooleanParam(params, 'is_production'),
isKey: readBooleanParam(params, 'is_key'),
isMonitor: readBooleanParam(params, 'is_monitor'),
};
if (next.startDate) {
committedFilters.startDate = next.startDate;
}
if (next.endDate) {
committedFilters.endDate = next.endDate;
}
if (next.granularity) {
committedFilters.granularity = next.granularity;
}
if (next.workcenterGroups.length > 0) {
committedFilters.workcenterGroups = next.workcenterGroups;
}
if (next.families.length > 0) {
committedFilters.families = next.families;
}
if (next.machines.length > 0) {
committedFilters.machines = next.machines;
}
committedFilters.isProduction = next.isProduction;
committedFilters.isKey = next.isKey;
committedFilters.isMonitor = next.isMonitor;
}
function updateUrlState() {
const queryString = buildQueryStringFromFilters(committedFilters);
const nextUrl = queryString ? `/resource-history?${queryString}` : '/resource-history';
replaceRuntimeHistory(nextUrl);
}
function validateDateRange(filters) {
if (!filters.startDate || !filters.endDate) {
return '請先設定開始與結束日期';
}
const start = new Date(filters.startDate);
const end = new Date(filters.endDate);
const diffDays = (end - start) / (1000 * 60 * 60 * 24);
if (diffDays < 0) {
return '結束日期必須大於起始日期';
}
if (diffDays > MAX_QUERY_DAYS) {
return '查詢範圍不可超過兩年';
}
return '';
}
async function loadOptions() {
loading.options = true;
try {
const response = await apiGet('/api/resource/history/options', {
timeout: API_TIMEOUT,
silent: true,
});
const payload = unwrapApiResult(response, '載入篩選選項失敗');
const data = payload.data || {};
options.workcenterGroups = Array.isArray(data.workcenter_groups) ? data.workcenter_groups : [];
options.families = Array.isArray(data.families) ? data.families : [];
options.resources = Array.isArray(data.resources) ? data.resources : [];
} finally {
loading.options = false;
}
}
const familyOptions = computed(() => {
return deriveResourceFamilyOptions(options.resources, draftFilters);
});
const machineOptions = computed(() => {
return deriveResourceMachineOptions(options.resources, draftFilters);
});
const filterBarOptions = computed(() => {
return {
workcenterGroups: options.workcenterGroups,
families: familyOptions.value,
};
});
function formatPruneHint(removed) {
const parts = [];
if (removed.families.length > 0) {
parts.push(`型號: ${removed.families.join(', ')}`);
}
if (removed.machines.length > 0) {
parts.push(`機台: ${removed.machines.join(', ')}`);
}
if (parts.length === 0) {
return '';
}
return `已自動清除失效篩選:${parts.join('')}`;
}
function pruneDraftSelections({ showHint = true } = {}) {
const result = pruneResourceFilterSelections(draftFilters, {
familyOptions: familyOptions.value,
machineOptions: machineOptions.value,
});
if (result.removedCount > 0) {
runWithDraftPruneSuppressed(() => {
draftFilters.families = [...result.filters.families];
draftFilters.machines = [...result.filters.machines];
});
if (showHint) {
autoPruneHint.value = formatPruneHint(result.removed);
}
}
return result;
}
const pruneSignature = computed(() => {
return JSON.stringify({
workcenterGroups: draftFilters.workcenterGroups,
families: draftFilters.families,
machines: draftFilters.machines,
isProduction: draftFilters.isProduction,
isKey: draftFilters.isKey,
isMonitor: draftFilters.isMonitor,
familyOptions: familyOptions.value,
machineOptions: machineOptions.value.map((item) => item.value),
});
});
watch(pruneSignature, () => {
if (!draftWatchReady.value || suppressDraftPrune) {
return;
}
pruneDraftSelections({ showHint: true });
});
// ─── 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;
loading.initial = false;
return;
}
loading.querying = true;
queryError.value = '';
detailWarning.value = '';
exportMessage.value = '';
try {
updateUrlState();
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,
};
const response = await apiPost('/api/resource/history/query', body, {
timeout: API_TIMEOUT,
silent: true,
});
const payload = unwrapApiResult(response, '查詢失敗');
queryId.value = payload.query_id || '';
lastPrimarySnapshot.value = buildPrimarySnapshot(committedFilters);
applyViewResult(payload);
} catch (error) {
if (error?.name === 'AbortError') {
queryError.value = '查詢逾時,請縮小日期範圍或資源篩選後重試';
} else {
queryError.value = error?.message || '查詢失敗';
}
summaryData.value = { kpi: {}, trend: [], heatmap: [], workcenter_comparison: [] };
detailData.value = [];
resetHierarchyState();
} finally {
loading.querying = false;
loading.initial = false;
}
}
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) {
queryError.value = validationError;
return;
}
pruneDraftSelections({ showHint: true });
assignFilterState(committedFilters, draftFilters);
const currentPrimary = buildPrimarySnapshot(committedFilters);
if (queryId.value && currentPrimary === lastPrimarySnapshot.value) {
await refreshView();
} else {
await executePrimaryQuery();
}
}
async function clearFilters() {
runWithDraftPruneSuppressed(() => {
resetToDefaultFilters(draftFilters);
});
autoPruneHint.value = '';
assignFilterState(committedFilters, draftFilters);
await executePrimaryQuery();
}
function updateFilters(nextFilters) {
runWithDraftPruneSuppressed(() => {
assignFilterState(draftFilters, nextFilters);
});
pruneDraftSelections({ showHint: true });
}
function handleToggleRow(rowId) {
hierarchyState[rowId] = !hierarchyState[rowId];
}
function handleToggleAllRows({ expand, rowIds }) {
(rowIds || []).forEach((rowId) => {
hierarchyState[rowId] = Boolean(expand);
});
}
function exportCsv() {
if (!committedFilters.startDate || !committedFilters.endDate) {
queryError.value = '請先設定查詢條件';
return;
}
const queryString = buildQueryStringFromFilters(committedFilters);
const link = document.createElement('a');
link.href = `/api/resource/history/export?${queryString}`;
link.download = `resource_history_${committedFilters.startDate}_to_${committedFilters.endDate}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
exportMessage.value = 'CSV 匯出中...';
}
async function initPage() {
resetToDefaultFilters(committedFilters);
restoreCommittedFiltersFromUrl();
runWithDraftPruneSuppressed(() => {
assignFilterState(draftFilters, committedFilters);
});
try {
await loadOptions();
pruneDraftSelections({ showHint: true });
assignFilterState(committedFilters, draftFilters);
} catch (error) {
queryError.value = error?.message || '載入篩選選項失敗';
}
draftWatchReady.value = true;
await executePrimaryQuery();
}
onMounted(() => {
void initPage();
});
</script>
<template>
<div class="resource-page">
<div class="dashboard">
<header class="header-gradient history-header">
<h1>設備歷史績效</h1>
</header>
<FilterBar
:filters="draftFilters"
:options="filterBarOptions"
:machine-options="machineOptions"
:loading="loading.options || loading.querying"
@update-filters="updateFilters"
@query="applyFilters"
@clear="clearFilters"
/>
<p v-if="queryError" class="error-banner query-error">{{ queryError }}</p>
<p v-if="autoPruneHint" class="filter-indicator">{{ autoPruneHint }}</p>
<p v-if="detailWarning" class="filter-indicator active">{{ detailWarning }}</p>
<p v-if="exportMessage" class="filter-indicator active">{{ exportMessage }}</p>
<KpiCards :kpi="summaryData.kpi" />
<section class="section-card">
<div class="section-inner">
<div class="chart-grid">
<TrendChart :trend="summaryData.trend || []" />
<StackedChart :trend="summaryData.trend || []" />
<ComparisonChart :comparison="summaryData.workcenter_comparison || []" />
<HeatmapChart :heatmap="summaryData.heatmap || []" />
</div>
</div>
</section>
<DetailSection
:detail-data="detailData"
:expanded-state="hierarchyState"
:loading="loading.querying"
@toggle-row="handleToggleRow"
@toggle-all="handleToggleAllRows"
@export-csv="exportCsv"
/>
</div>
</div>
</template>