This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
================================================================================
|
||||
|
||||
【已完成】
|
||||
- V10.594 重整 PChome 比價覆核工作台 UX:覆核頁不再沿用首頁商品表格,也不再把 `matcher_rescore`、`stored_status`、`rescore_accepted_current`、`HITL`、`COMPLETE` 等內部診斷/狀態碼輸出到前台或 tooltip;改為「商品 / MOMO、PChome 候選、覆核判讀、下一步、紀錄」六欄工作流。同步修正 catalog review status 的前台語義、決策信封中文標籤、局部 1540px 橫向工作台與手機版欄位 label,並補 `test_frontend_v2_assets.py` guard 防止 raw diagnostics 回流。
|
||||
- V10.597 重整 PChome 比價覆核工作台 UX 並補全站巡檢能力:覆核頁不再沿用首頁商品表格,也不再把 `matcher_rescore`、`stored_status`、`rescore_accepted_current`、`HITL`、`COMPLETE` 等內部診斷/狀態碼輸出到前台或 tooltip;改為「商品 / MOMO、PChome 候選、覆核判讀、下一步、紀錄」六欄工作流。同步修正 catalog review status 的前台語義、決策信封中文標籤、局部 1540px 橫向工作台、手機版欄位 label,並把覆核狀態分段列改為自適應 grid,避免 chip 造成桌面/平板/手機視覺溢出;`check_responsive_overflow.js` 改為逐頁輸出、HTTPS context、commit+body ready、timeout 後安全收尾,讓桌面/平板/手機全站 UX 巡檢可追蹤;topbar AI 觀測台 indicator 增加前端 60 秒 session cache / 2.5 秒 abort 與後端 30 秒 cache,避免每頁跳轉重複打 DB 查詢拖慢全站;`market_intel/disabled.html` 從 1MB 大型停用頁改為輕量狀態頁,保留狀態與正式操作入口,避免停用模組拖慢巡檢與使用者操作。
|
||||
- V10.584 補 PChome Nick 去重與 stale recovery 單品窄門:`Nick` 先去 HTML / 行銷星號 / 重複品名,避免 `29g`、`100ml` 被同一商品副標重複計數成 `component_count_conflict`;同步新增 NIVEA 妮維雅霜 100ml、Schick 舒綺敏感肌除毛刀片 3 入、TS6 沁涼潔淨慕斯 100g 的具名 exact total-price alignment。IBL 沐浴精+洗髮精 vs 洗髮精仍保留 identity review,唇釉色號/目錄款與 Paula's Choice 效期/金蓋差異仍不自動寫正式價差。
|
||||
- V10.583 補 Paula's Choice 身體乳 PChome Nick 具名 alignment:`2%水楊酸身體乳210ml二入` 可和 PChome `Nick` 補出的 `水楊酸身體乳雙入組 / 210ml x2` 對齊,進 `exact / total_price / price_alert_exact`;但 `118ml二入組(金蓋限定版)` 對上 PChome 效期品仍保留 `manual_review / identity_review`,不泛用放寬中文入數。
|
||||
- V10.582 補 PChome 比價通知專業分級與 Nick 副標身份證據:NemoTron 價格決策信封現在保留 `momo_price`、`competitor_price`、`candidate_gap_pct` 與 `sales_7d_delta_pct`,EventRouter / Telegram 模板會把 `match_type / price_basis / alert_tier` 翻成「直接價格威脅、單位價覆核、身份覆核、壓制告警」與操作邊界;PChome crawler 會保留 `Nick` 副標為 `match_name` 給 matcher 使用,UI/DB 顯示仍維持原品名,讓容量、入數、濃度資訊可參與比對。
|
||||
|
||||
@@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
|
||||
# ==========================================
|
||||
# 系統版本與路徑
|
||||
# ==========================================
|
||||
SYSTEM_VERSION = "V10.594"
|
||||
SYSTEM_VERSION = "V10.597"
|
||||
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
|
||||
public_url = PUBLIC_URL # 用於模板顯示
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ Operation Ollama-First v5.0 / Phase 27 — Admin Observability Dashboard
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, render_template, request, jsonify, send_file, url_for
|
||||
from sqlalchemy import text as sa_text
|
||||
@@ -37,6 +38,12 @@ admin_observability_bp = Blueprint(
|
||||
|
||||
_PPT_AIDER_HEAL_LOCK = threading.Lock()
|
||||
_PPT_AIDER_HEAL_ACTIVE = {}
|
||||
_HEALTH_INDICATOR_CACHE_LOCK = threading.Lock()
|
||||
_HEALTH_INDICATOR_CACHE = {
|
||||
'expires_at': 0.0,
|
||||
'payload': None,
|
||||
}
|
||||
_HEALTH_INDICATOR_CACHE_TTL_SECONDS = 30
|
||||
|
||||
|
||||
_GEMINI_BACKUP_CALLER_DISPLAY = {
|
||||
@@ -2169,6 +2176,12 @@ def health_indicator_api():
|
||||
- 預算 ≥ 90%
|
||||
"""
|
||||
try:
|
||||
now_ts = time.time()
|
||||
with _HEALTH_INDICATOR_CACHE_LOCK:
|
||||
cached_payload = _HEALTH_INDICATOR_CACHE.get('payload')
|
||||
if cached_payload and now_ts < float(_HEALTH_INDICATOR_CACHE.get('expires_at') or 0):
|
||||
return jsonify(dict(cached_payload))
|
||||
|
||||
session = get_session()
|
||||
try:
|
||||
# 三主機最新狀態
|
||||
@@ -2247,7 +2260,7 @@ def health_indicator_api():
|
||||
+ (1 if error_rate >= 30 else 0)
|
||||
+ (1 if budget_alert else 0)
|
||||
)
|
||||
return jsonify({
|
||||
payload = {
|
||||
'ok': True,
|
||||
'alert_count': alert_count,
|
||||
'host_unhealthy': host_unhealthy,
|
||||
@@ -2255,7 +2268,11 @@ def health_indicator_api():
|
||||
'error_rate_high': error_rate >= 30,
|
||||
'budget_alert': budget_alert,
|
||||
'tooltip': _build_indicator_tooltip(host_unhealthy, ep_pending, error_rate, budget_alert),
|
||||
})
|
||||
}
|
||||
with _HEALTH_INDICATOR_CACHE_LOCK:
|
||||
_HEALTH_INDICATOR_CACHE['payload'] = dict(payload)
|
||||
_HEALTH_INDICATOR_CACHE['expires_at'] = time.time() + _HEALTH_INDICATOR_CACHE_TTL_SECONDS
|
||||
return jsonify(payload)
|
||||
finally:
|
||||
session.close()
|
||||
except Exception as e:
|
||||
|
||||
@@ -105,6 +105,7 @@ function parseArgs(argv) {
|
||||
routes: [],
|
||||
viewports: DEFAULT_VIEWPORTS,
|
||||
timeoutMs: 30000,
|
||||
waitUntil: 'commit',
|
||||
settleMs: 350,
|
||||
maxOverflow: 1,
|
||||
screenshotDir: '',
|
||||
@@ -128,6 +129,8 @@ function parseArgs(argv) {
|
||||
options.viewports = [];
|
||||
} else if (arg === '--timeout') {
|
||||
options.timeoutMs = parseInt(argv[++i], 10) * 1000;
|
||||
} else if (arg === '--wait-until') {
|
||||
options.waitUntil = argv[++i];
|
||||
} else if (arg === '--settle-ms') {
|
||||
options.settleMs = parseInt(argv[++i], 10);
|
||||
} else if (arg === '--max-overflow') {
|
||||
@@ -165,6 +168,7 @@ Options:
|
||||
--viewport name=WxH Add a viewport
|
||||
--clear-default-viewports Use only custom --viewport entries
|
||||
--timeout SEC Navigation timeout, default 30
|
||||
--wait-until EVENT Playwright navigation event, default commit
|
||||
--settle-ms MS Fixed post-DOM layout settle wait, default 350
|
||||
--max-overflow PX Allowed body overflow, default 1
|
||||
--screenshot-dir DIR Save failure screenshots
|
||||
@@ -215,6 +219,32 @@ function safeName(input) {
|
||||
return input.replace(/[^a-z0-9]+/gi, '_').replace(/^_+|_+$/g, '').toLowerCase() || 'root';
|
||||
}
|
||||
|
||||
async function createViewportPage(context, viewport) {
|
||||
const page = await context.newPage();
|
||||
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
||||
return page;
|
||||
}
|
||||
|
||||
async function closePage(page) {
|
||||
if (!page) {
|
||||
return;
|
||||
}
|
||||
await Promise.race([
|
||||
page.close().catch(() => {}),
|
||||
new Promise((resolve) => setTimeout(resolve, 1000)),
|
||||
]);
|
||||
}
|
||||
|
||||
async function closeWithTimeout(target, timeoutMs = 1500) {
|
||||
if (!target || typeof target.close !== 'function') {
|
||||
return;
|
||||
}
|
||||
await Promise.race([
|
||||
target.close().catch(() => {}),
|
||||
new Promise((resolve) => setTimeout(resolve, timeoutMs)),
|
||||
]);
|
||||
}
|
||||
|
||||
async function collectMetrics(page, maxOverflow) {
|
||||
return page.evaluate(
|
||||
({ localScrollSelectors, maxOverflowPx }) => {
|
||||
@@ -269,6 +299,22 @@ async function collectMetrics(page, maxOverflow) {
|
||||
);
|
||||
}
|
||||
|
||||
function formatResult(result) {
|
||||
const status = result.passed ? 'PASS' : 'FAIL';
|
||||
const overflow = result.metrics ? `${result.metrics.overflow}px` : 'n/a';
|
||||
const localScroll = result.metrics ? result.metrics.localScroll.length : 0;
|
||||
return `${status} ${result.viewport} ${result.route} overflow=${overflow} local_scroll=${localScroll}${result.error ? ` error=${result.error}` : ''}`;
|
||||
}
|
||||
|
||||
function printResult(result) {
|
||||
console.log(formatResult(result));
|
||||
if (!result.passed && result.metrics && result.metrics.offenders.length) {
|
||||
for (const offender of result.metrics.offenders) {
|
||||
console.log(` offender ${offender.tag}.${offender.className} right=${offender.right} text="${offender.text}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const { chromium } = requirePlaywright();
|
||||
@@ -283,15 +329,13 @@ async function main() {
|
||||
}
|
||||
|
||||
const browser = await chromium.launch(launchOptions);
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const results = [];
|
||||
const pages = new Map();
|
||||
|
||||
try {
|
||||
for (const viewport of options.viewports) {
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: viewport.width, height: viewport.height },
|
||||
deviceScaleFactor: 1,
|
||||
});
|
||||
const page = await createViewportPage(context, viewport);
|
||||
pages.set(viewport.name, page);
|
||||
}
|
||||
|
||||
@@ -302,9 +346,11 @@ async function main() {
|
||||
let status = 0;
|
||||
let error = '';
|
||||
let metrics = null;
|
||||
let resetPage = false;
|
||||
|
||||
try {
|
||||
const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: options.timeoutMs });
|
||||
const response = await page.goto(url, { waitUntil: options.waitUntil, timeout: options.timeoutMs });
|
||||
await page.waitForSelector('body', { timeout: Math.min(options.timeoutMs, 5000) }).catch(() => {});
|
||||
await page.evaluate(() => document.fonts && document.fonts.ready).catch(() => {});
|
||||
if (options.settleMs > 0) {
|
||||
await page.waitForTimeout(options.settleMs);
|
||||
@@ -322,37 +368,35 @@ async function main() {
|
||||
}
|
||||
} catch (err) {
|
||||
error = err.message || String(err);
|
||||
resetPage = true;
|
||||
}
|
||||
|
||||
const passed = !error;
|
||||
const result = { route, viewport: viewport.name, status, passed, error, metrics };
|
||||
results.push(result);
|
||||
if (!options.json) {
|
||||
printResult(result);
|
||||
}
|
||||
|
||||
if ((options.screenshotAll || !passed) && options.screenshotDir) {
|
||||
const file = `${safeName(route)}_${safeName(viewport.name)}.png`;
|
||||
await page.screenshot({ path: path.join(options.screenshotDir, file), fullPage: false });
|
||||
}
|
||||
|
||||
if (resetPage) {
|
||||
await closePage(page);
|
||||
pages.set(viewport.name, await createViewportPage(context, viewport));
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await Promise.all(Array.from(pages.values()).map((page) => page.close().catch(() => {})));
|
||||
await browser.close();
|
||||
await Promise.all(Array.from(pages.values()).map((page) => closePage(page)));
|
||||
await closeWithTimeout(context);
|
||||
await closeWithTimeout(browser);
|
||||
}
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(results, null, 2));
|
||||
} else {
|
||||
for (const result of results) {
|
||||
const status = result.passed ? 'PASS' : 'FAIL';
|
||||
const overflow = result.metrics ? `${result.metrics.overflow}px` : 'n/a';
|
||||
const localScroll = result.metrics ? result.metrics.localScroll.length : 0;
|
||||
console.log(`${status} ${result.viewport} ${result.route} overflow=${overflow} local_scroll=${localScroll}${result.error ? ` error=${result.error}` : ''}`);
|
||||
if (!result.passed && result.metrics && result.metrics.offenders.length) {
|
||||
for (const offender of result.metrics.offenders) {
|
||||
console.log(` offender ${offender.tag}.${offender.className} right=${offender.right} text="${offender.text}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const failed = results.filter((result) => !result.passed);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -47,6 +47,36 @@ def test_frontend_v2_shell_uses_real_runtime_context():
|
||||
assert all(marker not in combined for marker in forbidden_markers)
|
||||
|
||||
|
||||
def test_topbar_observability_indicator_is_cached_and_timeout_bounded():
|
||||
base_js = (ROOT / "web/static/js/ewoooc-base.js").read_text(encoding="utf-8")
|
||||
observability_route = (ROOT / "routes/admin_observability_routes.py").read_text(encoding="utf-8")
|
||||
|
||||
assert "momoObsHealthIndicator:v1" in base_js
|
||||
assert "sessionStorage.getItem(cacheKey)" in base_js
|
||||
assert "sessionStorage.setItem(cacheKey" in base_js
|
||||
assert "const cacheTtlMs = 60000" in base_js
|
||||
assert "new AbortController()" in base_js
|
||||
assert "setTimeout(() => controller.abort(), 2500)" in base_js
|
||||
assert "setInterval(() => refresh(false), 60000)" in base_js
|
||||
assert "_HEALTH_INDICATOR_CACHE_LOCK" in observability_route
|
||||
assert "_HEALTH_INDICATOR_CACHE_TTL_SECONDS = 30" in observability_route
|
||||
assert "return jsonify(dict(cached_payload))" in observability_route
|
||||
|
||||
|
||||
def test_market_intel_disabled_page_stays_lightweight_and_action_oriented():
|
||||
template_path = ROOT / "templates/market_intel/disabled.html"
|
||||
template = template_path.read_text(encoding="utf-8")
|
||||
|
||||
assert template_path.stat().st_size < 40000
|
||||
assert "市場情報模組待啟用" in template
|
||||
assert "比價覆核" in template
|
||||
assert "PChome 爬蟲" in template
|
||||
assert "AI 觀測台" in template
|
||||
assert "data-market-intel-preview" not in template
|
||||
assert "/api/market_intel/" not in template
|
||||
assert "讀取候選預覽中" not in template
|
||||
|
||||
|
||||
def test_frontend_v2_syncs_latest_momo_pro_prototype_tokens_and_shell():
|
||||
tokens = (ROOT / "web/static/css/ewoooc-tokens.css").read_text(encoding="utf-8")
|
||||
shell = (ROOT / "web/static/css/ewoooc-shell.css").read_text(encoding="utf-8")
|
||||
@@ -325,6 +355,7 @@ def test_pchome_review_export_and_diagnostics_use_real_queue_data():
|
||||
assert ".dashboard-review-envelope" in dashboard_css
|
||||
assert ".dashboard-review-actions" in dashboard_css
|
||||
assert ".dashboard-review-action.is-research" in dashboard_css
|
||||
assert "grid-template-columns: repeat(auto-fit, minmax(128px, 1fr))" in dashboard_css
|
||||
|
||||
|
||||
def test_ai_intelligence_uses_v2_shell_and_real_runtime_apis():
|
||||
|
||||
@@ -632,21 +632,23 @@
|
||||
}
|
||||
|
||||
.dashboard-review-segments {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(128px, 1fr));
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
overflow-x: auto;
|
||||
overflow: visible;
|
||||
border-bottom: 1px solid var(--momo-border-light);
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.dashboard-review-segments a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
min-height: 30px;
|
||||
padding: 6px 10px;
|
||||
overflow: hidden;
|
||||
color: var(--momo-text-secondary);
|
||||
background: var(--momo-bg-paper);
|
||||
border: 1px solid var(--momo-border-light);
|
||||
@@ -656,6 +658,13 @@
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.dashboard-review-segments a span:first-child {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dashboard-review-segments a.is-active {
|
||||
color: var(--momo-text-inverse);
|
||||
background: var(--momo-ink);
|
||||
|
||||
@@ -76,23 +76,63 @@
|
||||
const link = document.getElementById('momo-obs-link');
|
||||
const badge = document.getElementById('momo-obs-badge');
|
||||
if (!link || !badge) return;
|
||||
async function refresh() {
|
||||
|
||||
const cacheKey = 'momoObsHealthIndicator:v1';
|
||||
const cacheTtlMs = 60000;
|
||||
|
||||
function applyIndicator(d) {
|
||||
if (!d || !d.ok) return;
|
||||
link.title = d.tooltip || 'AI 觀測台';
|
||||
if (d.alert_count > 0) {
|
||||
badge.textContent = d.alert_count;
|
||||
badge.hidden = false;
|
||||
link.classList.add('is-alert');
|
||||
} else {
|
||||
badge.hidden = true;
|
||||
link.classList.remove('is-alert');
|
||||
}
|
||||
}
|
||||
|
||||
function readCachedIndicator() {
|
||||
try {
|
||||
const r = await fetch('/observability/api/health_indicator', { credentials: 'same-origin' });
|
||||
if (!r.ok) return;
|
||||
const d = await r.json();
|
||||
if (!d.ok) return;
|
||||
link.title = d.tooltip || 'AI 觀測台';
|
||||
if (d.alert_count > 0) {
|
||||
badge.textContent = d.alert_count;
|
||||
badge.hidden = false;
|
||||
link.classList.add('is-alert');
|
||||
} else {
|
||||
badge.hidden = true;
|
||||
link.classList.remove('is-alert');
|
||||
}
|
||||
const cached = JSON.parse(sessionStorage.getItem(cacheKey) || 'null');
|
||||
if (!cached || !cached.data || Date.now() - cached.ts > cacheTtlMs) return null;
|
||||
return cached.data;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCachedIndicator(data) {
|
||||
try {
|
||||
sessionStorage.setItem(cacheKey, JSON.stringify({ ts: Date.now(), data }));
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function refresh(useCache = true) {
|
||||
if (useCache) {
|
||||
const cached = readCachedIndicator();
|
||||
if (cached) {
|
||||
applyIndicator(cached);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 2500);
|
||||
try {
|
||||
const r = await fetch('/observability/api/health_indicator', {
|
||||
credentials: 'same-origin',
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!r.ok) return;
|
||||
const d = await r.json();
|
||||
writeCachedIndicator(d);
|
||||
applyIndicator(d);
|
||||
} catch (e) {
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
refresh();
|
||||
setInterval(refresh, 60000);
|
||||
setInterval(() => refresh(false), 60000);
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user