diff --git a/docs/insights-metric-contract.md b/docs/insights-metric-contract.md new file mode 100644 index 0000000..b303ea5 --- /dev/null +++ b/docs/insights-metric-contract.md @@ -0,0 +1,41 @@ +# Insights observation contract correction + +Companion server PR: https://github.com/reactnativecn/pushy-go/pull/17 + +## Decisions + +The page describes best-effort observations, not an exactly-once update ledger. Delete coverage = retained activation UUIDs / today's check UUIDs and retained activation/download conversion. Do not cap them at 100% or substitute another unrelated denominator. Retained device observations are approximate positive counts, with missing/expired values shown unavailable; key lifetime is not permanent all-time or rolling-window coverage. + +Version events are independent reports and target options. Main and experiment can coexist in one response; an experiment offer is not client selection. Patch failure followed by full recovery can generate both failure and success. Display each event separately, and label report shares with their numerator, denominator and sample size. Rollback classification is explicitly rollback-only, never overall health. Low samples are visible; download/Patch failures remain separate columns. The 1%/5% rollback thresholds and minimum sample count are shared with the service-status panel in `src/constants/metric-thresholds.ts`; rollback labels use the shared translation-key map and precomputed row values. + +Traffic and failures keep their business-calendar window; historical version events keep their UTC-calendar window. Those UTC daily rollups cannot be shifted losslessly by eight hours. Each section shows the server's exact inclusive/exclusive bounds and last successful UI refresh. Legacy servers have an explicit timezone/availability warning. UI refresh is 60 seconds, not the writer's one-second flush interval. True unified historical windows need a separate collection/retention migration, not a frontend date-label change. + +Both request/device means exclude the current partial day, include known valid zeroes and exclude explicitly unavailable values. A device count of zero without availability metadata is ambiguous and is excluded; a zero with `dauStatus: observed` is valid. Positive legacy UUID observations remain usable. Their actual included/eligible day counts are shown. Requests, reports, UUID estimates and people are not interchangeable. + +Request-based ratios use the same available request days for numerator and denominator. Days with unavailable or invalid request totals do not contribute hit outcomes, native-package request counts or refused-package details. Independently collected hourly, host, carrier and device observations are retained in their own series. An hourly chart is shown according to its own nonzero observations, not according to the request total. Package request counts/shares with no usable request population are unavailable, not fabricated zeroes. + +The server summary is computed before top-50 truncation and includes unattributed event subsets. The console uses it for app-wide totals; legacy responses are labelled returned-version subtotals. Table filters are distinct from the app summary. Package-filtered detail hides cumulative observations because there is no package dimension. The version glance sorts returned candidates by offered targets plus all client report counts, with hash as a deterministic tie-breaker, before taking five entries. It never mutates cached API rows or promises to include versions omitted by the server. + +Native package request share is not installation share. Device peaks include only available observations. The page consumes the server's nullable device values, partial/expired/unavailable status, tracking-limit marker and separate 14-day device retention (requests retain 35 days). A missing device estimate is never a reason to infer the package is unused. + +No-update includes already-current and no bound version. Paused/restricted can be app, package or quota. Expired identifies a native package. Existing aggregate data cannot establish a more specific reason. All unrecognized failure reasons, including legacy free-text prefixes and future categories, are normalized to a single `other` identity before grouping, preserving event-type and version subtotals. + +Failure breakdown availability is evaluated before version filtering. Explicitly unavailable buckets are excluded from every dimension even when leftover numeric arrays are present. An explicitly observed empty bucket is usable; a legacy empty bucket is not evidence of zero failures. Legacy buckets with positive report evidence remain usable with a metadata warning. The UI shows available/total/unavailable day counts; an entirely unavailable window renders unavailable, whereas an observed empty filter states only that no reports were observed in that filter, not that the application is healthy. + +## Compatibility and rollout + +Deploy pushy-go #17 first, then this console. All server fields are additive; the console continues to operate with old payloads and labels their limitations. No billing, rollout decisions, auto-pause rules, client protocol, retention TTL, production data or schema is mutated. Existing realtime series, release insights and region sections retain their independent time controls. + +Metric-contract translations live directly in the canonical `src/i18n/locales/en.json` and `zh-CN.json` catalogs. The duplicate runtime override file has been removed. Runtime registration and locale validation import the same side-effect-free `src/i18n/resources.ts` catalog. Tests retain base JSON key parity, validate bilingual key parity and static source references, reject plain-text rendering of tagged translations, and assert that runtime resources are the canonical JSON objects. + +## Verification + +Unit regressions cover zero days, unavailable values, partial today, supplied business date, independently retained UUID sets, package filters, old/new payloads, top-N summaries, unlinked patch recovery, sample thresholds and old failure labels. Review regressions additionally cover matching request populations, invalid denominators, legacy versus explicit UUID zeroes, all/mixed/legacy breakdown availability, unknown-category coalescing, non-mutating ranking and inclusive rollback threshold boundaries. + +Render tests cover rollback-only wording, hidden out-of-scope retained metrics, the former 250% scenario, legacy totals, stale windows and package observation states. They also distinguish entirely unavailable failures from explicitly observed empty buckets, preserve observed failures in mixed windows, and display legacy availability warnings in Chinese. + +The existing CI gates remain unchanged: typecheck, lint, tests, production build and bundle-size limits. One-off repair helpers and workflows used to apply and validate the edits have been removed from the final branch. + +## Not fabricated by this correction + +Current version device share, linked-attempt upgrade conversion, first-device activation latency and exact gray participation need their own observation sets or client-attempt correlation. This change removes unsupported claims instead of inventing those measurements. diff --git a/src/constants/i18n-keys.ts b/src/constants/i18n-keys.ts index 5031d29..fa27afc 100644 --- a/src/constants/i18n-keys.ts +++ b/src/constants/i18n-keys.ts @@ -93,12 +93,12 @@ export const DEPS_VIOLATION_MESSAGE_KEY: Record< rnu_downgrade: 'bind_package.deps_rnu_downgrade', }; -/** 版本漏斗的健康标签(回滚率阈值判定,null 表示样本不足不判定)。 */ +/** 回滚报告占比标签(null 表示样本不足,不代表整体健康)。 */ export const FUNNEL_HEALTH_LABEL_KEY: Record< NonNullable, string > = { - healthy: 'app_insights.health_healthy', - warning: 'app_insights.health_warning', - critical: 'app_insights.health_critical', + healthy: 'app_insights.rollback_low', + warning: 'app_insights.rollback_warning', + critical: 'app_insights.rollback_high', }; diff --git a/src/constants/metric-thresholds.ts b/src/constants/metric-thresholds.ts new file mode 100644 index 0000000..dc172b6 --- /dev/null +++ b/src/constants/metric-thresholds.ts @@ -0,0 +1,5 @@ +// Shared by app insights and the service-status version report panel. +// These thresholds classify rollback report shares, not overall health. +export const CRITICAL_ROLLBACK = 0.05; +export const WARNING_ROLLBACK = 0.01; +export const MIN_EVENT_SAMPLES = 10; diff --git a/src/i18n/canonical-resources.test.ts b/src/i18n/canonical-resources.test.ts new file mode 100644 index 0000000..bd8efde --- /dev/null +++ b/src/i18n/canonical-resources.test.ts @@ -0,0 +1,9 @@ +import { expect, test } from 'bun:test'; +import en from './locales/en.json'; +import zhCN from './locales/zh-CN.json'; +import { resources } from './resources'; + +test('runtime translations use canonical JSON without duplicate overrides', () => { + expect(resources.en.translation).toBe(en); + expect(resources['zh-CN'].translation).toBe(zhCN); +}); diff --git a/src/i18n/index.ts b/src/i18n/index.ts index a35b2af..78ccd1d 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -1,17 +1,13 @@ import i18n from 'i18next'; import LanguageDetector from 'i18next-browser-languagedetector'; import { initReactI18next } from 'react-i18next'; -import en from './locales/en.json'; -import zhCN from './locales/zh-CN.json'; +import { resources } from './resources'; i18n .use(LanguageDetector) .use(initReactI18next) .init({ - resources: { - en: { translation: en }, - 'zh-CN': { translation: zhCN }, - }, + resources, fallbackLng: 'zh-CN', interpolation: { escapeValue: false, diff --git a/src/i18n/locales.test.ts b/src/i18n/locales.test.ts index 187c9d7..3b1a06d 100644 --- a/src/i18n/locales.test.ts +++ b/src/i18n/locales.test.ts @@ -2,13 +2,10 @@ import { describe, expect, it } from 'bun:test'; import { readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { resources } from './resources'; -// 两类 i18n 事故 CI 都看不见,因为 t() 的返回值类型上就是 string: -// 1. 只给一个语言包加了 key —— 另一个语言回退成英文/中文原文; -// 2. 两个语言包都没加 —— 界面直接显示 'nav.automation' 这样的原始 key; -// 3. 文案里带 之类的标签,却用 t() 渲染 —— 标签被原样显示。 -// 下面三个测试分别挡住它们。 - +// Validate both the base JSON catalogs and the effective runtime resources. +// Runtime overlays must not bypass key parity, missing-key or markup checks. const HERE = fileURLToPath(new URL('.', import.meta.url)); const LOCALES_DIR = join(HERE, 'locales'); const SRC_DIR = join(HERE, '..'); @@ -42,8 +39,7 @@ function sourceFiles(dir: string): string[] { /** * 静态可解析的 key:t('a.b') 与 。 - * t(`a.b_${x}`) 这类模板字面量无法静态判定,由 locale 平价测试兜底 - * ——只要两个语言包的键集一致,动态族就不会只在一边存在。 + * t(`a.b_${x}`) 这类模板字面量无法静态判定,由 locale 平价测试兜底。 */ function staticKeysIn(source: string): string[] { const keys: string[] = []; @@ -72,54 +68,52 @@ function valueAt(locale: Json, path: string): string | undefined { return typeof found === 'string' ? found : undefined; } -const en = loadLocale('en.json'); -const zh = loadLocale('zh-CN.json'); +const en = resources.en.translation; +const zh = resources['zh-CN'].translation; describe('i18n locales', () => { - it('en 与 zh-CN 的键集完全一致', () => { + it('base JSON catalogs keep identical key sets', () => { + expect(leafKeys(loadLocale('en.json')).sort()).toEqual( + leafKeys(loadLocale('zh-CN.json')).sort(), + ); + }); + + it('en 与 zh-CN 的运行时键集完全一致', () => { const enKeys = new Set(leafKeys(en)); const zhKeys = new Set(leafKeys(zh)); - const missingInZh = [...enKeys].filter((key) => !zhKeys.has(key)).sort(); const missingInEn = [...zhKeys].filter((key) => !enKeys.has(key)).sort(); - expect({ missingInZh, missingInEn }).toEqual({ missingInZh: [], missingInEn: [], }); }); - it('源码里静态引用的 key 在两个语言包里都存在', () => { + it('源码里静态引用的 key 在两个运行时语言包里都存在', () => { const enKeys = new Set(leafKeys(en)); const zhKeys = new Set(leafKeys(zh)); - const referenced = new Set( sourceFiles(SRC_DIR).flatMap((file) => staticKeysIn(readFileSync(file, 'utf-8')), ), ); - - // 只校验看起来像 i18n key 的引用(带点号的命名空间路径), - // 避免把恰好叫 t() 的其他调用误判成翻译。 const namespaced = [...referenced].filter((key) => key.includes('.')); const missing = namespaced .filter((key) => !enKeys.has(key) || !zhKeys.has(key)) .sort(); - expect(missing).toEqual([]); }); it('带标签的文案不能走 t(),必须用 渲染', () => { - // t() 返回的是字符串,React 会把 '原生代码' 原样显示出来。 - // 这类文案只能交给 }} />。 - const markup = leafKeys(en).filter((key) => - /<[a-z][a-z0-9]*>/i.test(valueAt(en, key) ?? ''), + const markup = [en, zh].flatMap((locale) => + leafKeys(locale).filter((key) => + /<[a-z][a-z0-9]*>/i.test(valueAt(locale, key) ?? ''), + ), ); - const sources = sourceFiles(SRC_DIR).map((file) => readFileSync(file, 'utf-8'), ); - const renderedAsPlainText = markup + const renderedAsPlainText = [...new Set(markup)] .filter((key) => sources.some( (source) => @@ -127,7 +121,6 @@ describe('i18n locales', () => { ), ) .sort(); - expect(renderedAsPlainText).toEqual([]); }); }); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 860f9cb..77890c1 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -457,95 +457,90 @@ }, "app_insights": { "view_overview": "Overview", - "view_versions": "Version Funnel", + "view_versions": "Version events", "view_traffic": "Traffic", "view_failures": "Failures", "days_option": "Last {{days}} days", - "live": "live", - "today_live": "today, live", + "live": "Refreshes every minute", + "today_live": "Today, incomplete calendar bucket", "unavailable": "Analytics data is temporarily unavailable", "load_failed": "Failed to load data", "today_requests": "Update checks today", "today_live_hint": "Beijing-time today, accumulating live", - "today_dau": "Active devices today", - "dau_hint": "Distinct device uuids", - "window_requests": "Update checks, last {{days}} days", - "daily_average": "{{value}} per completed day", - "peak_dau": "Peak active devices, last {{days}} days", - "dau_average": "{{value}} per day", - "dau": "Active devices", + "today_dau": "Devices checking for updates today", + "dau_hint": "Estimated UUIDs in completed update checks, not all app users", + "window_requests": "Observed checks in {{days}} calendar buckets", + "daily_average": "Complete-day mean {{value}} (available {{samples}} / {{days}} days)", + "peak_dau": "Peak devices among available day observations", + "dau_average": "Complete-day mean {{value}} (available {{samples}} / {{days}} days)", + "dau": "Devices checking for updates", "requests": "Requests", "refusal_title": "{{count}} requests ({{percent}}) got no update because the client did not match", - "refusal_blocked": "{{count}} requests were blocked by build_time_mismatch: the native build time reported by the client differs from the one registered. If the same native package was rebuilt, re-upload it under Releases → Packages, or disable the build-time check in the app settings.", - "refusal_unknown_package": "{{count}} requests came from an unregistered native package version: those devices run a package never uploaded to Pushy, so no update can ever match. Upload that native package and bind a bundle to it.", + "refusal_blocked": "Build identity mismatch: verify the client build and registered native package rather than disabling validation just to remove the warning.", + "refusal_unknown_package": "No registered native package matched. Check the App Key, version and uploaded package records.", "refused_packages": "Native package versions:", "refused_packages_more": "and {{count}} more", - "refused_packages_unattributed": "{{count}} more requests predate the per-version split and cannot be attributed", + "refused_packages_unattributed": "{{count}} requests lack an available package breakdown, including historical or missing detail.", "daily_title": "Daily trend", "daily_requests": "Outcomes", "daily_requests_question": "How many update checks per day, and what did each get?", "daily_dau_question": "How many devices checked for updates each day?", - "daily_footnote": "Per Beijing-time calendar day; today accumulates live (about 1 s delay). Kept for up to {{retention}} days.", + "daily_footnote": "Calendar days in the timezone above. Today is incomplete; missing device data is not filled with zero.", "no_traffic": "No request data in this range", "outcome_title": "Outcome mix", "outcome_question": "Of all update checks, how many received an update and how many were refused?", "update_share": "Update served", - "update_share_hint": "Requests served an hdiff / pdiff / full bundle", + "update_share_hint": "Share of requests offering an update option, not upgrade success", "refused_share": "Refused", "refused_share_hint": "blocked + unregistered package", - "outcome_footnote": "\"Up to date\" means the client already runs the bound bundle; \"paused\" and \"expired\" reflect app or version state, not a failure.", - "hit_uptodate": "Up to date", - "hit_hdiff": "hdiff served", - "hit_pdiff": "pdiff served", - "hit_full": "Full bundle served", - "hit_paused": "App paused", - "hit_expired": "Version expired", + "outcome_footnote": "No update includes already-current and no bound version. Restrictions can come from the app, native package or quota. An offered option is not a completed download.", + "hit_uptodate": "No update offered", + "hit_hdiff": "hdiff option offered", + "hit_pdiff": "pdiff option offered", + "hit_full": "Full bundle option offered", + "hit_paused": "Paused or checks restricted", + "hit_expired": "Native package expired", "hit_blocked": "Build time mismatch (blocked)", "hit_unknown_package": "Unregistered package", - "versions_glance_title": "Versions at a glance", - "versions_glance_question": "Which bundles were served recently, how many devices do they cover, and are they healthy?", + "versions_glance_title": "Highest-volume versions", + "versions_glance_question": "Returned versions ranked by offered targets and client reports in the window, not by release time.", "view_all_versions": "All versions →", - "glance_line": "served {{served}} · activated {{activated}} · activation, all time {{activation}}", + "glance_line": "{{served}} targets offered · {{activated}} activation reports · {{failed}} download/Patch failures · {{rollback}} rollbacks", "coverage": "coverage", "no_versions": "No version deliveries or events in this range", - "coverage_footnote": "Coverage = devices that ever activated the version ÷ active devices today ({{dau}}). Activated devices are cumulative and ignore the day range.", - "health_healthy": "Healthy", - "health_warning": "Watch", - "health_critical": "Critical", "dau_today": "Active devices today", "dau_today_hint": "Denominator of coverage, Beijing-time today", - "versions_in_window": "Versions in window", + "versions_in_window": "Versions with events", "window_utc": "UTC {{start}} – {{end}}", - "window_served": "Deliveries in window", + "window_served": "Total target options offered", "window_served_hint": "Diffs, full bundles and canaries", - "window_rollbacks": "Rollbacks in window", + "window_rollbacks": "Total rollback reports", "window_rollbacks_hint": "Rollback rate ≥5% is critical, ≥1% watch", "truncated": "Too many versions; showing the top {{count}} by deliveries and events.", - "funnel_table_title": "Version funnel", + "funnel_table_title": "Version update events", "funnel_table_question": "For each bundle: how many deliveries, successful downloads, activations and rollbacks, and how many devices does it cover?", "filter_all_versions": "All versions", "filter_all_packages": "All native packages", "no_versions_match": "No version matches the filters", "col_version": "Bundle", - "col_health": "Health", - "col_served": "Served", - "col_downloaded": "Downloaded", - "col_activated": "Activated", + "col_health": "Rollback observations", + "col_served": "Targets offered", + "col_downloaded": "Download-success reports", + "col_activated": "Activation-success reports", "col_failures": "Failed", - "col_rollback": "Rolled back", + "col_rollback": "Rollback reports", "col_activation_rate": "Activation (all time)", - "col_rollback_rate": "Rollback rate", - "col_coverage": "Coverage", + "col_rollback_rate": "Rollback report share", "col_package": "Native package", - "col_peak_devices": "Peak devices / day", + "col_peak_devices": "Peak among available device observations", "col_share": "Share", "col_count": "Count", "served_hdiff": "hdiff", "served_pdiff": "pdiff", "served_full": "Full bundle", "served_full_pending": "Full bundle (diff pending)", - "served_exp": "Canary", - "funnel_title": "Served → downloaded → activated → rolled back", + "served_exp": "Experimental option (not gray-rollout participation)", + "funnel_title": "Independent event counts", "step_served": "Served", "step_downloaded": "Downloaded", "step_activated": "Activated", @@ -553,13 +548,13 @@ "funnel_line": "download success {{downloadSuccess}} · activation, all time {{activation}} · rollback {{rollback}}", "by_package_title": "By native package", "no_package_rows": "No per-package detail", - "adopted_mark": "Devices activated (all time)", - "adopted_download": "Devices downloaded (all time)", + "adopted_mark": "Retained cumulative activation UUID estimate", + "adopted_download": "Retained cumulative download UUID estimate", "adopted_hint": "HLL-distinct, cumulative", "coverage_no_dau": "No active devices today, coverage unavailable", "coverage_of_dau": "{{percent}} of active devices today", - "lag_download": "Time from release to download", - "lag_mark": "Time from release to activation", + "lag_download": "Download-success report arrival after version creation", + "lag_mark": "Activation-success report arrival after version creation", "lag_total": "({{count}} events)", "no_lag": "No data", "lag_lt1h": "< 1 h", @@ -568,17 +563,17 @@ "lag_1d_3d": "1–3 d", "lag_3d_7d": "3–7 d", "lag_gt7d": "> 7 d", - "lag_footnote": "Lag distributions are cumulative from the release time and ignore the day range.", + "lag_footnote": "Shares of reports, not first-activated devices. Origin is version creation, not binding publication or download start. Bins can reset after 90 inactive days.", "funnel_footnote": "Window in UTC days; live hourly buckets from {{hourlyFrom}}. \"Served\" counts actual server deliveries. Activation (all time) = devices that ever activated ÷ devices that ever downloaded, both distinct-device counts that ignore the day range.", "package_filter_note": "With a native package selected, cumulative devices and lag still describe the whole version.", "series_question": "Update checks over the last hours, split by bundle or native package.", "series_footnote": "5-minute buckets; the legend selects the top 10 by default.", "hourly_title": "Hour of day", "hourly_question": "Over the last {{days}} days, which hours carry the most requests?", - "hourly_footnote": "Beijing-time hours, summed across the days in the window.", + "hourly_footnote": "Hours in the business timezone above, summed from available hourly observations independently of request totals. Today is incomplete, so hours can have unequal observation durations.", "packages_title": "Native packages", - "packages_question": "Which native package versions do users run?", - "packages_footnote": "Devices are distinct uuids and cannot be summed across days, so the peak single day is shown; \"-\" means no uuid or beyond the tracking bound.", + "packages_question": "Which native packages check for updates? Request share is not installation share.", + "packages_footnote": "Requests are retained {{requests}} days; devices {{devices}} days. Peaks cover available observations only, not necessarily the selected window. Missing devices do not establish an unused package.", "carriers_title": "Carriers", "carriers_question": "Which networks do requests come from?", "carriers_footnote": "Resolved from the client IP; everything outside mainland China is grouped as \"overseas\".", @@ -600,17 +595,17 @@ "ip_unknown": "Unknown", "ip_footnote": "Determined from the client IP.", "failures_intro": "Why do downloads, patches and activations fail, and which versions, platforms and networks are affected?", - "failure_events": "Failure events, last {{days}} days", - "failure_events_hint": "Download failures + patch failures + rollbacks", + "failure_events": "Classified failure reports in {{days}} calendar buckets", + "failure_events_hint": "Reason aggregates: download failures, Patch failures and rollbacks, not failed devices.", "top_reason": "Top reason", "top_reason_hint": "{{percent}} of failure events", "no_failures": "No failure events in this range", - "worst_os": "Highest failure rate", + "worst_os": "Highest failure report share (at least 10 reports)", "worst_os_hint": "Download/patch failure rate {{percent}}", "no_os_rows": "No platform data", "reasons_title": "Failure reasons", "reasons_question": "Failure events by cause, and which versions each cause affects.", - "reasons_footnote": "Causes are classified from the error reported by the client; \"other\" keeps the error prefix. Rollback events normally carry no error text and land in \"no detail reported\".", + "reasons_footnote": "Reasons are heuristic classifications. Other omits free-text prefixes. Missing detail is not a diagnosed cause. Dimensions are independently aggregated and may differ due to collection loss.", "col_reason": "Reason", "col_event_type": "Event type", "col_versions": "Versions", @@ -628,22 +623,57 @@ "reason_zip": "Unzip failed", "reason_bundle_mismatch": "Bundle mismatch", "reason_other": "Other", - "reason_other_detail": "Other: {{detail}}", "os_title": "By platform", "os_question": "Do failure and rollback rates differ across platforms?", "col_os": "Platform", - "col_failure_rate": "Failure rate", - "rates_footnote": "Failure rate = (download failures + patch failures) ÷ (downloads + download failures + patch failures); rollback rate = rollbacks ÷ (successful starts + rollbacks).", + "col_failure_rate": "Download/Patch failure report share", + "rates_footnote": "Failure share = (download failures + Patch failures) / (download successes + download failures + Patch failures). Rollback share = rollbacks / (activation successes + rollbacks). Parentheses show numerator/denominator; neither is final upgrade failure rate.", "no_events": "No event data in this range", "carrier_events_title": "By carrier", "carrier_events_question": "Are failures concentrated on one carrier's network?", "carrier_no_version_filter": "The carrier dimension is not split by version; select \"All versions\" to see it.", - "breakdown_footnote": "Per Beijing-time calendar day, kept for up to {{retention}} days. Events come only from SDK v10.47.0 and later.", - "version_deleted": "deleted version", - "version_deleted_hint": "This bundle has been deleted; only its hash remains", + "breakdown_footnote": "Business calendar buckets above, retained up to {{retention}} days. Only supported SDK events successfully reported are observed; not all devices.", + "version_deleted": "Unnamed or deleted version", + "version_deleted_hint": "A missing name does not by itself prove deletion.", "copy_hash": "Copy full hash", "page_footnote": "All views share the day range above; \"today\" accumulates live in Beijing time. The version funnel uses UTC days, everything else Beijing-time days.", - "col_carrier": "Carrier" + "col_carrier": "Carrier", + "means_note": "Both means exclude today. Valid zeroes count; unavailable values do not. Available days do not imply complete collection.", + "window_exact": "{{timezone}}: {{start}} to {{end}} (start inclusive, end exclusive)", + "window_utc_legacy": "Legacy API: version events use UTC calendar days; exact window metadata is unavailable.", + "window_business_legacy": "Legacy API lacks business timezone and availability metadata. Today falls back to UTC+8; ambiguous zero-device counts are excluded, not treated as observed zeroes.", + "last_refreshed": "Last successful read: {{time}}; refreshes every minute", + "not_loaded": "No successful read yet", + "stale_data": "Refresh failed; showing the last successful read", + "best_effort": "Best-effort observations may be duplicated or missing. No data does not establish zero devices or no failures.", + "no_observations": "No available observations; this does not establish zero or healthy", + "observed_glance": "Retained cumulative activation UUID estimate", + "col_download_fail": "Download-failure reports", + "col_patch_fail": "Patch-failure reports", + "events_not_funnel": "Independent report counts, not a conversion funnel. One response may offer main and experimental targets. Patch failure followed by full recovery produces both failure and success reports.", + "retained_title": "Retained cumulative devices and report arrival bins", + "retained_scope": "Whole-version retained observations, independent of the day filter; not devices currently running this version.", + "retained_unavailable_package": "Retained device counts and arrival bins have no native-package dimension and are hidden under this filter.", + "retained_hint": "Approximate HLL counts can reset after 35 inactive days. Missing UUIDs or reports can omit devices.", + "rollback_low": "Low rollback report share", + "rollback_warning": "Elevated rollback report share", + "rollback_high": "High rollback report share", + "insufficient_samples": "Insufficient rollback observations ({{count}} / {{minimum}})", + "rollback_only": "Rollback / (activation success + rollback), not overall health. With at least 10 reports, 1% / 5% are the warning thresholds.", + "totals_all": "App-wide observed totals, independent of the filters and top-50 presentation cap", + "totals_returned": "Legacy API: returned-version subtotal, not app-wide totals", + "totals_unattributed": "Included but unattributed: {{offers}} offered targets and {{reports}} client reports", + "filtered_note": "The table follows the version/package filters; the summary above remains app-wide or a returned-version subtotal.", + "request_share": "Request share", + "col_availability": "Device observation range and status", + "package_observed_days": "{{count}} available days: {{start}} to {{end}}", + "package_missing_days": "{{expired}} expired days; {{missing}} unavailable days", + "package_partial": "Collection limit reached; partial data", + "no_ranked_os": "No platform has both enough samples and a positive failure share", + "breakdown_unavailable": "Failure report data is unavailable; absence cannot establish zero failures.", + "breakdown_availability": "Available observation days: {{available}} / {{total}}; unavailable: {{unavailable}}. Unavailable days are excluded, not filled with zero.", + "breakdown_legacy_days": "{{days}} legacy days lack availability metadata; only positive reported observations are usable.", + "no_reports_in_scope": "No reports observed in this filter; this is not an overall health conclusion." }, "admin_metrics": { "mode_requests": "Requests", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 111d0d6..01e59ab 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -457,95 +457,90 @@ }, "app_insights": { "view_overview": "概览", - "view_versions": "版本漏斗", + "view_versions": "版本事件", "view_traffic": "流量画像", "view_failures": "失败诊断", "days_option": "近 {{days}} 天", - "live": "实时", - "today_live": "今日实时累计", + "live": "每分钟刷新", + "today_live": "今日未完成日桶", "unavailable": "分析数据暂时不可用", "load_failed": "数据加载失败", - "today_requests": "今日查询热更", + "today_requests": "今日查询热更请求", "today_live_hint": "北京时间今日,实时累计", - "today_dau": "今日活跃设备", - "dau_hint": "按设备 uuid 去重", - "window_requests": "近 {{days}} 天查询热更", - "daily_average": "已完成日子日均 {{value}}", - "peak_dau": "近 {{days}} 天峰值活跃设备", - "dau_average": "日均 {{value}}", - "dau": "活跃设备", + "today_dau": "今日查询热更设备", + "dau_hint": "完成更新检查并上报 UUID 的设备估算,不是全部 App 用户", + "window_requests": "近 {{days}} 个日桶观测请求", + "daily_average": "完整日均 {{value}}(可用 {{samples}} / {{days}} 日)", + "peak_dau": "可用日桶中的峰值查询设备", + "dau_average": "完整日均 {{value}}(可用 {{samples}} / {{days}} 日)", + "dau": "查询热更设备", "requests": "请求数", "refusal_title": "有 {{count}} 次请求({{percent}})因客户端信息不匹配而收不到更新", - "refusal_blocked": "{{count}} 次被 build_time_mismatch 拦截:客户端上报的原生包构建时间与服务端登记的不一致。若是同一份原生包重新打包上传,请到「应用发布 → 原生包」重新上传或在应用设置中关闭构建时间校验。", - "refusal_unknown_package": "{{count}} 次请求了未登记的原生包版本:这些设备装的原生包从未上传到 Pushy,永远匹配不到热更。请把对应版本的原生包上传并绑定热更。", + "refusal_blocked": "构建信息不匹配:核对客户端构建与已登记原生包,不应仅为消除告警而关闭校验。", + "refusal_unknown_package": "当前未匹配到已登记的原生包;核对 App Key、版本与原生包上传记录。", "refused_packages": "涉及原生包版本:", "refused_packages_more": "等另外 {{count}} 个版本", - "refused_packages_unattributed": "另有 {{count}} 次来自按版本拆分上线前的数据,无法对应版本", + "refused_packages_unattributed": "另有 {{count}} 次无可用原生包拆分,可能来自历史采集或明细缺失。", "daily_title": "每日趋势", "daily_requests": "请求结果", "daily_requests_question": "每天有多少次查询热更,分别得到了什么结果?", "daily_dau_question": "每天有多少台设备在查询热更?", - "daily_footnote": "按北京时间自然日统计;今日为实时累计(约 1 秒延迟)。最多保留 {{retention}} 天。", + "daily_footnote": "按上方时区的自然日统计;今天尚未结束,缺失设备数据不补零。", "no_traffic": "该时间范围内没有请求数据", "outcome_title": "请求结果构成", "outcome_question": "查询热更的请求里,多少命中了更新、多少被拒绝?", "update_share": "命中更新占比", - "update_share_hint": "下发了 hdiff / pdiff / 整包的请求", + "update_share_hint": "提供了更新选项的请求占比,不是升级成功率", "refused_share": "被拒绝占比", "refused_share_hint": "blocked + 未登记原生包", - "outcome_footnote": "「已是最新」表示客户端已运行绑定的最新热更;「暂停」「过期」是应用或版本状态所致,不是故障。", - "hit_uptodate": "已是最新", - "hit_hdiff": "下发 hdiff 增量", - "hit_pdiff": "下发 pdiff 增量", - "hit_full": "下发整包", - "hit_paused": "应用已暂停", - "hit_expired": "版本已过期", + "outcome_footnote": "未提供更新包括已运行目标版本和未绑定版本;暂停或受限可能来自应用、原生包或额度。提供下载选项不代表客户端已经下载。", + "hit_uptodate": "未提供更新", + "hit_hdiff": "提供 hdiff 选项", + "hit_pdiff": "提供 pdiff 选项", + "hit_full": "提供整包选项", + "hit_paused": "暂停或检查受限", + "hit_expired": "原生包已过期", "hit_blocked": "构建时间不匹配", "hit_unknown_package": "未登记原生包", - "versions_glance_title": "版本速览", - "versions_glance_question": "最近下发的热更版本,各自覆盖了多少设备、是否健康?", + "versions_glance_title": "事件量最高的版本", + "versions_glance_question": "在返回的版本中按窗口内提供目标与客户端事件总量排序,不是按发布时间排序。", "view_all_versions": "查看全部版本 →", - "glance_line": "下发 {{served}} · 激活 {{activated}} · 累计激活率 {{activation}}", + "glance_line": "提供目标 {{served}} 次 · 激活报告 {{activated}} 次 · 下载/Patch 失败 {{failed}} 次 · 回滚 {{rollback}} 次", "coverage": "覆盖率", "no_versions": "该时间范围内没有版本下发或事件数据", - "coverage_footnote": "覆盖率 = 累计激活该版本的设备数 ÷ 今日活跃设备数({{dau}})。激活设备数是累计值,不受天数切换影响。", - "health_healthy": "健康", - "health_warning": "关注", - "health_critical": "异常", "dau_today": "今日活跃设备", "dau_today_hint": "覆盖率的分母,北京时间今日", - "versions_in_window": "窗口内版本数", + "versions_in_window": "有事件的版本数", "window_utc": "UTC {{start}} ~ {{end}}", - "window_served": "窗口内下发次数", + "window_served": "提供目标选项总次数", "window_served_hint": "含增量、整包与灰度", - "window_rollbacks": "窗口内回滚次数", + "window_rollbacks": "回滚报告总次数", "window_rollbacks_hint": "回滚率 ≥5% 标为异常,≥1% 关注", "truncated": "版本数超出上限,只显示按下发与事件量排名前 {{count}} 的版本。", - "funnel_table_title": "版本漏斗", + "funnel_table_title": "版本更新事件", "funnel_table_question": "每个热更版本从下发、下载成功、激活到回滚各有多少,覆盖了多少设备?", "filter_all_versions": "全部版本", "filter_all_packages": "全部原生包", "no_versions_match": "没有匹配筛选条件的版本", "col_version": "热更版本", - "col_health": "健康", - "col_served": "下发", - "col_downloaded": "下载成功", - "col_activated": "激活", + "col_health": "回滚观测", + "col_served": "提供目标次数", + "col_downloaded": "下载成功事件", + "col_activated": "激活成功事件", "col_failures": "失败", - "col_rollback": "回滚", + "col_rollback": "回滚事件", "col_activation_rate": "累计激活率", - "col_rollback_rate": "回滚率", - "col_coverage": "覆盖率", + "col_rollback_rate": "回滚报告占比", "col_package": "原生包", - "col_peak_devices": "单日峰值设备", + "col_peak_devices": "可用观测日中的设备峰值", "col_share": "占比", "col_count": "次数", "served_hdiff": "hdiff 增量", "served_pdiff": "pdiff 增量", "served_full": "整包", "served_full_pending": "整包(增量未生成)", - "served_exp": "灰度", - "funnel_title": "下发 → 下载 → 激活 → 回滚", + "served_exp": "实验版本选项(不代表灰度命中)", + "funnel_title": "独立事件计数", "step_served": "下发", "step_downloaded": "下载成功", "step_activated": "激活", @@ -553,13 +548,13 @@ "funnel_line": "下载成功率 {{downloadSuccess}} · 累计激活率 {{activation}} · 回滚率 {{rollback}}", "by_package_title": "按原生包拆分", "no_package_rows": "没有按原生包的明细", - "adopted_mark": "累计激活设备", - "adopted_download": "累计下载设备", + "adopted_mark": "留存累计激活设备观测", + "adopted_download": "留存累计下载设备观测", "adopted_hint": "HLL 去重,累计值", "coverage_no_dau": "今日暂无活跃设备,无法计算覆盖率", "coverage_of_dau": "占今日活跃设备 {{percent}}", - "lag_download": "发布后多久下载成功", - "lag_mark": "发布后多久激活", + "lag_download": "版本创建后下载成功报告到达时间", + "lag_mark": "版本创建后激活成功报告到达时间", "lag_total": "(共 {{count}} 次)", "no_lag": "暂无数据", "lag_lt1h": "1 小时内", @@ -568,17 +563,17 @@ "lag_1d_3d": "1–3 天", "lag_3d_7d": "3–7 天", "lag_gt7d": "7 天以上", - "lag_footnote": "时延分布为累计值,从版本发布时刻算起,不受天数切换影响。", + "lag_footnote": "按报告次数分布,不是首次激活设备数;起点是版本创建,不是绑定发布时间或下载开始。时间桶 90 天不活动可过期重计。", "funnel_footnote": "窗口按 UTC 日;{{hourlyFrom}} 起读实时小时桶。「下发」是服务端实际下发次数。累计激活率 = 累计激活设备 ÷ 累计下载设备(都按设备去重、不分日),所以它不随天数切换变化。", "package_filter_note": "按原生包筛选时,累计激活设备与时延分布仍是该版本的整体值。", "series_question": "最近几个小时的查询热更曲线,按热更包或原生包拆分。", "series_footnote": "5 分钟粒度;图例默认只勾选 Top 10。", "hourly_title": "小时分布", "hourly_question": "近 {{days}} 天里,一天中哪些时段请求最多?", - "hourly_footnote": "按北京时间小时累加窗口内各天。", + "hourly_footnote": "按上方业务时区小时累加,使用独立于请求总量的小时观测。包含尚未结束的今天,各小时可观测时长可能不同。", "packages_title": "原生包", - "packages_question": "用户装的是哪些原生包版本?", - "packages_footnote": "设备数按设备 uuid 去重,跨日不能相加,这里取单日峰值;「-」表示没有 uuid 或超出跟踪上限。", + "packages_question": "哪些原生包在查询更新?请求占比不是装机占比。", + "packages_footnote": "请求保留 {{requests}} 天,设备观测保留 {{devices}} 天。峰值仅来自可用日,不代表整个所选窗口;没有设备观测不能解释为无人使用。", "carriers_title": "运营商", "carriers_question": "请求来自哪些网络运营商?", "carriers_footnote": "按客户端 IP 归属判断;中国大陆之外统一记为「其他地区」。", @@ -600,17 +595,17 @@ "ip_unknown": "未知", "ip_footnote": "按客户端 IP 判断。", "failures_intro": "下载、Patch 与回滚事件为什么会失败,集中在哪些版本、平台和网络?", - "failure_events": "近 {{days}} 天失败事件", - "failure_events_hint": "下载失败 + Patch 失败 + 回滚", + "failure_events": "近 {{days}} 日桶已分类失败事件", + "failure_events_hint": "来自原因分类聚合:下载失败、Patch 失败和回滚;不是失败设备数。", "top_reason": "最主要原因", "top_reason_hint": "占失败事件 {{percent}}", "no_failures": "该时间范围内没有失败事件", - "worst_os": "失败率最高的平台", + "worst_os": "失败事件占比最高的平台(至少 10 个报告)", "worst_os_hint": "下载/Patch 失败率 {{percent}}", "no_os_rows": "暂无平台数据", "reasons_title": "失败原因", "reasons_question": "失败事件按原因分类,各自涉及哪些版本?", - "reasons_footnote": "原因由客户端上报的错误信息归类;「其他」保留错误信息前缀。回滚事件通常不带错误信息,会落到「未提供原因」。", + "reasons_footnote": "原因由上报内容启发式归类;其他不展示自由文本前缀。未提供原因不是具体根因。各维度独立聚合,可能因采集遗漏而不完全对齐。", "col_reason": "原因", "col_event_type": "事件类型", "col_versions": "涉及版本", @@ -628,22 +623,57 @@ "reason_zip": "解压失败", "reason_bundle_mismatch": "Bundle 不匹配", "reason_other": "其他", - "reason_other_detail": "其他:{{detail}}", "os_title": "按平台", "os_question": "各平台的失败率和回滚率是否有差异?", "col_os": "平台", - "col_failure_rate": "失败率", - "rates_footnote": "失败率 = (下载失败 + Patch 失败) ÷ (下载成功 + 下载失败 + Patch 失败);回滚率 = 回滚 ÷ (启动成功 + 回滚)。", + "col_failure_rate": "下载/Patch 失败事件占比", + "rates_footnote": "失败事件占比 = (下载失败 + Patch 失败) ÷ (下载成功 + 下载失败 + Patch 失败)。回滚占比 = 回滚 ÷ (激活成功 + 回滚)。括号显示分子/分母;均不是最终升级失败率。", "no_events": "该时间范围内没有事件数据", "carrier_events_title": "按运营商", "carrier_events_question": "失败是否集中在某个运营商的网络?", "carrier_no_version_filter": "运营商维度不区分版本,请选择「全部版本」查看。", - "breakdown_footnote": "按北京时间自然日统计,最多保留 {{retention}} 天。事件仅来自 SDK v10.47.0 及以上版本。", - "version_deleted": "已删除的版本", - "version_deleted_hint": "该热更版本已被删除,仅保留 hash", + "breakdown_footnote": "按上方业务时区日桶,最多保留 {{retention}} 天;仅包含支持遥测并成功上报的 SDK 事件,不能代表所有设备。", + "version_deleted": "未命名或已删除的版本", + "version_deleted_hint": "当前未解析到名称,不能仅凭空名称确认已删除。", "copy_hash": "复制完整 hash", "page_footnote": "所有视图共用上方的天数;「今日」为北京时间今日实时累计。版本漏斗按 UTC 日、其余按北京时间自然日。", - "col_carrier": "运营商" + "col_carrier": "运营商", + "means_note": "两个日均都排除今天;有效零值计入,缺失值排除。可用日数不代表采集完整。", + "window_exact": "{{timezone}}:{{start}} 至 {{end}}(起含止不含)", + "window_utc_legacy": "旧接口:版本事件按 UTC 自然日;未提供精确窗口元数据。", + "window_business_legacy": "旧接口未提供业务时区与可用性元数据;今日标记暂按 UTC+8,含义不明的零设备数已排除,不作为有效零值。", + "last_refreshed": "最近成功读取:{{time}};页面每分钟刷新", + "not_loaded": "尚未读取到数据", + "stale_data": "刷新失败,以下为上次成功读取的数据", + "best_effort": "以下为尽力采集的运行观测,可能重复或遗漏;无数据不等于没有设备、没有故障。", + "no_observations": "没有可用观测,不能据此判断为零或正常", + "observed_glance": "留存累计激活设备观测", + "col_download_fail": "下载失败事件", + "col_patch_fail": "Patch 失败事件", + "events_not_funnel": "各项是未关联的事件次数,不是转化漏斗。一次响应可提供主版本和实验选项;Patch 失败后整包成功会产生两类报告。", + "retained_title": "留存累计设备与事件到达分布", + "retained_scope": "整个热更版本的累计观测,不受天数筛选影响;不是当前正在运行该版本的设备。", + "retained_unavailable_package": "累计设备和时间分布没有原生包维度,已在原生包筛选下隐藏。", + "retained_hint": "HLL 近似去重;35 天不活动可过期重计,缺失 UUID 或报告可能导致遗漏。", + "rollback_low": "回滚报告占比较低", + "rollback_warning": "回滚报告占比需关注", + "rollback_high": "回滚报告占比较高", + "insufficient_samples": "回滚观测样本不足({{count}} / {{minimum}})", + "rollback_only": "仅描述回滚报告:回滚 ÷(激活成功 + 回滚),不代表整体健康。达到 10 个报告后按 1% / 5% 提示。", + "totals_all": "应用整体观测汇总(不受下方筛选或前 50 个版本截断影响)", + "totals_returned": "旧接口:仅返回版本的小计,不是应用整体总量", + "totals_unattributed": "其中无法归属版本:提供目标 {{offers}} 次,客户端报告 {{reports}} 次(已包含在汇总中)", + "filtered_note": "下表仅显示当前版本/原生包筛选;上方汇总仍为应用整体或返回版本小计。", + "request_share": "请求占比", + "col_availability": "设备观测范围与状态", + "package_observed_days": "可用 {{count}} 日:{{start}} 至 {{end}}", + "package_missing_days": "过期 {{expired}} 日;无可用观测 {{missing}} 日", + "package_partial": "触及采集限制,部分数据", + "no_ranked_os": "没有达到样本门槛且失败占比大于零的平台", + "breakdown_unavailable": "失败事件数据不可用,不能据此判断为零失败。", + "breakdown_availability": "可用观测日 {{available}} / {{total}};不可用 {{unavailable}} 日。不可用日已排除,不补零。", + "breakdown_legacy_days": "{{days}} 个旧接口日桶缺少可用性状态;仅保留有上报证据的观测。", + "no_reports_in_scope": "当前筛选范围未观测到报告,不代表整体健康。" }, "admin_metrics": { "mode_requests": "请求数", diff --git a/src/i18n/resources.ts b/src/i18n/resources.ts new file mode 100644 index 0000000..1079d1e --- /dev/null +++ b/src/i18n/resources.ts @@ -0,0 +1,8 @@ +import en from './locales/en.json'; +import zhCN from './locales/zh-CN.json'; + +// Canonical JSON catalogs are the single source for runtime and validation. +export const resources = { + en: { translation: en }, + 'zh-CN': { translation: zhCN }, +}; diff --git a/src/pages/admin-service-status/version-health-overview-panel.tsx b/src/pages/admin-service-status/version-health-overview-panel.tsx index 542bda5..2df9fa5 100644 --- a/src/pages/admin-service-status/version-health-overview-panel.tsx +++ b/src/pages/admin-service-status/version-health-overview-panel.tsx @@ -4,19 +4,19 @@ import { Card, Space, Table, Tag, Typography } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router'; +import { + CRITICAL_ROLLBACK, + MIN_EVENT_SAMPLES, + WARNING_ROLLBACK, +} from '@/constants/metric-thresholds'; import { adminApi } from '@/services/admin-api'; import { serviceStatusKeys } from '@/utils/query-keys'; import { formatCount, formatPercent } from './metrics'; const { Text } = Typography; -// 阈值:回滚率 >5% 异常、>1% 关注;样本太少(<10)不判定 -const CRITICAL_ROLLBACK = 0.05; -const WARNING_ROLLBACK = 0.01; -const MIN_SAMPLES = 10; - function healthTag(row: VersionHealthOverviewRow, t: (key: string) => string) { - if (row.startSamples < MIN_SAMPLES || row.rollbackRate === null) { + if (row.startSamples < MIN_EVENT_SAMPLES || row.rollbackRate === null) { return null; } if (row.rollbackRate >= CRITICAL_ROLLBACK) { diff --git a/src/pages/app-insights/failures-panel.tsx b/src/pages/app-insights/failures-panel.tsx index 99fbd03..dd8a083 100644 --- a/src/pages/app-insights/failures-panel.tsx +++ b/src/pages/app-insights/failures-panel.tsx @@ -5,14 +5,19 @@ import { useTranslation } from 'react-i18next'; import { type DimensionRow, FAILURE_EVENT_TYPES, + highestFailureDimension, type ReasonRow, summarizeBreakdown, } from './logic'; +import { + BreakdownAvailability, + ObservationNotice, + ReportShare, +} from './observation-ui'; import { EmptyState, Footnote, formatInteger, - formatPercent, formatShare, InsightsError, Question, @@ -23,62 +28,67 @@ import { useFailureReasonLabel, VersionLabel, } from './shared'; -import { CLIENT_EVENT_TYPES } from './types'; +import { CLIENT_EVENT_TYPES, type ClientEventType } from './types'; const ALL = '__all__'; -const REASON_VERSION_LIMIT = 3; - const DimensionTable = ({ rows, labelOf, keyTitle, + emptyText, }: { rows: DimensionRow[]; labelOf: (key: string) => string; keyTitle: string; + emptyText: string; }) => { const { t } = useTranslation(); const eventLabel = useEventTypeLabel(); const columns: ColumnsType = [ - { - title: keyTitle, - key: 'key', - render: (_, row) => labelOf(row.key), - }, + { title: keyTitle, key: 'key', render: (_, row) => labelOf(row.key) }, ...CLIENT_EVENT_TYPES.map((type) => ({ title: eventLabel(type), key: type, align: 'right' as const, - render: (_: unknown, row: DimensionRow) => - row.counts[type] > 0 && FAILURE_EVENT_TYPES.has(type) ? ( - - {formatInteger(row.counts[type])} - - ) : ( - formatInteger(row.counts[type]) - ), + render: (_: unknown, row: DimensionRow) => ( + 0 + ? 'text-red-500' + : undefined + } + > + {formatInteger(row.counts[type])} + + ), })), { title: t('app_insights.col_failure_rate'), - key: 'failureRate', - align: 'right', - render: (_, row) => formatPercent(row.failureRate), + key: 'failureShare', + render: (_, row) => ( + + ), }, { title: t('app_insights.col_rollback_rate'), - key: 'rollbackRate', - align: 'right', - render: (_, row) => formatPercent(row.rollbackRate), + key: 'rollbackShare', + render: (_, row) => ( + + ), }, ]; return ( ); }; @@ -95,8 +105,7 @@ export const FailuresPanel = ({ const carrierLabel = useCarrierLabel(); const eventLabel = useEventTypeLabel(); const breakdown = useAppEventBreakdown(appKey, days); - const [versionFilter, setVersionFilter] = useState(ALL); - + const [versionFilter, setVersionFilter] = useState(ALL); const all = useMemo( () => summarizeBreakdown(breakdown.data?.days), [breakdown.data], @@ -108,54 +117,31 @@ export const FailuresPanel = ({ : summarizeBreakdown(breakdown.data?.days, versionFilter), [all, breakdown.data, versionFilter], ); - - const versionOptions = useMemo( - () => [ - { value: ALL, label: t('app_insights.filter_all_versions') }, - ...Array.from(all.versionNames.entries()).map(([hash, name]) => ({ - value: hash, - label: name - ? `${name} (${hash.slice(0, 8)})` - : `${t('app_insights.version_deleted')} (${hash.slice(0, 8)})`, - })), - ], - [all.versionNames, t], + const hasAvailableDays = summary.availableDays > 0; + const emptyText = t( + hasAvailableDays + ? 'app_insights.no_reports_in_scope' + : 'app_insights.breakdown_unavailable', ); - + const highest = highestFailureDimension(summary.os); + const topReason = summary.reasons[0]; const reasonColumns: ColumnsType = [ { title: t('app_insights.col_reason'), key: 'reason', - render: (_, row) => ( - {reasonLabel(row.reason)} - ), + render: (_, row) => reasonLabel(row.reason), }, { title: t('app_insights.col_count'), - key: 'count', + dataIndex: 'count', align: 'right', - width: 100, - render: (_, row) => ( - {formatInteger(row.count)} - ), + render: formatInteger, }, { title: t('app_insights.col_share'), - key: 'share', - width: 180, - render: (_, row) => ( -
- - - - - {formatShare(row.percent)} - -
- ), + dataIndex: 'percent', + align: 'right', + render: formatShare, }, { title: t('app_insights.col_event_type'), @@ -163,9 +149,8 @@ export const FailuresPanel = ({ render: (_, row) => ( {Object.entries(row.byType).map(([type, count]) => ( - - {eventLabel(type as keyof typeof row.byType)}{' '} - {formatInteger(count)} + + {eventLabel(type as ClientEventType)} {formatInteger(count)} ))} @@ -175,161 +160,135 @@ export const FailuresPanel = ({ title: t('app_insights.col_versions'), key: 'versions', render: (_, row) => ( - - {row.versions.slice(0, REASON_VERSION_LIMIT).map((version) => ( - - - - {formatInteger(version.count)} - - +
+ {row.versions.slice(0, 3).map((version) => ( +
+ {' '} + {formatInteger(version.count)} +
))} - {row.versions.length > REASON_VERSION_LIMIT && ( - + {row.versions.length > 3 && ( + {t('app_insights.more_versions', { - count: row.versions.length - REASON_VERSION_LIMIT, + count: row.versions.length - 3, })} )} - +
), }, ]; - - const topReason = summary.reasons[0]; - const worstOs = [...summary.os] - .filter((row) => row.failureRate !== null) - .sort( - (left, right) => (right.failureRate ?? 0) - (left.failureRate ?? 0), - )[0]; - return ( -
- {breakdown.error ? : null} +
+ {!!breakdown.error && } + +
{t('app_insights.failures_intro')}
10 ? { pageSize: 10 } : false } scroll={{ x: 'max-content' }} /> + ) : ( + {breakdown.isLoading ? '' : emptyText} )} {t('app_insights.reasons_footnote')} - - {t('app_insights.os_question')} - - {summary.os.length === 0 ? ( - - {breakdown.isLoading ? '' : t('app_insights.no_events')} - - ) : ( - os} - keyTitle={t('app_insights.col_os')} - /> - )} - + key} + /> {t('app_insights.rates_footnote')} - - {t('app_insights.carrier_events_question')} - {versionFilter !== ALL ? ( - {t('app_insights.carrier_no_version_filter')} + {versionFilter === ALL ? ( + ) : ( - - {summary.carriers.length === 0 ? ( - - {breakdown.isLoading ? '' : t('app_insights.no_events')} - - ) : ( - - )} - + {t('app_insights.carrier_no_version_filter')} )} - - {t('app_insights.breakdown_footnote', { - retention: breakdown.data?.retentionDays ?? 35, - })} - + {t('app_insights.rates_footnote')} + + {t('app_insights.breakdown_footnote', { + retention: breakdown.data?.retentionDays ?? 35, + })} + ); }; diff --git a/src/pages/app-insights/logic.test.ts b/src/pages/app-insights/logic.test.ts index 9c2f8a5..995b464 100644 --- a/src/pages/app-insights/logic.test.ts +++ b/src/pages/app-insights/logic.test.ts @@ -1,13 +1,13 @@ import { describe, expect, it } from 'bun:test'; - -const near = (value: number | null | undefined, expected: number) => - expect(Math.abs((value ?? Number.NaN) - expected) < 1e-9).toBe(true); - import { beijingToday, buildFunnelRows, computeFunnelRates, + filterFunnelRows, + highestFailureDimension, + isKnownCarrier, lagShares, + observationCount, parseFailureReason, parseInsightDays, parseInsightView, @@ -15,16 +15,58 @@ import { summarizeBreakdown, summarizeTraffic, trafficWarnings, + versionTotals, } from './logic'; import type { AppEventBreakdownDay, AppTrafficDay, + FunnelEventCounts, + VersionFunnel, VersionFunnelResponse, } from './types'; -const trafficDay = ( - overrides: Partial & { date: string }, +const events = ( + overrides: Partial = {}, +): FunnelEventCounts => ({ + downloadSuccess: 0, + downloadFail: 0, + patchFail: 0, + markSuccess: 0, + rollback: 0, + ...overrides, +}); +const offered = { hdiff: 0, pdiff: 0, full: 6, fullPending: 0, exp: 0 }; +const version = (): VersionFunnel => ({ + hash: 'v1', + name: 'Version 1', + served: offered, + events: events({ markSuccess: 5 }), + adopted: { mark: 5, download: 5 }, + lag: { downloadSuccess: { lt1h: 5 }, markSuccess: { lt1h: 5 } }, + byPackage: [ + { + packageVersion: '1.0', + served: { ...offered, full: 2 }, + events: events({ markSuccess: 2 }), + }, + ], +}); +const response = ( + overrides: Partial = {}, +): VersionFunnelResponse => ({ + days: 7, + start: '2026-09-21', + end: '2026-09-27', + hourlyFrom: '2026-09-25', + dauToday: 2, + versions: [version()], + ...overrides, +}); +const day = ( + date: string, + overrides: Partial = {}, ): AppTrafficDay => ({ + date, requests: 0, dau: 0, hourly: [], @@ -35,383 +77,365 @@ const trafficDay = ( packages: [], ...overrides, }); +const breakdown = ( + overrides: Partial = {}, +): AppEventBreakdownDay => ({ + date: '2026-09-26', + byOS: [], + byReason: [], + byCarrier: [], + ...overrides, +}); -describe('URL params', () => { - it('falls back to defaults for unknown values', () => { +describe('parameters and basic aggregation', () => { + it('keeps URL compatibility and defaults', () => { expect(parseInsightDays('14')).toBe(14); expect(parseInsightDays('9')).toBe(7); - expect(parseInsightDays(null)).toBe(7); - expect(parseInsightView('failures')).toBe('failures'); - expect(parseInsightView('nope')).toBe('overview'); + expect(parseInsightView('versions')).toBe('versions'); + expect(parseInsightView(null)).toBe('overview'); + expect(beijingToday(Date.UTC(2026, 8, 26, 20))).toBe('2026-09-27'); }); - - it('derives today in Beijing time', () => { - // 2026-09-11T20:00Z 已经是北京时间 12 日 - expect(beijingToday(Date.UTC(2026, 8, 11, 20))).toBe('2026-09-12'); - expect(beijingToday(Date.UTC(2026, 8, 11, 15))).toBe('2026-09-11'); - }); -}); - -describe('rankCounts', () => { - it('sorts by count desc, then by name, dropping empty entries', () => { - expect(rankCounts({ b: 2, a: 2, c: 5, d: 0, e: Number.NaN })).toEqual([ - { key: 'c', count: 5, percent: (5 / 9) * 100 }, - { key: 'a', count: 2, percent: (2 / 9) * 100 }, - { key: 'b', count: 2, percent: (2 / 9) * 100 }, + it('ranks positive counts deterministically and drops invalid values', () => { + expect(rankCounts({ b: 2, a: 2, c: -1, d: Number.NaN })).toEqual([ + { key: 'a', count: 2, percent: 50 }, + { key: 'b', count: 2, percent: 50 }, ]); expect(rankCounts(undefined)).toEqual([]); }); }); -describe('summarizeTraffic', () => { - const days: AppTrafficDay[] = [ - trafficDay({ - date: '2026-09-12', - requests: 100, - dau: 40, - hourly: [5, 95], - hit: { uptodate: 80, hdiff: 10, blocked: 10 }, - ipVersion: { v4: 90, v6: 10 }, - hosts: { 'a.example': 100 }, - carriers: { 电信: 60, unknown: 40 }, - packages: [{ packageVersion: '1.0', requests: 100, devices: 30 }], - refused: [{ outcome: 'blocked', packageVersion: '1.0', requests: 10 }], - }), - trafficDay({ - date: '2026-09-11', - requests: 200, - dau: 60, - hourly: [10], - hit: { uptodate: 150, pdiff: 40, unknown_package: 10 }, - ipVersion: { v4: 200 }, - hosts: { 'a.example': 150, 'b.example': 50 }, - carriers: { 电信: 200 }, - packages: [ - { packageVersion: '1.0', requests: 150, devices: 50 }, - { packageVersion: '0.9', requests: 50, devices: 0 }, +describe('complete-day means and missing observations', () => { + it('includes valid zero days in both means, excludes today and future days', () => { + const days = Array.from({ length: 7 }, (_, i) => + day(`2026-09-${20 + i}`, { + requests: i === 0 ? 100 : 0, + dau: i === 0 ? 100 : 0, + requestsStatus: 'observed', + dauStatus: 'observed', + }), + ); + const result = summarizeTraffic( + [ + ...days, + day('2026-09-27', { requests: 999, dau: 999 }), + day('2026-09-28', { requests: 999, dau: 999 }), ], - refused: [ - { outcome: 'unknown_package', packageVersion: '0.8', requests: 4 }, - { outcome: 'unknown_package', packageVersion: '0.7', requests: 6 }, + '2026-09-27', + ); + expect(result.averageDau).toBe(100 / 7); + expect(result.averageDailyRequests).toBe(100 / 7); + expect(result.dauSampleDays).toBe(7); + expect(result.requestSampleDays).toBe(7); + expect(result.completedDays).toBe(7); + }); + it('does not turn unavailable days into observed zeroes', () => { + const result = summarizeTraffic( + [ + day('2026-09-24', { + requests: 10, + dau: 5, + requestsStatus: 'observed', + dauStatus: 'observed', + }), + day('2026-09-25', { + requestsStatus: 'unavailable', + dauStatus: 'unavailable', + }), + day('2026-09-26', { + requestsStatus: 'observed', + dauStatus: 'observed', + }), ], - }), - trafficDay({ date: '2026-09-10', requests: 0, dau: 0 }), - ]; + '2026-09-27', + ); + expect(result.averageDailyRequests).toBe(5); + expect(result.averageDau).toBe(2.5); + expect(result.dauSampleDays).toBe(2); + expect(result.completedDays).toBe(3); + expect(result.daily[1]?.dau).toBeNull(); + expect(observationCount(9, 'unavailable')).toBeNull(); + expect(observationCount(0, 'observed')).toBe(0); + expect(observationCount(Number.NaN)).toBeNull(); + }); + it('respects a supplied business today instead of browser or Beijing date', () => { + const result = summarizeTraffic( + [day('2026-09-26', { dau: 9 })], + '2026-09-26', + ); + expect(result.today?.dau).toBe(9); + expect(result.averageDau).toBeNull(); + }); + it('is empty safe without fabricating means or device peaks', () => { + const result = summarizeTraffic(undefined); + expect(result.averageDau).toBeNull(); + expect(result.averageDailyRequests).toBeNull(); + expect(result.peakDau).toBeNull(); + expect(result.today).toBeNull(); + expect(result.hourly).toHaveLength(24); + }); + it('keeps legacy positive device observations but does not invent observed zeroes', () => { + const result = summarizeTraffic( + [day('2026-09-25', { dau: 10 }), day('2026-09-26')], + '2026-09-27', + ); + expect(result.averageDau).toBe(10); + expect(result.dauSampleDays).toBe(1); + expect(result.daily[1]?.dau).toBeNull(); + }); +}); - it('folds the window into totals, shares and rankings', () => { - const summary = summarizeTraffic(days, '2026-09-12'); - expect(summary.requests).toBe(300); - expect(summary.today?.date).toBe('2026-09-12'); - expect(summary.today?.requests).toBe(100); - // 日均只算已完成的日子(含请求为 0 的那天) - expect(summary.completedDays).toBe(2); - expect(summary.averageDailyRequests).toBe(100); - expect(summary.peakDau).toBe(60); - expect(summary.averageDau).toBe(50); - expect(summary.hit.uptodate).toBe(230); - expect(summary.hit.blocked).toBe(10); - expect(summary.hit.unknown_package).toBe(10); - near(summary.refusedPercent, (20 / 300) * 100); - near(summary.updatePercent, (50 / 300) * 100); - expect(summary.hourly[0]).toBe(15); - expect(summary.hourly[1]).toBe(95); - expect(summary.hourly[23]).toBe(0); - expect(summary.ipVersion[0]).toEqual({ - key: 'v4', - count: 290, - percent: (290 / 300) * 100, - }); - expect(summary.hosts.map((item) => item.key)).toEqual([ - 'a.example', - 'b.example', - ]); - expect(summary.carriers[0]).toMatchObject({ key: '电信', count: 260 }); - // 设备数不能跨日相加,取单日峰值 - expect(summary.packages).toEqual([ - { - packageVersion: '1.0', - requests: 250, - peakDevices: 50, - percent: (250 / 300) * 100, - }, - { - packageVersion: '0.9', - requests: 50, - peakDevices: 0, - percent: (50 / 300) * 100, - }, - ]); - expect(summary.refused).toEqual({ - blocked: [{ packageVersion: '1.0', requests: 10 }], - unknown_package: [ - { packageVersion: '0.7', requests: 6 }, - { packageVersion: '0.8', requests: 4 }, +describe('package observations and refusals', () => { + it('reports partial and expired data rather than a whole-window device count', () => { + const result = summarizeTraffic( + [ + day('2026-09-01', { + requests: 100, + packages: [ + { + packageVersion: '1', + requests: 100, + devices: null, + devicesStatus: 'expired', + }, + ], + }), + day('2026-09-26', { + requests: 20, + packageDevicesLimited: true, + packages: [ + { + packageVersion: '1', + requests: 20, + devices: 3, + devicesStatus: 'partial', + }, + ], + }), + day('2026-09-27', { + requests: 10, + packages: [ + { + packageVersion: '1', + requests: 10, + devices: 2, + devicesStatus: 'observed', + }, + ], + }), ], + '2026-09-27', + ); + expect(result.packages[0]).toMatchObject({ + requests: 130, + peakDevices: 3, + percent: 100, + observedDays: 2, + availableStart: '2026-09-26', + availableEnd: '2026-09-27', + partial: true, + expiredDays: 1, }); - expect(summary.daily.map((day) => day.date)).toEqual([ - '2026-09-10', - '2026-09-11', - '2026-09-12', - ]); - expect(summary.daily[2]?.isToday).toBe(true); }); - - it('is empty-safe', () => { - const summary = summarizeTraffic(undefined); - expect(summary.requests).toBe(0); - expect(summary.today).toBeNull(); - expect(summary.averageDailyRequests).toBe(0); - expect(summary.hourly).toHaveLength(24); - expect(summary.packages).toEqual([]); - expect(summary.refused).toEqual({ blocked: [], unknown_package: [] }); + it('does not interpret missing or legacy zero device estimates as no users', () => { + const result = summarizeTraffic([ + day('2026-09-26', { + packages: [{ packageVersion: 'old', requests: 3, devices: 0 }], + }), + ]); + expect(result.packages[0]?.peakDevices).toBeNull(); + expect(result.packages[0]?.unavailableDays).toBe(1); }); - - it('flags the refusals that explain missing updates', () => { - const summary = summarizeTraffic(days, '2026-09-12'); - expect(trafficWarnings(summary.hit)).toEqual([ - 'blocked', - 'unknown_package', + it('retains refusal detail and request-based shares', () => { + const result = summarizeTraffic([ + day('2026-09-26', { + requests: 10, + hit: { blocked: 2, unknown_package: 1, full: 7 }, + refused: [{ outcome: 'blocked', packageVersion: '1.0', requests: 2 }], + hourly: [3, 7], + carriers: { 电信: 10 }, + }), + ]); + expect(trafficWarnings(result.hit)).toEqual(['blocked', 'unknown_package']); + expect(result.updatePercent).toBe(70); + expect(result.refusedPercent).toBe(30); + expect(result.refused.blocked).toEqual([ + { packageVersion: '1.0', requests: 2 }, ]); - expect(trafficWarnings(summarizeTraffic([]).hit)).toEqual([]); + expect(result.carriers[0]?.percent).toBe(100); }); }); -describe('funnel', () => { - it('computes rates and health from event counts', () => { - const rates = computeFunnelRates({ - downloadSuccess: 100, - downloadFail: 5, - patchFail: 3, - markSuccess: 90, - rollback: 10, - }); - near(rates.downloadSuccessRate, 100 / 105); - near(rates.rollbackRate, 0.1); - expect(rates.failures).toBe(8); - expect(rates.health).toBe('critical'); - - expect( - computeFunnelRates({ - downloadSuccess: 0, - downloadFail: 0, - patchFail: 0, - markSuccess: 3, - rollback: 1, +describe('independent version observations', () => { + it('never derives coverage or adoption conversion from 5 retained UUIDs and 2 DAU', () => { + const row = buildFunnelRows(response())[0]!; + expect(row.retained?.mark).toBe(5); + expect(row.servedTotal).toBe(6); + expect('coverage' in row).toBe(false); + expect('adoptionRate' in row).toBe(false); + }); + it('honors explicit null observations rather than falling back to adopted', () => { + const row = buildFunnelRows( + response({ + versions: [{ ...version(), observed: { mark: null, download: null } }], }), - ).toMatchObject({ - downloadSuccessRate: null, - // 样本不足不判定 - health: null, - }); - expect( - computeFunnelRates({ - downloadSuccess: 50, - downloadFail: 0, - patchFail: 0, - markSuccess: 50, - rollback: 0, - }).health, - ).toBe('healthy'); + )[0]!; + expect(row.retained?.mark).toBeNull(); + expect(row.retained?.download).toBeNull(); }); - - it('adds served totals, coverage and the deleted flag per version', () => { - const response: VersionFunnelResponse = { - days: 7, - start: '2026-09-05', - end: '2026-09-11', - hourlyFrom: '2026-09-10', - dauToday: 200, - versions: [ - { - hash: 'abc', - name: '1.2.3', - served: { hdiff: 10, pdiff: 5, full: 1, fullPending: 2, exp: 0 }, - events: { - downloadSuccess: 15, - downloadFail: 0, - patchFail: 0, - markSuccess: 12, - rollback: 0, - }, - adopted: { mark: 50, download: 60 }, - lag: { downloadSuccess: { lt1h: 10 }, markSuccess: null }, - byPackage: [], - }, - { - hash: 'def', - name: null, - served: { hdiff: 0, pdiff: 0, full: 0, fullPending: 0, exp: 0 }, - events: { - downloadSuccess: 0, - downloadFail: 0, - patchFail: 0, - markSuccess: 0, - rollback: 0, - }, - adopted: { mark: 0, download: 0 }, - lag: { downloadSuccess: null, markSuccess: null }, - byPackage: [], + it('hides whole-version observations under native-package filters without mutation', () => { + const original = buildFunnelRows(response()); + const filtered = filterFunnelRows(original, 'v1', '1.0')[0]!; + expect(filtered.retained).toBeNull(); + expect(filtered.events.markSuccess).toBe(2); + expect(filtered.servedTotal).toBe(2); + expect(original[0]?.retained?.mark).toBe(5); + expect(filterFunnelRows(original, undefined, 'missing')).toEqual([]); + expect(filterFunnelRows(original, 'missing')).toEqual([]); + }); + it('uses uncapped app summaries including unattributed events', () => { + const result = versionTotals( + response({ + truncated: true, + summary: { + versionCount: 51, + offeredTargets: 52, + events: events({ rollback: 7 }), + unattributed: events({ rollback: 7 }), + unattributedOffers: 0, }, - ], - }; - const rows = buildFunnelRows(response); - expect(rows[0]).toMatchObject({ - servedTotal: 18, - coverage: 0.25, - deleted: false, - }); - // 累计激活率用两个累计设备数,与窗口无关,也不会结构性地超过 100% - near(rows[0]?.adoptionRate, 50 / 60); - expect(rows[1]).toMatchObject({ - servedTotal: 0, - deleted: true, - adoptionRate: null, - }); + }), + ); + expect(result?.versionCount).toBe(51); + expect(result?.events.rollback).toBe(7); + expect(result?.scope).toBe('all_observed'); + }); + it('labels legacy sums as returned-version subtotals', () => { + const result = versionTotals(response({ truncated: true })); + expect(result?.scope).toBe('returned_versions'); + expect(result?.offeredTargets).toBe(6); + expect(result?.unattributed).toBeNull(); + expect(versionTotals(undefined)).toBeNull(); + }); + it('classifies only rollback reports and exposes sample counts separately', () => { + const result = computeFunnelRates( + events({ downloadFail: 90, markSuccess: 10 }), + ); + expect(result.rollbackSamples).toBe(10); + expect(result.failures).toBe(90); + expect(result.rollbackRate).toBe(0); expect( - buildFunnelRows({ ...response, dauToday: 0 })[0]?.coverage, + computeFunnelRates(events({ markSuccess: 3, rollback: 1 })).health, ).toBeNull(); - expect(buildFunnelRows(undefined)).toEqual([]); + expect( + computeFunnelRates(events({ markSuccess: 90, rollback: 10 })).health, + ).toBe('critical'); }); - - it('expands lag buckets in fixed order with shares', () => { - const { total, shares } = lagShares({ lt1h: 30, '6h-24h': 10 }); - expect(total).toBe(40); - expect(shares.map((share) => share.bucket)).toEqual([ - 'lt1h', - '1h-6h', - '6h-24h', - '1d-3d', - '3d-7d', - 'gt7d', - ]); - expect(shares[0]?.percent).toBe(75); - expect(shares[1]?.count).toBe(0); + it('keeps lag reports distinct and discards invalid values', () => { + const result = lagShares({ lt1h: 30, '6h-24h': 10, gt7d: Number.NaN }); + expect(result.total).toBe(40); + expect(result.shares[0]?.percent).toBe(75); + expect(result.shares).toHaveLength(6); expect(lagShares(null).total).toBe(0); }); }); -describe('summarizeBreakdown', () => { - const days: AppEventBreakdownDay[] = [ - { - date: '2026-09-12', - byOS: [ - { - type: 'download_success', - hash: 'v1', - name: '1.0', - os: 'android', - count: 90, - }, - { - type: 'download_fail', - hash: 'v1', - name: '1.0', - os: 'android', - count: 10, - }, - { type: 'mark_success', hash: 'v1', name: '1.0', os: 'ios', count: 40 }, - { type: 'rollback', hash: 'v2', name: null, os: 'ios', count: 2 }, - ], - byReason: [ - { - type: 'download_fail', - hash: 'v1', - name: '1.0', - reason: 'network', - count: 8, - }, - { - type: 'download_fail', - hash: 'v2', - name: null, - reason: 'network', - count: 1, - }, - { - type: 'patch_fail', - hash: 'v1', - name: '1.0', - reason: 'other:ENOENT', - count: 3, - }, - ], - byCarrier: [ - { type: 'download_success', carrier: '电信', count: 50 }, - { type: 'download_fail', carrier: '电信', count: 5 }, - ], - }, - { - date: '2026-09-11', - byOS: [], - byReason: [ - { - type: 'download_fail', - hash: 'v1', - name: '1.0', - reason: 'timeout', - count: 4, - }, - ], - byCarrier: [], - }, - ]; - - it('ranks reasons with per-version detail and rates per dimension', () => { - const summary = summarizeBreakdown(days); - expect(summary.failures).toBe(16); - expect(summary.reasons.map((row) => [row.reason, row.count])).toEqual([ - ['network', 9], - ['timeout', 4], - ['other:ENOENT', 3], - ]); - near(summary.reasons[0]?.percent, (9 / 16) * 100); - expect(summary.reasons[0]?.byType).toEqual({ download_fail: 9 }); - expect(summary.reasons[0]?.versions).toEqual([ - { hash: 'v1', name: '1.0', count: 8 }, - { hash: 'v2', name: null, count: 1 }, +describe('diagnostic report shares, not attempt failure rates', () => { + it('keeps a patch failure followed by success as two reports', () => { + const result = summarizeBreakdown([ + breakdown({ + byOS: [ + { type: 'patch_fail', hash: 'v1', name: 'V1', os: 'ios', count: 1 }, + { + type: 'download_success', + hash: 'v1', + name: 'V1', + os: 'ios', + count: 1, + }, + ], + }), ]); - expect(summary.os[0]).toMatchObject({ - key: 'android', - total: 100, - failureRate: 0.1, - rollbackRate: null, - }); - expect(summary.os[1]).toMatchObject({ - key: 'ios', - rollbackRate: 2 / 42, - }); - expect(summary.carriers[0]).toMatchObject({ - key: '电信', - failureRate: 5 / 55, - }); - expect(summary.versionNames.get('v2')).toBeNull(); - expect(summary.versionNames.get('v1')).toBe('1.0'); - }); - - it('filters by version hash and drops the carrier dimension when filtering', () => { - const summary = summarizeBreakdown(days, 'v2'); - expect(summary.failures).toBe(1); - expect(summary.reasons).toHaveLength(1); - expect(summary.os.map((row) => row.key)).toEqual(['ios']); - expect(summary.carriers).toEqual([]); + expect(result.os[0]?.failureSamples).toBe(2); + expect(result.os[0]?.failureRate).toBe(0.5); + expect(highestFailureDimension(result.os)).toBeNull(); }); - - it('is empty-safe', () => { - expect(summarizeBreakdown(undefined).failures).toBe(0); + it('does not rank one failure or a zero-failure platform above sufficient evidence', () => { + const result = summarizeBreakdown([ + breakdown({ + byOS: [ + { + type: 'download_fail', + hash: 'v1', + name: 'V1', + os: 'tiny', + count: 1, + }, + { + type: 'download_fail', + hash: 'v1', + name: 'V1', + os: 'ios', + count: 10, + }, + { + type: 'download_success', + hash: 'v1', + name: 'V1', + os: 'ios', + count: 90, + }, + { + type: 'download_success', + hash: 'v1', + name: 'V1', + os: 'android', + count: 100, + }, + ], + }), + ]); + expect(highestFailureDimension(result.os)?.key).toBe('ios'); + expect( + highestFailureDimension(result.os.filter((row) => row.key === 'android')), + ).toBeNull(); }); - - it('separates known reasons from the other: bucket', () => { - expect(parseFailureReason('crc_mismatch')).toEqual({ - kind: 'known', - reason: 'crc_mismatch', - }); - expect(parseFailureReason('other:ENOENT')).toEqual({ + it('normalizes legacy free-text reasons and keeps version and carrier scopes separate', () => { + const days = [ + breakdown({ + byReason: [ + { + type: 'download_fail', + hash: 'v1', + name: 'V1', + reason: 'other:private-a', + count: 2, + }, + { + type: 'patch_fail', + hash: 'v2', + name: null, + reason: 'other:private-b', + count: 3, + }, + ], + byCarrier: [{ type: 'download_fail', carrier: '电信', count: 5 }], + }), + ]; + const result = summarizeBreakdown(days); + expect(result.failures).toBe(5); + expect(result.reasons).toHaveLength(1); + expect(result.reasons[0]?.reason).toBe('other'); + expect(result.reasons[0]?.percent).toBe(100); + expect(summarizeBreakdown(days, 'v1').failures).toBe(2); + expect(summarizeBreakdown(days, 'v1').carriers).toEqual([]); + expect(parseFailureReason('other:private')).toEqual({ kind: 'other', - detail: 'ENOENT', }); - expect(parseFailureReason('weird')).toEqual({ - kind: 'other', - detail: 'weird', + expect(parseFailureReason('timeout')).toEqual({ + kind: 'known', + reason: 'timeout', }); + expect(isKnownCarrier('移动')).toBe(true); + expect(summarizeBreakdown(undefined).reasons).toEqual([]); }); }); diff --git a/src/pages/app-insights/logic.ts b/src/pages/app-insights/logic.ts index d8da5aa..8b97251 100644 --- a/src/pages/app-insights/logic.ts +++ b/src/pages/app-insights/logic.ts @@ -1,15 +1,22 @@ -// 数据分析页的纯逻辑:把三个接口按日返回的数据折叠成窗口内的汇总、 -// 排名与比率。这里不碰 React,也不做文案,方便单测。 - +// Events, offered targets and retained devices have different populations. +// Do not synthesize conversion rates from independent observation counters. +import { + CRITICAL_ROLLBACK, + MIN_EVENT_SAMPLES, + WARNING_ROLLBACK, +} from '../../constants/metric-thresholds'; import { type AppEventBreakdownDay, type AppTrafficDay, type ClientEventType, + type DeviceStatus, type FunnelEventCounts, HIT_OUTCOMES, type HitOutcome, LAG_BUCKETS, type LagBucket, + type LagBuckets, + type ObservationStatus, type ServedCounts, type VersionFunnel, type VersionFunnelResponse, @@ -18,7 +25,6 @@ import { export const INSIGHT_DAY_OPTIONS = [7, 14, 35] as const; export type InsightDays = (typeof INSIGHT_DAY_OPTIONS)[number]; export const DEFAULT_INSIGHT_DAYS: InsightDays = 7; - export const INSIGHT_VIEWS = [ 'overview', 'versions', @@ -39,22 +45,42 @@ export const parseInsightView = (value: string | null): InsightView => ? (value as InsightView) : 'overview'; -/** 服务端按北京时间自然日累加;今天那一条是实时累计。 */ +/** Legacy fallback only; prefer window.today in the writer's timezone. */ export const beijingToday = (now: number = Date.now()): string => new Date(now + 8 * 60 * 60 * 1000).toISOString().slice(0, 10); +export const validCount = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value) && value >= 0; + +const countOf = (value: unknown): number => (validCount(value) ? value : 0); +const percentOf = (part: number, total: number) => + total > 0 ? (part / total) * 100 : 0; + +export const observationCount = ( + value: number | null | undefined, + status?: ObservationStatus, +): number | null => + status === 'unavailable' || !validCount(value) ? null : value; + +/** A legacy zero UUID count may mean missing/expired collection, not zero + * devices. An explicit observed zero is valid and participates in the mean. */ +export const deviceObservationCount = ( + value: number | null | undefined, + status?: ObservationStatus, +): number | null => + status === undefined && value === 0 ? null : observationCount(value, status); + export interface RankedItem { key: string; count: number; percent: number; } -/** 把 {名称→次数} 按次数降序排名并算占比;次数相同按名称稳定排序。 */ export const rankCounts = ( counts: Readonly> | undefined, ): RankedItem[] => { const entries = Object.entries(counts ?? {}).filter( - ([, count]) => Number.isFinite(count) && count > 0, + ([, count]) => validCount(count) && count > 0, ); const total = entries.reduce((sum, [, count]) => sum + count, 0); return entries @@ -63,11 +89,7 @@ export const rankCounts = ( ? leftKey.localeCompare(rightKey) : rightCount - leftCount, ) - .map(([key, count]) => ({ - key, - count, - percent: total > 0 ? (count / total) * 100 : 0, - })); + .map(([key, count]) => ({ key, count, percent: percentOf(count, total) })); }; const addCounts = ( @@ -75,29 +97,34 @@ const addCounts = ( source: Readonly> | undefined, ) => { for (const [key, count] of Object.entries(source ?? {})) { - if (!Number.isFinite(count) || count <= 0) continue; - target[key] = (target[key] ?? 0) + count; + if (validCount(count) && count > 0) { + target[key] = (target[key] ?? 0) + count; + } } }; export interface DailyTrafficPoint { date: string; - requests: number; - dau: number; + requests: number | null; + dau: number | null; hit: Record; isToday: boolean; } export interface PackageTrafficSummary { packageVersion: string; - requests: number; - /** 窗口内单日最高的去重设备数(HLL 不能跨日合并)。 */ - peakDevices: number; - percent: number; + requests: number | null; + peakDevices: number | null; + percent: number | null; + observedDays: number; + availableStart: string | null; + availableEnd: string | null; + partial: boolean; + expiredDays: number; + unavailableDays: number; } export type RefusalOutcome = 'blocked' | 'unknown_package'; - export interface RefusedPackageSummary { packageVersion: string; requests: number; @@ -106,24 +133,21 @@ export interface RefusedPackageSummary { export interface TrafficSummary { requests: number; today: DailyTrafficPoint | null; - /** 不含今天的完整日数据,用于"日均" */ completedDays: number; - averageDailyRequests: number; - peakDau: number; - averageDau: number; + requestSampleDays: number; + dauSampleDays: number; + averageDailyRequests: number | null; + peakDau: number | null; + averageDau: number | null; hit: Record; - /** 拒绝类(blocked + unknown_package)在总请求里的占比 */ refusedPercent: number; - /** 命中更新(hdiff + pdiff + full)在总请求里的占比 */ updatePercent: number; hourly: number[]; ipVersion: RankedItem[]; hosts: RankedItem[]; carriers: RankedItem[]; packages: PackageTrafficSummary[]; - /** 被拒绝请求按原生包版本拆分,请求数降序;旧日桶没有拆分,合计可能小于 hit */ refused: Record; - /** 按日期升序,供图表使用 */ daily: DailyTrafficPoint[]; } @@ -138,9 +162,6 @@ const emptyHit = (): Record => ({ unknown_package: 0, }); -const percentOf = (part: number, total: number) => - total > 0 ? (part / total) * 100 : 0; - const rankRefused = (counts: Map): RefusedPackageSummary[] => Array.from(counts, ([packageVersion, requests]) => ({ packageVersion, @@ -151,10 +172,9 @@ const rankRefused = (counts: Map): RefusedPackageSummary[] => : right.requests - left.requests, ); -/** - * 服务端按最新在前返回;这里把窗口内各天折叠成一份汇总。 - * DAU 是 HLL 去重,跨日不能相加,只给峰值和日均;原生包设备数同理取单日峰值。 - */ +/** Both means exclude today, include valid zeroes, and disclose sample days. + * Unavailable values are not zero. Daily device estimates cannot be added; + * merging retained original HLLs would require a different server API. */ export const summarizeTraffic = ( days: readonly AppTrafficDay[] | undefined, today: string = beijingToday(), @@ -164,98 +184,125 @@ export const summarizeTraffic = ( const ipVersion: Record = {}; const hosts: Record = {}; const carriers: Record = {}; - const packages = new Map(); + const packages = new Map(); const refused: Record> = { blocked: new Map(), unknown_package: new Map(), }; const daily: DailyTrafficPoint[] = []; let requests = 0; - let dauSum = 0; - let dauDays = 0; - let peakDau = 0; - let completedRequests = 0; let completedDays = 0; + let requestSampleDays = 0; + let dauSampleDays = 0; + let completedRequests = 0; + let completedDau = 0; + let peakDau: number | null = null; for (const day of days ?? []) { + const dayRequests = observationCount(day.requests, day.requestsStatus); const dayHit = emptyHit(); - for (const [outcome, count] of Object.entries(day.hit ?? {})) { - if (!Number.isFinite(count) || count <= 0) continue; - if (HIT_OUTCOMES.includes(outcome as HitOutcome)) { - dayHit[outcome as HitOutcome] += count; - hit[outcome as HitOutcome] += count; + // Ratios must share the same available request-day population. Hourly, + // host and UUID observations remain separate, self-denominated series. + if (dayRequests !== null) { + for (const outcome of HIT_OUTCOMES) { + dayHit[outcome] = countOf(day.hit?.[outcome]); + hit[outcome] += dayHit[outcome]; } } - const dayRequests = Number.isFinite(day.requests) ? day.requests : 0; - const isToday = day.date === today; - requests += dayRequests; - if (!isToday) { - completedRequests += dayRequests; + const dau = deviceObservationCount(day.dau, day.dauStatus); + requests += dayRequests ?? 0; + if (dau !== null) peakDau = Math.max(peakDau ?? 0, dau); + if (day.date < today) { completedDays += 1; - } - if (Number.isFinite(day.dau) && day.dau > 0) { - dauSum += day.dau; - dauDays += 1; - peakDau = Math.max(peakDau, day.dau); + if (dayRequests !== null) { + completedRequests += dayRequests; + requestSampleDays += 1; + } + if (dau !== null) { + completedDau += dau; + dauSampleDays += 1; + } } (day.hourly ?? []).forEach((count, hour) => { - if (hour < 24 && Number.isFinite(count)) { - hourly[hour] = (hourly[hour] ?? 0) + count; - } + if (hour < 24) hourly[hour] = (hourly[hour] ?? 0) + countOf(count); }); addCounts(ipVersion, day.ipVersion); addCounts(hosts, day.hosts); addCounts(carriers, day.carriers); for (const item of day.packages ?? []) { - const entry = packages.get(item.packageVersion) ?? { - requests: 0, - peakDevices: 0, + const entry: PackageTrafficSummary = packages.get( + item.packageVersion, + ) ?? { + packageVersion: item.packageVersion, + requests: null, + peakDevices: null, + percent: 0, + observedDays: 0, + availableStart: null, + availableEnd: null, + partial: false, + expiredDays: 0, + unavailableDays: 0, }; - entry.requests += item.requests; - entry.peakDevices = Math.max(entry.peakDevices, item.devices ?? 0); + if (dayRequests !== null && validCount(item.requests)) { + entry.requests = (entry.requests ?? 0) + item.requests; + } + const status: DeviceStatus = + item.devicesStatus ?? + (validCount(item.devices) && item.devices > 0 + ? 'observed' + : 'unavailable'); + if ( + (status === 'observed' || status === 'partial') && + validCount(item.devices) + ) { + entry.peakDevices = Math.max(entry.peakDevices ?? 0, item.devices); + entry.observedDays += 1; + entry.availableStart = + entry.availableStart === null || day.date < entry.availableStart + ? day.date + : entry.availableStart; + entry.availableEnd = + entry.availableEnd === null || day.date > entry.availableEnd + ? day.date + : entry.availableEnd; + } else if (status === 'expired') { + entry.expiredDays += 1; + } else { + entry.unavailableDays += 1; + } + entry.partial ||= + status === 'partial' || day.packageDevicesLimited === true; packages.set(item.packageVersion, entry); } - for (const item of day.refused ?? []) { + for (const item of dayRequests === null ? [] : (day.refused ?? [])) { const target = refused[item.outcome]; - if (!target || !Number.isFinite(item.requests) || item.requests <= 0) { - continue; + if (target && validCount(item.requests) && item.requests > 0) { + target.set( + item.packageVersion, + (target.get(item.packageVersion) ?? 0) + item.requests, + ); } - target.set( - item.packageVersion, - (target.get(item.packageVersion) ?? 0) + item.requests, - ); } daily.push({ date: day.date, requests: dayRequests, - dau: day.dau ?? 0, + dau, hit: dayHit, - isToday, + isToday: day.date === today, }); } daily.sort((left, right) => left.date.localeCompare(right.date)); - - const packageRows = Array.from(packages.entries()) - .map(([packageVersion, entry]) => ({ - packageVersion, - requests: entry.requests, - peakDevices: entry.peakDevices, - percent: percentOf(entry.requests, requests), - })) - .sort((left, right) => - right.requests === left.requests - ? left.packageVersion.localeCompare(right.packageVersion) - : right.requests - left.requests, - ); - return { requests, today: daily.find((day) => day.isToday) ?? null, completedDays, + requestSampleDays, + dauSampleDays, averageDailyRequests: - completedDays > 0 ? completedRequests / completedDays : 0, + requestSampleDays > 0 ? completedRequests / requestSampleDays : null, + averageDau: dauSampleDays > 0 ? completedDau / dauSampleDays : null, peakDau, - averageDau: dauDays > 0 ? dauSum / dauDays : 0, hit, refusedPercent: percentOf(hit.blocked + hit.unknown_package, requests), updatePercent: percentOf(hit.hdiff + hit.pdiff + hit.full, requests), @@ -263,7 +310,19 @@ export const summarizeTraffic = ( ipVersion: rankCounts(ipVersion), hosts: rankCounts(hosts), carriers: rankCounts(carriers), - packages: packageRows, + packages: Array.from(packages.values()) + .map((entry) => ({ + ...entry, + percent: + entry.requests !== null && requests > 0 + ? percentOf(entry.requests, requests) + : null, + })) + .sort( + (left, right) => + (right.requests ?? 0) - (left.requests ?? 0) || + left.packageVersion.localeCompare(right.packageVersion), + ), refused: { blocked: rankRefused(refused.blocked), unknown_package: rankRefused(refused.unknown_package), @@ -272,7 +331,6 @@ export const summarizeTraffic = ( }; }; -/** hit 里值得提醒的项:这两类是"为什么收不到更新"的直接证据。 */ export const trafficWarnings = ( hit: Readonly>, ): Array<'blocked' | 'unknown_package'> => { @@ -284,82 +342,157 @@ export const trafficWarnings = ( export const servedTotal = (served: ServedCounts | undefined) => served - ? served.hdiff + - served.pdiff + - served.full + - served.fullPending + - served.exp + ? countOf(served.hdiff) + + countOf(served.pdiff) + + countOf(served.full) + + countOf(served.fullPending) + + countOf(served.exp) : 0; +// Legacy enum identifiers for shared key mappings; rollback reports ONLY. export type FunnelHealth = 'healthy' | 'warning' | 'critical' | null; - -// 与服务状态页的版本健康总览一致:回滚率 ≥5% 异常、≥1% 关注;样本 <10 不判定 -const CRITICAL_ROLLBACK = 0.05; -const WARNING_ROLLBACK = 0.01; -const MIN_SAMPLES = 10; +export { MIN_EVENT_SAMPLES }; export interface FunnelRates { - /** 下载成功 / (下载成功 + 下载失败) */ downloadSuccessRate: number | null; - /** 回滚 / (激活 + 回滚) */ rollbackRate: number | null; + downloadSamples: number; + rollbackSamples: number; failures: number; health: FunnelHealth; } export const computeFunnelRates = (events: FunnelEventCounts): FunnelRates => { - const downloadSamples = events.downloadSuccess + events.downloadFail; - const startSamples = events.markSuccess + events.rollback; - const rollbackRate = startSamples > 0 ? events.rollback / startSamples : null; - let health: FunnelHealth = null; - if (rollbackRate !== null && startSamples >= MIN_SAMPLES) { - health = - rollbackRate >= CRITICAL_ROLLBACK + const downloadSamples = + countOf(events.downloadSuccess) + countOf(events.downloadFail); + const rollbackSamples = + countOf(events.markSuccess) + countOf(events.rollback); + const rollbackRate = + rollbackSamples > 0 ? countOf(events.rollback) / rollbackSamples : null; + const health: FunnelHealth = + rollbackSamples < MIN_EVENT_SAMPLES || rollbackRate === null + ? null + : rollbackRate >= CRITICAL_ROLLBACK ? 'critical' : rollbackRate >= WARNING_ROLLBACK ? 'warning' : 'healthy'; - } return { downloadSuccessRate: - downloadSamples > 0 ? events.downloadSuccess / downloadSamples : null, + downloadSamples > 0 + ? countOf(events.downloadSuccess) / downloadSamples + : null, rollbackRate, - failures: events.downloadFail + events.patchFail, + downloadSamples, + rollbackSamples, + failures: countOf(events.downloadFail) + countOf(events.patchFail), health, }; }; +export interface RetainedObservations { + mark: number | null; + download: number | null; + lag: LagBuckets; +} + export interface FunnelRow extends VersionFunnel, FunnelRates { servedTotal: number; - /** - * adopted.mark / adopted.download:下载过该版本的设备里有多少真的激活了。 - * 两个数都是累计去重(HLL),处在同一时间基准上,所以不像"窗口内激活数 ÷ - * 窗口内下载数"那样会因为下载在窗口之前、激活在窗口之内而超过 100%。 - */ - adoptionRate: number | null; - /** adopted.mark / dauToday;dauToday 为 0 时为 null */ - coverage: number | null; - /** 名字为空表示版本已删除 */ + retained: RetainedObservations | null; deleted: boolean; } export const buildFunnelRows = ( response: VersionFunnelResponse | undefined, ): FunnelRow[] => - (response?.versions ?? []).map((version) => ({ - ...version, - ...computeFunnelRates(version.events), - servedTotal: servedTotal(version.served), - adoptionRate: - version.adopted.download > 0 - ? version.adopted.mark / version.adopted.download - : null, - coverage: - response && response.dauToday > 0 - ? version.adopted.mark / response.dauToday - : null, - deleted: version.name === null, - })); + (response?.versions ?? []).map((version) => { + const retainedCount = (kind: 'mark' | 'download') => { + // Explicit null is authoritative; never fall through to legacy data. + const value = + version.observed === undefined + ? version.adopted[kind] + : version.observed?.[kind]; + return validCount(value) && value > 0 ? value : null; + }; + return { + ...version, + ...computeFunnelRates(version.events), + servedTotal: servedTotal(version.served), + retained: { + mark: retainedCount('mark'), + download: retainedCount('download'), + lag: version.lag, + }, + deleted: version.name === null, + }; + }); + +/** Order only the returned candidates, without mutating cached API data. */ +export const rankFunnelRows = (rows: readonly FunnelRow[]): FunnelRow[] => { + const volume = (row: FunnelRow) => + row.servedTotal + + Object.values(row.events).reduce((sum, value) => sum + countOf(value), 0); + return [...rows].sort( + (left, right) => + volume(right) - volume(left) || + (left.hash < right.hash ? -1 : left.hash > right.hash ? 1 : 0), + ); +}; + +export const filterFunnelRows = ( + rows: readonly FunnelRow[], + hash?: string, + packageVersion?: string, +): FunnelRow[] => + rows.flatMap((row) => { + if (hash && row.hash !== hash) return []; + if (!packageVersion) return [row]; + const item = row.byPackage.find( + (entry) => entry.packageVersion === packageVersion, + ); + return item + ? [ + { + ...row, + ...computeFunnelRates(item.events), + served: item.served, + events: item.events, + servedTotal: servedTotal(item.served), + retained: null, + byPackage: [item], + }, + ] + : []; + }); + +export const versionTotals = (response: VersionFunnelResponse | undefined) => { + if (!response) return null; + if (response.summary) { + return { ...response.summary, scope: 'all_observed' as const }; + } + const events: FunnelEventCounts = { + downloadSuccess: 0, + downloadFail: 0, + patchFail: 0, + markSuccess: 0, + rollback: 0, + }; + let offeredTargets = 0; + for (const row of response.versions) { + offeredTargets += servedTotal(row.served); + for (const key of Object.keys(events) as (keyof FunnelEventCounts)[]) { + events[key] += countOf(row.events[key]); + } + } + return { + versionCount: response.versions.length, + offeredTargets, + events, + unattributed: null, + unattributedOffers: null, + scope: 'returned_versions' as const, + }; +}; export interface LagShare { bucket: LagBucket; @@ -367,24 +500,22 @@ export interface LagShare { percent: number; } -/** 发布后多久收到事件:按固定桶顺序展开,缺失桶补 0。 */ export const lagShares = ( buckets: Partial> | null | undefined, ): { total: number; shares: LagShare[] } => { const total = LAG_BUCKETS.reduce( - (sum, bucket) => sum + (buckets?.[bucket] ?? 0), + (sum, bucket) => sum + countOf(buckets?.[bucket]), 0, ); return { total, shares: LAG_BUCKETS.map((bucket) => { - const count = buckets?.[bucket] ?? 0; + const count = countOf(buckets?.[bucket]); return { bucket, count, percent: percentOf(count, total) }; }), }; }; -/** 找到 hash 对应的版本名;用来在漏斗表之外(例如失败诊断)显示。 */ export const shortHash = (hash: string) => hash.length > 12 ? `${hash.slice(0, 12)}…` : hash; @@ -393,9 +524,7 @@ export const FAILURE_EVENT_TYPES: ReadonlySet = new Set([ 'patch_fail', 'rollback', ]); - export type EventTypeCounts = Record; - const emptyEventCounts = (): EventTypeCounts => ({ download_success: 0, download_fail: 0, @@ -408,34 +537,52 @@ export interface DimensionRow { key: string; counts: EventTypeCounts; total: number; - /** (下载失败 + Patch 失败) / (下载成功 + 下载失败 + Patch 失败) */ failureRate: number | null; - /** 回滚 / (启动成功 + 回滚) */ rollbackRate: number | null; + failureSamples: number; + rollbackSamples: number; } -const finishDimensionRow = (key: string, counts: EventTypeCounts) => { - const downloadSamples = +const finishDimensionRow = ( + key: string, + counts: EventTypeCounts, +): DimensionRow => { + const failureSamples = counts.download_success + counts.download_fail + counts.patch_fail; - const startSamples = counts.mark_success + counts.rollback; + const rollbackSamples = counts.mark_success + counts.rollback; return { key, counts, total: Object.values(counts).reduce((sum, count) => sum + count, 0), failureRate: - downloadSamples > 0 - ? (counts.download_fail + counts.patch_fail) / downloadSamples + failureSamples > 0 + ? (counts.download_fail + counts.patch_fail) / failureSamples : null, - rollbackRate: startSamples > 0 ? counts.rollback / startSamples : null, + rollbackRate: + rollbackSamples > 0 ? counts.rollback / rollbackSamples : null, + failureSamples, + rollbackSamples, }; }; +export const highestFailureDimension = (rows: readonly DimensionRow[]) => + [...rows] + .filter( + (row) => + row.failureSamples >= MIN_EVENT_SAMPLES && (row.failureRate ?? 0) > 0, + ) + .sort( + (left, right) => + (right.failureRate ?? 0) - (left.failureRate ?? 0) || + right.failureSamples - left.failureSamples || + left.key.localeCompare(right.key), + )[0] ?? null; + export interface ReasonVersion { hash: string; name: string | null; count: number; } - export interface ReasonRow { reason: string; count: number; @@ -443,21 +590,18 @@ export interface ReasonRow { byType: Partial>; versions: ReasonVersion[]; } - export interface BreakdownSummary { - /** 失败事件总数(byReason 之和) */ + totalDays: number; + availableDays: number; + unavailableDays: number; + legacyDays: number; failures: number; reasons: ReasonRow[]; os: DimensionRow[]; carriers: DimensionRow[]; - /** 出现在失败原因里的版本:hash → name(null 表示已删除) */ versionNames: Map; } -/** - * 三组明细各自求和。byReason 只含失败事件,所以 reasons 的分母就是失败总数; - * byOS / byCarrier 含成功事件,能算出每个平台/运营商的失败率。 - */ export const summarizeBreakdown = ( days: readonly AppEventBreakdownDay[] | undefined, hashFilter?: string, @@ -474,16 +618,42 @@ export const summarizeBreakdown = ( const carriers = new Map(); const versionNames = new Map(); let failures = 0; - + let availableDays = 0; + let unavailableDays = 0; + let legacyDays = 0; for (const day of days ?? []) { + if (day.status === undefined) legacyDays += 1; + const legacyHasReports = [ + ...(day.byReason ?? []), + ...(day.byOS ?? []), + ...(day.byCarrier ?? []), + ].some((item) => validCount(item.count) && item.count > 0); + // Availability belongs to the unfiltered bucket, not to the selected + // version. Explicit unavailable always wins over leftover numeric fields. + if ( + day.status === 'unavailable' || + (day.status !== 'observed' && !legacyHasReports) + ) { + unavailableDays += 1; + continue; + } + availableDays += 1; for (const item of day.byReason ?? []) { if (hashFilter && item.hash !== hashFilter) continue; - if (!Number.isFinite(item.count) || item.count <= 0) continue; + if ( + !validCount(item.count) || + item.count <= 0 || + !FAILURE_EVENT_TYPES.has(item.type) + ) { + continue; + } failures += item.count; if (!versionNames.has(item.hash) || item.name !== null) { versionNames.set(item.hash, item.name); } - const entry = reasons.get(item.reason) ?? { + const parsed = parseFailureReason(item.reason); + const reason = parsed.kind === 'known' ? parsed.reason : 'other'; + const entry = reasons.get(reason) ?? { count: 0, byType: {} as Partial>, versions: new Map(), @@ -498,64 +668,53 @@ export const summarizeBreakdown = ( version.count += item.count; if (item.name !== null) version.name = item.name; entry.versions.set(item.hash, version); - reasons.set(item.reason, entry); + reasons.set(reason, entry); } for (const item of day.byOS ?? []) { if (hashFilter && item.hash !== hashFilter) continue; - if (!Number.isFinite(item.count) || item.count <= 0) continue; + if (!validCount(item.count) || item.count <= 0) continue; if (!versionNames.has(item.hash) || item.name !== null) { versionNames.set(item.hash, item.name); } const counts = os.get(item.os) ?? emptyEventCounts(); - if (item.type in counts) counts[item.type] += item.count; + if (Object.hasOwn(counts, item.type)) counts[item.type] += item.count; os.set(item.os, counts); } - // 运营商维度没有 hash,版本筛选时跳过它 if (hashFilter) continue; for (const item of day.byCarrier ?? []) { - if (!Number.isFinite(item.count) || item.count <= 0) continue; + if (!validCount(item.count) || item.count <= 0) continue; const counts = carriers.get(item.carrier) ?? emptyEventCounts(); - if (item.type in counts) counts[item.type] += item.count; + if (Object.hasOwn(counts, item.type)) counts[item.type] += item.count; carriers.set(item.carrier, counts); } } - - const byCountDesc = ( - left: T, - right: T, - ) => - right.total === left.total - ? left.key.localeCompare(right.key) - : right.total - left.total; - + const byCount = (a: DimensionRow, b: DimensionRow) => + b.total - a.total || a.key.localeCompare(b.key); return { + totalDays: days?.length ?? 0, + availableDays, + unavailableDays, + legacyDays, failures, - reasons: Array.from(reasons.entries()) - .map(([reason, entry]) => ({ - reason, - count: entry.count, - percent: percentOf(entry.count, failures), - byType: entry.byType, - versions: Array.from(entry.versions.values()).sort( - (left, right) => right.count - left.count, - ), - })) - .sort((left, right) => - right.count === left.count - ? left.reason.localeCompare(right.reason) - : right.count - left.count, + reasons: Array.from(reasons, ([reason, entry]) => ({ + reason, + count: entry.count, + percent: percentOf(entry.count, failures), + byType: entry.byType, + versions: Array.from(entry.versions.values()).sort( + (a, b) => b.count - a.count || a.hash.localeCompare(b.hash), ), - os: Array.from(os.entries()) - .map(([key, counts]) => finishDimensionRow(key, counts)) - .sort(byCountDesc), - carriers: Array.from(carriers.entries()) - .map(([key, counts]) => finishDimensionRow(key, counts)) - .sort(byCountDesc), + })).sort((a, b) => b.count - a.count || a.reason.localeCompare(b.reason)), + os: Array.from(os, ([key, counts]) => finishDimensionRow(key, counts)).sort( + byCount, + ), + carriers: Array.from(carriers, ([key, counts]) => + finishDimensionRow(key, counts), + ).sort(byCount), versionNames, }; }; -/** 失败原因的有限类别;`other:<前缀>` 之外的值都在这里。 */ export const KNOWN_FAILURE_REASONS = [ 'empty', 'crc_mismatch', @@ -573,17 +732,11 @@ export type KnownFailureReason = (typeof KNOWN_FAILURE_REASONS)[number]; export const parseFailureReason = ( reason: string, -): - | { kind: 'known'; reason: KnownFailureReason } - | { kind: 'other'; detail: string } => +): { kind: 'known'; reason: KnownFailureReason } | { kind: 'other' } => KNOWN_FAILURE_REASONS.includes(reason as KnownFailureReason) ? { kind: 'known', reason: reason as KnownFailureReason } - : { - kind: 'other', - detail: reason.startsWith('other:') ? reason.slice(6) : reason, - }; + : { kind: 'other' }; -/** 服务端写入的运营商标签是中文字面量;界面上按语言翻译,未知标签原样显示。 */ export const KNOWN_CARRIERS = [ '电信', '联通', @@ -596,6 +749,5 @@ export const KNOWN_CARRIERS = [ 'unknown', ] as const; export type KnownCarrier = (typeof KNOWN_CARRIERS)[number]; - export const isKnownCarrier = (carrier: string): carrier is KnownCarrier => KNOWN_CARRIERS.includes(carrier as KnownCarrier); diff --git a/src/pages/app-insights/observation-ui.test.tsx b/src/pages/app-insights/observation-ui.test.tsx new file mode 100644 index 0000000..94859ba --- /dev/null +++ b/src/pages/app-insights/observation-ui.test.tsx @@ -0,0 +1,174 @@ +import { afterEach, beforeEach, expect, test } from 'bun:test'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { cleanup, render, screen } from '@testing-library/react'; +import i18n from 'i18next'; +import '@/i18n'; +import en from '@/i18n/locales/en.json'; +import zhCN from '@/i18n/locales/zh-CN.json'; +import { metricsKeys } from '@/utils/query-keys'; +import { buildFunnelRows, filterFunnelRows } from './logic'; +import { ObservationNotice, RollbackObservation } from './observation-ui'; +import { PackageObservation } from './traffic-panel'; +import type { VersionFunnelResponse } from './types'; +import { VersionDetail, VersionsPanel } from './versions-panel'; + +const insightsEn = en.app_insights; +const insightsZh = zhCN.app_insights; +const clients: QueryClient[] = []; +beforeEach(async () => { + global.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as any; + global.ShadowRoot = class {} as any; + await i18n.changeLanguage('en'); +}); +afterEach(() => { + cleanup(); + for (const client of clients.splice(0)) client.clear(); +}); +const fixture = (): VersionFunnelResponse => ({ + days: 7, + start: '2026-09-21', + end: '2026-09-27', + hourlyFrom: '2026-09-25', + dauToday: 2, + versions: [ + { + hash: 'v1', + name: 'V1', + served: { hdiff: 0, pdiff: 0, full: 6, fullPending: 0, exp: 0 }, + events: { + downloadSuccess: 5, + downloadFail: 90, + patchFail: 0, + markSuccess: 10, + rollback: 0, + }, + adopted: { mark: 5, download: 5 }, + lag: { downloadSuccess: null, markSuccess: null }, + byPackage: [ + { + packageVersion: '1.0', + served: { hdiff: 0, pdiff: 0, full: 2, fullPending: 0, exp: 0 }, + events: { + downloadSuccess: 2, + downloadFail: 0, + patchFail: 0, + markSuccess: 2, + rollback: 0, + }, + }, + ], + }, + ], +}); + +test('canonical metric language catalogs have identical keys', () => { + expect(Object.keys(insightsEn).sort()).toEqual( + Object.keys(insightsZh).sort(), + ); + expect(i18n.t('app_insights.hit_uptodate')).toBe('No update offered'); + expect(i18n.t('app_insights.view_versions')).toBe('Version events'); +}); + +test('low rollback does not claim overall health and tiny samples are explicit', () => { + const response = fixture(); + const row = buildFunnelRows(response)[0]!; + render( + , + ); + expect(screen.getByText('Low rollback report share')).not.toBeNull(); + expect(screen.queryByText('Healthy')).toBeNull(); + cleanup(); + render(); + expect( + screen.getByText('Insufficient rollback observations (4 / 10)'), + ).not.toBeNull(); +}); + +test('package-filtered details hide whole-version cumulative metrics', () => { + const row = filterFunnelRows(buildFunnelRows(fixture()), 'v1', '1.0')[0]!; + render(); + expect( + screen.getByText(insightsEn.retained_unavailable_package), + ).not.toBeNull(); + expect(screen.queryByText(insightsEn.retained_title)).toBeNull(); +}); + +test('retained counts are rendered without a coverage or adoption percentage', () => { + render(); + expect(screen.getAllByText('≈ 5')).toHaveLength(2); + expect(screen.queryByText('250.0%')).toBeNull(); + expect(screen.queryByText('Coverage')).toBeNull(); +}); + +test('legacy table labels returned-version totals and keeps download failures visible', () => { + const client = new QueryClient({ + defaultOptions: { queries: { staleTime: Infinity, retry: false } }, + }); + clients.push(client); + client.setQueryData(metricsKeys.appVersionFunnel('metric-test', 7), { + ...fixture(), + truncated: true, + }); + render( + + + , + ); + expect(screen.getByText(insightsEn.totals_returned)).not.toBeNull(); + expect(screen.getByText('90')).not.toBeNull(); + expect(screen.queryByText('250.0%')).toBeNull(); +}); + +test('window and stale state disclose actual UTC boundaries and refresh freshness', () => { + render( + , + ); + expect(screen.getByText(/2026-09-21T00:00:00Z/)).not.toBeNull(); + expect(screen.getByText(insightsEn.stale_data)).not.toBeNull(); + expect(screen.getByText(/refreshes every minute/)).not.toBeNull(); +}); + +test('package observations surface short retention and collection limits', () => { + render( + , + ); + expect( + screen.getByText('20 expired days; 1 unavailable days'), + ).not.toBeNull(); + expect(screen.getByText(insightsEn.package_partial)).not.toBeNull(); +}); diff --git a/src/pages/app-insights/observation-ui.tsx b/src/pages/app-insights/observation-ui.tsx new file mode 100644 index 0000000..68c9929 --- /dev/null +++ b/src/pages/app-insights/observation-ui.tsx @@ -0,0 +1,134 @@ +import { Alert, Tag, Tooltip } from 'antd'; +import { useTranslation } from 'react-i18next'; +import { FUNNEL_HEALTH_LABEL_KEY } from '@/constants/i18n-keys'; +import { MIN_EVENT_SAMPLES } from '@/constants/metric-thresholds'; +import type { BreakdownSummary, FunnelHealth } from './logic'; +import { formatInteger, formatPercent } from './shared'; +import type { ObservationWindow } from './types'; + +export const ObservationNotice = ({ + window, + source, + updatedAt, + stale = false, +}: { + window?: ObservationWindow | null; + source: 'utc' | 'business'; + updatedAt: number; + stale?: boolean; +}) => { + const { t } = useTranslation(); + return ( +
+ {stale && } +
+ {window + ? t('app_insights.window_exact', { + timezone: window.timezone, + start: window.startInclusive, + end: window.endExclusive, + }) + : t(`app_insights.window_${source}_legacy`)} +
+
+ {updatedAt > 0 + ? t('app_insights.last_refreshed', { + time: new Date(updatedAt).toLocaleString(), + }) + : t('app_insights.not_loaded')} +
+
{t('app_insights.best_effort')}
+
+ ); +}; + +export const observedInteger = (value: number | null | undefined) => + typeof value === 'number' && Number.isFinite(value) + ? `≈ ${formatInteger(value)}` + : '-'; + +export const ReportShare = ({ + part, + total, +}: { + part: number; + total: number; +}) => ( + + {total > 0 ? formatPercent(part / total) : '-'} + + ({formatInteger(part)}/{formatInteger(total)}) + + +); + +export const RollbackObservation = ({ + health, + samples, + count, +}: { + health: FunnelHealth; + samples: number; + count: number; +}) => { + const { t } = useTranslation(); + const label = + health === null + ? t('app_insights.insufficient_samples', { + count: samples, + minimum: MIN_EVENT_SAMPLES, + }) + : t(FUNNEL_HEALTH_LABEL_KEY[health]); + return ( + + + + {label} + + + + + ); +}; + +export const BreakdownAvailability = ({ + summary, +}: { + summary: Pick< + BreakdownSummary, + 'totalDays' | 'availableDays' | 'unavailableDays' | 'legacyDays' + >; +}) => { + const { t } = useTranslation(); + if (summary.totalDays === 0) return null; + return ( +
+ {summary.availableDays === 0 && ( + + )} +
+ {t('app_insights.breakdown_availability', { + available: summary.availableDays, + total: summary.totalDays, + unavailable: summary.unavailableDays, + })} +
+ {summary.legacyDays > 0 && ( +
+ {t('app_insights.breakdown_legacy_days', { + days: summary.legacyDays, + })} +
+ )} +
+ ); +}; diff --git a/src/pages/app-insights/observed-null.test.ts b/src/pages/app-insights/observed-null.test.ts new file mode 100644 index 0000000..8b9aa65 --- /dev/null +++ b/src/pages/app-insights/observed-null.test.ts @@ -0,0 +1,33 @@ +import { expect, test } from 'bun:test'; +import { buildFunnelRows } from './logic'; + +test('an explicit null observation object is unavailable, not legacy fallback', () => { + // Exercise the actual JSON boundary, including a defensive null shape that + // is not emitted by the v2 server but must not crash the whole panel. + const payload = JSON.parse( + JSON.stringify({ + versions: [ + { + hash: 'v1', + name: 'V1', + adopted: { mark: 5, download: 5 }, + observed: null, + served: { hdiff: 0, pdiff: 0, full: 0, fullPending: 0, exp: 0 }, + events: { + downloadSuccess: 0, + downloadFail: 0, + patchFail: 0, + markSuccess: 0, + rollback: 0, + }, + lag: { downloadSuccess: null, markSuccess: null }, + byPackage: [], + }, + ], + }), + ); + expect(buildFunnelRows(payload)[0]?.retained).toMatchObject({ + mark: null, + download: null, + }); +}); diff --git a/src/pages/app-insights/overview-panel.tsx b/src/pages/app-insights/overview-panel.tsx index 936330d..d554668 100644 --- a/src/pages/app-insights/overview-panel.tsx +++ b/src/pages/app-insights/overview-panel.tsx @@ -1,26 +1,26 @@ import { Alert, Card, Radio, Spin, Tag } from 'antd'; import { useMemo, useState } from 'react'; -import { Trans, useTranslation } from 'react-i18next'; -import { AsyncColumn, AsyncLine } from '@/components/lazy-chart'; -import { FUNNEL_HEALTH_LABEL_KEY } from '@/constants/i18n-keys'; -import { buildTimeSeriesLineConfig } from '@/utils/charts'; +import { useTranslation } from 'react-i18next'; +import { AsyncColumn } from '@/components/lazy-chart'; import { useThemeMode } from '@/utils/theme-mode'; import { buildFunnelRows, - type FunnelRow, type InsightView, type RefusedPackageSummary, + rankFunnelRows, summarizeTraffic, trafficWarnings, } from './logic'; -import { HIT_OUTCOME_COLORS } from './palette'; +import { + ObservationNotice, + observedInteger, + RollbackObservation, +} from './observation-ui'; import { BarList, EmptyState, Footnote, formatInteger, - formatPercent, - formatShare, InsightsError, Question, StatTile, @@ -29,14 +29,8 @@ import { useHitOutcomeLabel, VersionLabel, } from './shared'; -import { HIT_OUTCOMES, type HitOutcome } from './types'; - -type DailyMetric = 'requests' | 'dau'; +import { HIT_OUTCOMES } from './types'; -const OVERVIEW_VERSION_LIMIT = 5; -const REFUSED_PACKAGE_LIMIT = 10; - -/** 被拒绝的原生包版本及各自请求数;旧日桶没有按版本拆分,差额单独说明。 */ const RefusedPackages = ({ items, total, @@ -45,33 +39,26 @@ const RefusedPackages = ({ total: number; }) => { const { t } = useTranslation(); - if (items.length === 0) return null; - const shown = items.slice(0, REFUSED_PACKAGE_LIMIT); - const hidden = items.length - shown.length; const unattributed = - total - items.reduce((sum, item) => sum + item.requests, 0); + total - items.reduce((sum, row) => sum + row.requests, 0); return ( -
- - {t('app_insights.refused_packages')} - - {shown.map((item) => ( - - {item.packageVersion} - - {formatInteger(item.requests)} - +
+ {items.slice(0, 10).map((row) => ( + + {row.packageVersion}: {formatInteger(row.requests)} ))} - {hidden > 0 && ( - - {t('app_insights.refused_packages_more', { count: hidden })} + {items.length > 10 && ( + + {t('app_insights.refused_packages_more', { + count: items.length - 10, + })} )} {unattributed > 0 && ( - + {t('app_insights.refused_packages_unattributed', { - count: formatInteger(unattributed), + count: unattributed, })} )} @@ -79,22 +66,6 @@ const RefusedPackages = ({ ); }; -const HEALTH_TAG_COLOR = { - healthy: 'green', - warning: 'orange', - critical: 'red', -} as const; - -export const HealthTag = ({ health }: { health: FunnelRow['health'] }) => { - const { t } = useTranslation(); - if (!health) return null; - return ( - - {t(FUNNEL_HEALTH_LABEL_KEY[health])} - - ); -}; - export const OverviewPanel = ({ appKey, days, @@ -107,203 +78,114 @@ export const OverviewPanel = ({ const { t } = useTranslation(); const { isDark } = useThemeMode(); const hitLabel = useHitOutcomeLabel(); - const [dailyMetric, setDailyMetric] = useState('requests'); - + const [dailyMetric, setDailyMetric] = useState<'requests' | 'dau'>( + 'requests', + ); const traffic = useAppTraffic(appKey, days); const funnel = useAppVersionFunnel(appKey, days); - const summary = useMemo( - () => summarizeTraffic(traffic.data?.days), + () => summarizeTraffic(traffic.data?.days, traffic.data?.window?.today), [traffic.data], ); - const warnings = trafficWarnings(summary.hit); - const funnelRows = useMemo( - () => buildFunnelRows(funnel.data).slice(0, OVERVIEW_VERSION_LIMIT), + const rows = useMemo( + () => rankFunnelRows(buildFunnelRows(funnel.data)).slice(0, 5), [funnel.data], ); - - // 只保留窗口内出现过的结果类别,图例不会被一排 0 撑满;颜色仍按类别固定。 - const activeOutcomes = HIT_OUTCOMES.filter( - (outcome) => summary.hit[outcome] > 0, - ); - const stackedData = useMemo( - () => - summary.daily.flatMap((day) => - activeOutcomes.map((outcome) => ({ + const warnings = trafficWarnings(summary.hit); + const hasRequests = summary.daily.some((day) => day.requests !== null); + const activeOutcomes = HIT_OUTCOMES.filter((key) => summary.hit[key] > 0); + const dailyData = summary.daily.flatMap((day) => + dailyMetric === 'dau' + ? [{ date: day.date, category: t('app_insights.dau'), value: day.dau }] + : activeOutcomes.map((key) => ({ date: day.date, - outcome, - label: hitLabel(outcome), - value: day.hit[outcome], - isToday: day.isToday, + category: hitLabel(key), + value: day.requests === null ? null : day.hit[key], })), - ), - [summary.daily, activeOutcomes, hitLabel], - ); - const columnConfig = { - theme: isDark ? 'classicDark' : 'classic', - data: stackedData, - xField: 'date', - yField: 'value', - colorField: 'label', - stack: true, - height: 320, - axis: { - x: { title: false, labelAutoRotate: true }, - y: { title: false }, - }, - legend: { color: { position: 'top' } }, - scale: { - color: { - domain: activeOutcomes.map(hitLabel), - range: activeOutcomes.map((outcome) => HIT_OUTCOME_COLORS[outcome]), - }, - }, - interaction: { tooltip: { shared: true } }, - tooltip: { - title: (point: { date: string; isToday: boolean }) => - point.isToday - ? `${point.date} · ${t('app_insights.today_live')}` - : point.date, - items: [ - (point: { label: string; value: number }) => ({ - name: point.label, - value: formatInteger(point.value), - }), - ], - }, - }; - - const dauData = useMemo( - () => - summary.daily.map((day) => ({ - time: day.date, - value: day.dau, - category: t('app_insights.dau'), - })), - [summary.daily, t], ); - const dauConfig = buildTimeSeriesLineConfig({ - data: dauData, - isDark, - height: 320, - axisTimeFormat: 'MM/DD', - tooltipTimeFormat: 'YYYY-MM-DD', - formatTooltipValue: (point) => formatInteger(point.value), - }); - - const outcomeItems = HIT_OUTCOMES.filter( - (outcome) => summary.hit[outcome] > 0, - ).map((outcome: HitOutcome) => ({ - key: outcome, - label: ( - - - {hitLabel(outcome)} - - ), - count: summary.hit[outcome], - percent: - summary.requests > 0 - ? (summary.hit[outcome] / summary.requests) * 100 - : 0, - muted: outcome === 'uptodate', - alert: outcome === 'blocked' || outcome === 'unknown_package', - })); - - const hasTraffic = summary.requests > 0; + const outcomes = activeOutcomes + .map((key) => ({ + key, + label: hitLabel(key), + count: summary.hit[key], + percent: + summary.requests > 0 ? (summary.hit[key] / summary.requests) * 100 : 0, + alert: key === 'blocked' || key === 'unknown_package', + })) + .sort((a, b) => b.count - a.count); return (
- {traffic.error ? : null} + {!!traffic.error && } +
- - {warnings.length > 0 && ( + {t('app_insights.means_note')} + {warnings.map((outcome) => ( - {warnings.includes('blocked') && ( -
  • - }} - /> - -
  • - )} - {warnings.includes('unknown_package') && ( -
  • - }} - /> - -
  • + <> + {t( + outcome === 'blocked' + ? 'app_insights.refusal_blocked' + : 'app_insights.refusal_unknown_package', )} - + + } /> - )} - + ))} - setDailyMetric(event.target.value as DailyMetric) - } - optionType="button" - buttonStyle="solid" + onChange={(event) => setDailyMetric(event.target.value)} + size="small" > {t('app_insights.daily_requests')} @@ -312,117 +194,105 @@ export const OverviewPanel = ({ } > - - {dailyMetric === 'requests' - ? t('app_insights.daily_requests_question') - : t('app_insights.daily_dau_question')} - - {!hasTraffic ? ( - - {traffic.isLoading ? '' : t('app_insights.no_traffic')} - - ) : dailyMetric === 'requests' ? ( - + {dailyData.length > 0 ? ( + ) : ( - + + {traffic.isLoading ? '' : t('app_insights.no_observations')} + )} - - {t('app_insights.daily_footnote', { - retention: traffic.data?.retentionDays ?? 35, - })} - + {t('app_insights.daily_footnote')} - -
    - - {t('app_insights.outcome_question')} - {outcomeItems.length > 0 ? ( - <> -
    - - 0 ? 'warning' : undefined} - /> -
    - - + + {t('app_insights.outcome_question')} + {outcomes.length > 0 ? ( + + ) : ( + {t('app_insights.no_observations')} + )} + {t('app_insights.outcome_footnote')} + + onNavigate('versions')} + > + {t('app_insights.view_all_versions')} + + } + > + {t('app_insights.versions_glance_question')} + {!!funnel.error && } + + + {rows.length > 0 ? ( +
    + {rows.map((row) => ( +
    +
    + +
    + {t('app_insights.glance_line', { + served: formatInteger(row.servedTotal), + activated: formatInteger(row.events.markSuccess), + failed: formatInteger(row.failures), + rollback: formatInteger(row.events.rollback), + })} +
    +
    + +
    +
    +
    +
    + {observedInteger(row.retained?.mark)} +
    +
    + {t('app_insights.observed_glance')} +
    +
    +
    + ))} +
    ) : ( - {traffic.isLoading ? '' : t('app_insights.no_traffic')} + {funnel.isLoading ? '' : t('app_insights.no_observations')} )} - {t('app_insights.outcome_footnote')} -
    - - onNavigate('versions')} - > - {t('app_insights.view_all_versions')} - - } - > - {t('app_insights.versions_glance_question')} - {funnel.error ? : null} - - {funnelRows.length > 0 ? ( -
      - {funnelRows.map((row) => ( -
    • -
      - -
      - {t('app_insights.glance_line', { - served: formatInteger(row.servedTotal), - activated: formatInteger(row.events.markSuccess), - activation: formatPercent(row.adoptionRate), - })} -
      -
      -
      - - {row.coverage === null - ? '-' - : formatPercent(row.coverage)} - - - {t('app_insights.coverage')} - - -
      -
    • - ))} -
    - ) : ( - - {funnel.isLoading ? '' : t('app_insights.no_versions')} - - )} -
    - - {t('app_insights.coverage_footnote', { - dau: formatInteger(funnel.data?.dauToday ?? 0), - })} - -
    -
    + + {t('app_insights.events_not_funnel')} + + {t('app_insights.retained_scope')} {t('app_insights.retained_hint')} + +
    ); }; diff --git a/src/pages/app-insights/review-availability.test.tsx b/src/pages/app-insights/review-availability.test.tsx new file mode 100644 index 0000000..a8b9ce5 --- /dev/null +++ b/src/pages/app-insights/review-availability.test.tsx @@ -0,0 +1,158 @@ +import { afterEach, beforeEach, expect, test } from 'bun:test'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { cleanup, render, screen } from '@testing-library/react'; +import i18n from 'i18next'; +import '@/i18n'; +import { metricsKeys } from '@/utils/query-keys'; +import { FailuresPanel } from './failures-panel'; +import { summarizeBreakdown } from './logic'; +import { BreakdownAvailability } from './observation-ui'; +import type { AppEventBreakdownDay } from './types'; + +const clients: QueryClient[] = []; +beforeEach(async () => { + global.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as any; + global.ShadowRoot = class {} as any; + await i18n.changeLanguage('en'); +}); +afterEach(() => { + cleanup(); + for (const client of clients.splice(0)) client.clear(); +}); +const day = ( + overrides: Partial = {}, +): AppEventBreakdownDay => ({ + date: '2026-09-26', + byOS: [], + byReason: [], + byCarrier: [], + ...overrides, +}); +const show = (days: AppEventBreakdownDay[]) => { + const client = new QueryClient({ + defaultOptions: { queries: { staleTime: Infinity, retry: false } }, + }); + clients.push(client); + client.setQueryData(metricsKeys.appEventBreakdown('review-app', 7), { + days, + retentionDays: 35, + }); + render( + + + , + ); +}; +const failureTileValue = () => + screen + .getByText(i18n.t('app_insights.failure_events', { days: 7 })) + .parentElement?.parentElement?.querySelector('.text-2xl')?.textContent; + +test('an entirely unavailable failure window renders unavailable, not zero or healthy', () => { + show([ + day({ + status: 'unavailable', + byReason: [ + { + type: 'rollback', + hash: 'v1', + name: 'V1', + reason: 'network', + count: 500, + }, + ], + }), + ]); + expect( + screen.getAllByText(i18n.t('app_insights.breakdown_unavailable')).length > + 0, + ).toBe(true); + expect(failureTileValue()).toBe('-'); + expect(screen.queryByText('500')).toBeNull(); + expect( + screen.getByText( + i18n.t('app_insights.breakdown_availability', { + available: 0, + total: 1, + unavailable: 1, + }), + ), + ).not.toBeNull(); +}); +test('explicit observed empty days render zero with available-day context', () => { + show([day({ status: 'observed' })]); + expect(failureTileValue()).toBe('0'); + expect( + screen.queryByText(i18n.t('app_insights.breakdown_unavailable')), + ).toBeNull(); + expect( + screen.getAllByText(i18n.t('app_insights.no_reports_in_scope')).length > 0, + ).toBe(true); +}); +test('mixed availability keeps observed failures visible while explaining missing days', () => { + show([ + day({ status: 'unavailable' }), + day({ + status: 'observed', + byReason: [ + { + type: 'download_fail', + hash: 'v1', + name: 'V1', + reason: 'network', + count: 2, + }, + ], + }), + ]); + expect(failureTileValue()).toBe('2'); + expect( + screen.getByText( + i18n.t('app_insights.breakdown_availability', { + available: 1, + total: 2, + unavailable: 1, + }), + ), + ).not.toBeNull(); +}); +test('Chinese availability distinguishes legacy missing buckets from an empty filter', async () => { + await i18n.changeLanguage('zh-CN'); + const summary = summarizeBreakdown( + [ + day(), + day({ + byOS: [ + { + type: 'download_success', + hash: 'v1', + name: null, + os: 'ios', + count: 1, + }, + ], + }), + ], + 'missing', + ); + render(); + expect( + screen.getByText( + i18n.t('app_insights.breakdown_availability', { + available: 1, + total: 2, + unavailable: 1, + }), + ), + ).not.toBeNull(); + expect( + screen.getByText(i18n.t('app_insights.breakdown_legacy_days', { days: 2 })), + ).not.toBeNull(); + expect( + screen.queryByText(i18n.t('app_insights.breakdown_unavailable')), + ).toBeNull(); +}); diff --git a/src/pages/app-insights/review-regressions.test.ts b/src/pages/app-insights/review-regressions.test.ts new file mode 100644 index 0000000..ff60a68 --- /dev/null +++ b/src/pages/app-insights/review-regressions.test.ts @@ -0,0 +1,351 @@ +import { describe, expect, it } from 'bun:test'; +import { + buildFunnelRows, + computeFunnelRates, + rankFunnelRows, + summarizeBreakdown, + summarizeTraffic, +} from './logic'; +import type { + AppEventBreakdownDay, + AppTrafficDay, + FunnelEventCounts, + VersionFunnel, + VersionFunnelResponse, +} from './types'; + +const traffic = (overrides: Partial = {}): AppTrafficDay => ({ + date: '2026-09-25', + requests: 0, + dau: 0, + hourly: [], + hit: {}, + ipVersion: {}, + hosts: {}, + carriers: {}, + packages: [], + ...overrides, +}); +const events = ( + overrides: Partial = {}, +): FunnelEventCounts => ({ + downloadSuccess: 0, + downloadFail: 0, + patchFail: 0, + markSuccess: 0, + rollback: 0, + ...overrides, +}); +const breakdown = ( + overrides: Partial = {}, +): AppEventBreakdownDay => ({ + date: '2026-09-25', + byReason: [], + byOS: [], + byCarrier: [], + ...overrides, +}); +const failedDay = (): AppEventBreakdownDay => + breakdown({ + status: 'unavailable', + byReason: [ + { + type: 'download_fail', + hash: 'unavailable', + name: null, + reason: 'network', + count: 500, + }, + ], + byOS: [ + { + type: 'download_fail', + hash: 'unavailable', + name: null, + os: 'unavailable', + count: 500, + }, + ], + byCarrier: [{ type: 'download_fail', carrier: 'unavailable', count: 500 }], + }); + +describe('review: request ratios use matching observation days', () => { + it('excludes unavailable request numerators without dropping independent series', () => { + const input = [ + traffic({ + requests: 1000, + requestsStatus: 'unavailable', + dau: 8, + dauStatus: 'observed', + hit: { blocked: 500, full: 500 }, + hourly: [20], + hosts: { independent: 20 }, + packages: [ + { + packageVersion: '1.0', + requests: 1000, + devices: 8, + devicesStatus: 'observed', + }, + ], + refused: [{ outcome: 'blocked', packageVersion: '1.0', requests: 500 }], + }), + traffic({ + date: '2026-09-26', + requests: 100, + requestsStatus: 'observed', + hit: { blocked: 10, full: 90 }, + hourly: [10], + packages: [ + { + packageVersion: '1.0', + requests: 100, + devices: 3, + devicesStatus: 'observed', + }, + ], + refused: [{ outcome: 'blocked', packageVersion: '1.0', requests: 10 }], + }), + ]; + const before = structuredClone(input); + const result = summarizeTraffic(input, '2026-09-27'); + expect(result.requests).toBe(100); + expect(result.refusedPercent).toBe(10); + expect(result.updatePercent).toBe(90); + expect(result.packages[0]).toMatchObject({ + requests: 100, + percent: 100, + peakDevices: 8, + }); + expect(result.refused.blocked).toEqual([ + { packageVersion: '1.0', requests: 10 }, + ]); + expect(result.daily[0]?.hit.blocked).toBe(0); + expect(result.daily[0]?.requests).toBeNull(); + expect(result.hourly[0]).toBe(30); + expect(result.hosts[0]?.count).toBe(20); + expect(input).toEqual(before); + }); + it('excludes numerators when their request denominator is invalid', () => { + for (const requests of [Number.NaN, -1, Number.POSITIVE_INFINITY]) { + const result = summarizeTraffic( + [ + traffic({ + requests, + hit: { blocked: 500 }, + packages: [{ packageVersion: '1', requests: 500, devices: 2 }], + refused: [ + { outcome: 'blocked', packageVersion: '1', requests: 500 }, + ], + }), + ], + '2026-09-27', + ); + expect(result.hit.blocked).toBe(0); + expect(result.refused.blocked).toEqual([]); + expect(result.packages[0]?.requests).toBeNull(); + expect(result.packages[0]?.percent).toBeNull(); + expect(result.packages[0]?.peakDevices).toBe(2); + } + }); + it('preserves explicit observed device zeroes but excludes ambiguous legacy zeroes', () => { + const input = [ + traffic({ date: '2026-09-21', dau: 10 }), + traffic({ date: '2026-09-22', dau: 0 }), + traffic({ date: '2026-09-23', dau: 0, dauStatus: 'observed' }), + traffic({ date: '2026-09-24', dau: 999, dauStatus: 'unavailable' }), + traffic({ date: '2026-09-27', dau: 999, dauStatus: 'observed' }), + ]; + const result = summarizeTraffic(input, '2026-09-27'); + expect(result.averageDau).toBe(5); + expect(result.dauSampleDays).toBe(2); + expect(result.daily.map((day) => day.dau)).toEqual([ + 10, + null, + 0, + null, + 999, + ]); + }); +}); + +describe('review: failure availability and canonical reason identity', () => { + it('keeps unavailable counts out of every dimension and distinguishes an observed empty day', () => { + const input = [ + failedDay(), + breakdown({ status: 'observed' }), + breakdown({ + status: 'observed', + byReason: [ + { + type: 'patch_fail', + hash: 'v1', + name: 'V1', + reason: 'patch_apply', + count: 2, + }, + ], + byOS: [ + { type: 'patch_fail', hash: 'v1', name: 'V1', os: 'ios', count: 2 }, + ], + byCarrier: [{ type: 'patch_fail', carrier: 'known', count: 2 }], + }), + ]; + const before = structuredClone(input); + const result = summarizeBreakdown(input); + expect(result).toMatchObject({ + totalDays: 3, + availableDays: 2, + unavailableDays: 1, + legacyDays: 0, + failures: 2, + }); + expect(result.reasons.map((row) => row.reason)).toEqual(['patch_apply']); + expect(result.os.map((row) => row.key)).toEqual(['ios']); + expect(result.carriers.map((row) => row.key)).toEqual(['known']); + expect(result.versionNames.has('unavailable')).toBe(false); + expect(summarizeBreakdown(input, 'absent')).toMatchObject({ + availableDays: 2, + unavailableDays: 1, + failures: 0, + reasons: [], + os: [], + carriers: [], + }); + expect(input).toEqual(before); + }); + it('does not treat empty legacy or explicit unavailable buckets as observed zero failures', () => { + const result = summarizeBreakdown([failedDay(), breakdown()]); + expect(result).toMatchObject({ + totalDays: 2, + availableDays: 0, + unavailableDays: 2, + legacyDays: 1, + failures: 0, + }); + const legacy = breakdown({ + byOS: [ + { + type: 'download_success', + hash: 'v1', + name: null, + os: 'ios', + count: 1, + }, + ], + }); + expect(summarizeBreakdown([legacy], 'absent')).toMatchObject({ + availableDays: 1, + unavailableDays: 0, + legacyDays: 1, + failures: 0, + }); + expect( + summarizeBreakdown([breakdown({ status: 'observed' })]), + ).toMatchObject({ availableDays: 1, unavailableDays: 0, failures: 0 }); + }); + it('merges future and legacy unknown reasons into one Other row with intact totals', () => { + const input = breakdown({ + status: 'observed', + byReason: [ + { + type: 'download_fail', + hash: 'v1', + name: 'V1', + reason: 'dns', + count: 2, + }, + { type: 'patch_fail', hash: 'v2', name: 'V2', reason: 'ssl', count: 3 }, + { + type: 'rollback', + hash: 'v1', + name: 'V1', + reason: 'other:old', + count: 4, + }, + { + type: 'download_fail', + hash: 'v2', + name: 'V2', + reason: 'other', + count: 1, + }, + { + type: 'download_fail', + hash: 'v1', + name: 'V1', + reason: 'network', + count: 5, + }, + ], + }); + const result = summarizeBreakdown([input]); + expect(result.failures).toBe(15); + expect(result.reasons.map((row) => row.reason)).toEqual([ + 'other', + 'network', + ]); + expect(result.reasons[0]).toMatchObject({ + count: 10, + percent: (10 / 15) * 100, + byType: { download_fail: 3, patch_fail: 3, rollback: 4 }, + versions: [ + { hash: 'v1', name: 'V1', count: 6 }, + { hash: 'v2', name: 'V2', count: 4 }, + ], + }); + }); +}); + +describe('review: stable version ranking and shared rollback boundaries', () => { + it('sorts every event component and breaks ties by hash without mutating cached rows', () => { + const makeVersion = ( + hash: string, + counts: FunnelEventCounts, + full = 0, + ): VersionFunnel => ({ + hash, + name: hash, + served: { hdiff: 0, pdiff: 0, full, fullPending: 0, exp: 0 }, + events: counts, + adopted: { mark: 0, download: 0 }, + byPackage: [], + lag: { markSuccess: null, downloadSuccess: null }, + }); + const response: VersionFunnelResponse = { + days: 7, + start: '2026-09-21', + end: '2026-09-27', + hourlyFrom: '2026-09-25', + dauToday: 0, + versions: [ + makeVersion('z', events(), 10), + makeVersion('a', events({ rollback: 10 })), + makeVersion('small', events({ markSuccess: 1 })), + makeVersion('download', events({ downloadSuccess: 20 })), + makeVersion('failed', events({ downloadFail: 30 })), + makeVersion('patched', events({ patchFail: 40 })), + ], + }; + const rows = buildFunnelRows(response); + const before = rows.map((row) => row.hash); + expect( + rankFunnelRows(rows) + .slice(0, 5) + .map((row) => row.hash), + ).toEqual(['patched', 'failed', 'download', 'a', 'z']); + expect(rows.map((row) => row.hash)).toEqual(before); + }); + it('classifies 1% and 5% inclusively and keeps fewer than ten reports insufficient', () => { + expect(computeFunnelRates(events({ markSuccess: 9 })).health).toBeNull(); + expect(computeFunnelRates(events({ markSuccess: 10 })).health).toBe( + 'healthy', + ); + expect( + computeFunnelRates(events({ markSuccess: 99, rollback: 1 })).health, + ).toBe('warning'); + expect( + computeFunnelRates(events({ markSuccess: 95, rollback: 5 })).health, + ).toBe('critical'); + }); +}); diff --git a/src/pages/app-insights/shared.tsx b/src/pages/app-insights/shared.tsx index 70f39f1..dab2567 100644 --- a/src/pages/app-insights/shared.tsx +++ b/src/pages/app-insights/shared.tsx @@ -75,7 +75,10 @@ export const formatPercent = (ratio: number | null | undefined, digits = 1) => ? `${((ratio as number) * 100).toFixed(digits)}%` : '-'; -export const formatShare = (percent: number) => `${percent.toFixed(1)}%`; +export const formatShare = (percent: number | null | undefined) => + typeof percent === 'number' && Number.isFinite(percent) + ? `${percent.toFixed(1)}%` + : '-'; /** 单个指标块:标题 → 大数字 → 一句补充说明。 */ export const StatTile = ({ @@ -309,9 +312,7 @@ export const useFailureReasonLabel = () => { return (reason: string) => { const parsed = parseFailureReason(reason); if (parsed.kind === 'other') { - return parsed.detail - ? t('app_insights.reason_other_detail', { detail: parsed.detail }) - : t('app_insights.reason_other'); + return t('app_insights.reason_other'); } const key = { empty: 'app_insights.reason_empty', diff --git a/src/pages/app-insights/traffic-panel.tsx b/src/pages/app-insights/traffic-panel.tsx index 7378309..e9ab1bc 100644 --- a/src/pages/app-insights/traffic-panel.tsx +++ b/src/pages/app-insights/traffic-panel.tsx @@ -1,10 +1,11 @@ -import { Card, Spin, Table, theme } from 'antd'; +import { Card, Spin, Table, Tag } from 'antd'; import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { AsyncColumn } from '@/components/lazy-chart'; import { useThemeMode } from '@/utils/theme-mode'; import { RealtimeGeoPanel } from '../realtime-metrics-geo'; -import { summarizeTraffic } from './logic'; +import { type PackageTrafficSummary, summarizeTraffic } from './logic'; +import { ObservationNotice, observedInteger } from './observation-ui'; import { RealtimeSeriesPanel } from './realtime-series-panel'; import { BarList, @@ -18,6 +19,36 @@ import { useCarrierLabel, } from './shared'; +export const PackageObservation = ({ row }: { row: PackageTrafficSummary }) => { + const { t } = useTranslation(); + return ( +
    + {row.observedDays > 0 ? ( +
    + {t('app_insights.package_observed_days', { + count: row.observedDays, + start: row.availableStart, + end: row.availableEnd, + })} +
    + ) : ( +
    {t('app_insights.no_observations')}
    + )} + {row.partial && ( + {t('app_insights.package_partial')} + )} + {(row.expiredDays > 0 || row.unavailableDays > 0) && ( +
    + {t('app_insights.package_missing_days', { + expired: row.expiredDays, + missing: row.unavailableDays, + })} +
    + )} +
    + ); +}; + export const TrafficPanel = ({ appKey, days, @@ -29,190 +60,147 @@ export const TrafficPanel = ({ }) => { const { t } = useTranslation(); const { isDark } = useThemeMode(); - const { token } = theme.useToken(); const carrierLabel = useCarrierLabel(); const traffic = useAppTraffic(appKey, days); const summary = useMemo( - () => summarizeTraffic(traffic.data?.days), + () => summarizeTraffic(traffic.data?.days, traffic.data?.window?.today), [traffic.data], ); - const hourlyData = summary.hourly.map((value, hour) => ({ hour: `${String(hour).padStart(2, '0')}:00`, value, })); - const hourlyTotal = summary.hourly.reduce((sum, value) => sum + value, 0); - const hourlyConfig = { - theme: isDark ? 'classicDark' : 'classic', - data: hourlyData, - xField: 'hour', - yField: 'value', - height: 260, - style: { fill: token.colorPrimary, radiusTopLeft: 3, radiusTopRight: 3 }, - axis: { - x: { title: false, labelAutoRotate: true }, - y: { title: false }, + const distributions = [ + { + key: 'carriers', + title: t('app_insights.carriers_title'), + question: t('app_insights.carriers_question'), + footnote: t('app_insights.carriers_footnote'), + items: summary.carriers.map((row) => ({ + ...row, + label: carrierLabel(row.key), + })), }, - tooltip: { - title: (point: { hour: string }) => point.hour, - items: [ - (point: { value: number }) => ({ - name: t('app_insights.requests'), - value: `${formatInteger(point.value)} (${formatShare( - hourlyTotal > 0 ? (point.value / hourlyTotal) * 100 : 0, - )})`, - }), - ], + { + key: 'hosts', + title: t('app_insights.hosts_title'), + question: t('app_insights.hosts_question'), + footnote: t('app_insights.hosts_footnote'), + items: summary.hosts.map((row) => ({ ...row, label: row.key })), }, - }; - - const hasTraffic = summary.requests > 0; - + { + key: 'ip', + title: t('app_insights.ip_title'), + question: t('app_insights.ip_question'), + footnote: t('app_insights.ip_footnote'), + items: summary.ipVersion.map((row) => ({ + ...row, + label: + row.key === 'v4' + ? 'IPv4' + : row.key === 'v6' + ? 'IPv6' + : t('app_insights.ip_unknown'), + })), + }, + ]; return ( -
    +
    - - {traffic.error ? : null} + {!!traffic.error && } + {t('app_insights.hourly_question', { days })} - {hasTraffic ? ( - + {summary.hourly.some((count) => count > 0) ? ( + ) : ( - - {traffic.isLoading ? '' : t('app_insights.no_traffic')} + + {traffic.isLoading ? '' : t('app_insights.no_observations')} )} {t('app_insights.hourly_footnote')} - -
    - - {t('app_insights.packages_question')} - - {summary.packages.length > 0 ? ( -
    10 ? { pageSize: 10 } : false - } - scroll={{ x: 'max-content' }} - columns={[ - { - title: t('app_insights.col_package'), - dataIndex: 'packageVersion', - }, - { - title: t('app_insights.requests'), - dataIndex: 'requests', - align: 'right', - render: (value: number) => formatInteger(value), - }, - { - title: t('app_insights.col_share'), - dataIndex: 'percent', - align: 'right', - render: (value: number) => formatShare(value), - }, - { - title: t('app_insights.col_peak_devices'), - dataIndex: 'peakDevices', - align: 'right', - render: (value: number) => - value > 0 ? ( - formatInteger(value) - ) : ( - - - ), - }, - ]} - /> - ) : ( - - {traffic.isLoading ? '' : t('app_insights.no_traffic')} - - )} - - {t('app_insights.packages_footnote')} - - - - {t('app_insights.carriers_question')} - - {summary.carriers.length > 0 ? ( - ({ - key: item.key, - label: carrierLabel(item.key), - title: item.key, - count: item.count, - percent: item.percent, - muted: item.key === 'unknown' || item.key === '其他', - }))} - /> - ) : ( - - {traffic.isLoading ? '' : t('app_insights.no_traffic')} - - )} - - {t('app_insights.carriers_footnote')} - - - - {t('app_insights.hosts_question')} - - {summary.hosts.length > 0 ? ( - ({ - key: item.key, - label: item.key, - title: item.key, - count: item.count, - percent: item.percent, - }))} - restLabel={(count) => t('app_insights.more_hosts', { count })} - /> - ) : ( - - {traffic.isLoading ? '' : t('app_insights.no_traffic')} - - )} - - {t('app_insights.hosts_footnote')} - - - - {t('app_insights.ip_question')} - - {summary.ipVersion.length > 0 ? ( - ({ - key: item.key, - label: - item.key === 'v4' - ? 'IPv4' - : item.key === 'v6' - ? 'IPv6' - : t('app_insights.ip_unknown'), - count: item.count, - percent: item.percent, - muted: item.key !== 'v4' && item.key !== 'v6', - }))} - /> - ) : ( - - {traffic.isLoading ? '' : t('app_insights.no_traffic')} - - )} - - {t('app_insights.ip_footnote')} - + + {t('app_insights.packages_question')} + +
    10 ? { pageSize: 10 } : false} + scroll={{ x: 'max-content' }} + locale={{ emptyText: t('app_insights.no_observations') }} + columns={[ + { + title: t('app_insights.col_package'), + dataIndex: 'packageVersion', + }, + { + title: t('app_insights.requests'), + dataIndex: 'requests', + align: 'right', + render: formatInteger, + }, + { + title: t('app_insights.request_share'), + dataIndex: 'percent', + align: 'right', + render: formatShare, + }, + { + title: t('app_insights.col_peak_devices'), + dataIndex: 'peakDevices', + align: 'right', + render: observedInteger, + }, + { + title: t('app_insights.col_availability'), + key: 'availability', + render: (_, row) => , + }, + ]} + /> + + + {t('app_insights.packages_footnote', { + requests: traffic.data?.retentionDays ?? 35, + devices: traffic.data?.packageDevicesRetentionDays ?? 14, + })} + + +
    + {distributions.map((distribution) => ( + + {distribution.question} + + {distribution.items.length > 0 ? ( + + ) : ( + + {traffic.isLoading ? '' : t('app_insights.no_observations')} + + )} + + + {distribution.footnote} {t('app_insights.request_share')} + + + ))}
    - ); diff --git a/src/pages/app-insights/types.ts b/src/pages/app-insights/types.ts index 0f0fb0e..fd702d6 100644 --- a/src/pages/app-insights/types.ts +++ b/src/pages/app-insights/types.ts @@ -1,7 +1,22 @@ -// pushy-go 三个按应用的洞察接口的响应形状(docs/client-telemetry.md「读取入口」)。 -// 三个接口共用 /metrics/app/geo 的授权边界与 days 参数(默认 7,上限 35)。 +// Additive v2 observation contract; optional fields support rolling deploys. +export interface ObservationWindow { + timezone: string; + startDate: string; + endDate: string; + startInclusive: string; + endExclusive: string; + today: string; + generatedAt: string; + partialDay: boolean; +} + +export type ObservationStatus = 'observed' | 'unavailable'; +export type DeviceStatus = ObservationStatus | 'partial' | 'expired'; +export interface ObservationContract { + version: number; + collection: string; +} -/** checkUpdate 请求的最终结果分类(服务端 classifyResult)。 */ export const HIT_OUTCOMES = [ 'uptodate', 'hdiff', @@ -17,36 +32,38 @@ export type HitOutcome = (typeof HIT_OUTCOMES)[number]; export interface PackageTraffic { packageVersion: string; requests: number; - /** 来自 HLL,可能为 0(没有 uuid 或超出跟踪上限)。 */ - devices: number; + devices: number | null; + devicesStatus?: DeviceStatus; } -/** 被拒绝的请求按客户端上报的原生包版本拆分(hit:blocked / hit:unknown_package)。 */ export interface RefusedPackage { outcome: 'blocked' | 'unknown_package'; packageVersion: string; requests: number; } -/** 一个北京时间自然日的流量;今天为实时累计。 */ export interface AppTrafficDay { date: string; - /** hit:* 之和,即含拒绝在内的全部完成请求。 */ requests: number; dau: number; + requestsStatus?: ObservationStatus; + dauStatus?: ObservationStatus; hourly: number[]; hit: Partial> & Record; ipVersion: Record; hosts: Record; carriers: Record; packages: PackageTraffic[]; - /** 拆分上线前的日子为空数组;旧服务端不返回该字段。 */ refused?: RefusedPackage[]; + packageDevicesLimited?: boolean; } export interface AppTrafficResponse { days: AppTrafficDay[]; retentionDays: number; + packageDevicesRetentionDays?: number; + window?: ObservationWindow | null; + contract?: ObservationContract; } export const CLIENT_EVENT_TYPES = [ @@ -61,7 +78,6 @@ export type ClientEventType = (typeof CLIENT_EVENT_TYPES)[number]; export interface EventOSCount { type: ClientEventType; hash: string; - /** null 表示版本已删除。 */ name: string | null; os: string; count: number; @@ -83,6 +99,7 @@ export interface EventCarrierCount { export interface AppEventBreakdownDay { date: string; + status?: ObservationStatus; byOS: EventOSCount[]; byReason: EventReasonCount[]; byCarrier: EventCarrierCount[]; @@ -91,17 +108,20 @@ export interface AppEventBreakdownDay { export interface AppEventBreakdownResponse { days: AppEventBreakdownDay[]; retentionDays: number; + window?: ObservationWindow | null; + contract?: ObservationContract; } +// Offered targets/options, not unique requests or file downloads. export interface ServedCounts { hdiff: number; pdiff: number; full: number; - /** 增量还没生成、退化成整包下发的次数。 */ fullPending: number; exp: number; } +// Unlinked client reports, not mutually exclusive update attempts. export interface FunnelEventCounts { downloadSuccess: number; downloadFail: number; @@ -136,20 +156,41 @@ export interface VersionFunnel { name: string | null; served: ServedCounts; events: FunnelEventCounts; - /** 累计激活 / 下载过该版本的设备数(HLL,不分日)。 */ adopted: { mark: number; download: number }; + observed?: { mark: number | null; download: number | null }; lag: LagBuckets; byPackage: PackageFunnel[]; } +export interface VersionEventSummary { + versionCount: number; + offeredTargets: number; + events: FunnelEventCounts; + unattributed: FunnelEventCounts; + unattributedOffers: number; +} + export interface VersionFunnelResponse { releaseInsights?: import('./release-insights-types').ReleaseInsights; days: number; start: string; end: string; - /** 从这一 UTC 日起读实时小时桶。 */ hourlyFrom: string; dauToday: number; truncated?: boolean; versions: VersionFunnel[]; + window?: ObservationWindow | null; + dauWindow?: ObservationWindow | null; + summary?: VersionEventSummary; + contract?: ObservationContract & { + servedUnit: string; + eventUnit: string; + deviceUnit: string; + adoptionScope: string; + adoptionInactivityDays: number; + lagOrigin: string; + lagUnit: string; + lagInactivityDays: number; + versionOrder: string; + }; } diff --git a/src/pages/app-insights/versions-panel.tsx b/src/pages/app-insights/versions-panel.tsx index fbca385..a738e59 100644 --- a/src/pages/app-insights/versions-panel.tsx +++ b/src/pages/app-insights/versions-panel.tsx @@ -5,16 +5,22 @@ import { useTranslation } from 'react-i18next'; import { buildFunnelRows, computeFunnelRates, + type FunnelRates, type FunnelRow, + filterFunnelRows, lagShares, servedTotal, + versionTotals, } from './logic'; -import { HealthTag } from './overview-panel'; +import { + ObservationNotice, + observedInteger, + RollbackObservation, +} from './observation-ui'; import { EmptyState, Footnote, formatInteger, - formatPercent, formatShare, InsightsError, Question, @@ -22,10 +28,14 @@ import { useAppVersionFunnel, VersionLabel, } from './shared'; -import type { LagBucket, ServedCounts } from './types'; +import type { + FunnelEventCounts, + LagBucket, + LagBuckets, + ServedCounts, +} from './types'; const ALL = '__all__'; - const LAG_LABEL_KEY: Record = { lt1h: 'app_insights.lag_lt1h', '1h-6h': 'app_insights.lag_1h_6h', @@ -35,6 +45,11 @@ const LAG_LABEL_KEY: Record = { gt7d: 'app_insights.lag_gt7d', }; +type EventRow = { served: ServedCounts; events: FunnelEventCounts } & Pick< + FunnelRates, + 'health' | 'rollbackSamples' +>; + const ServedBreakdown = ({ served }: { served: ServedCounts }) => { const { t } = useTranslation(); const parts: Array<[string, number]> = [ @@ -45,239 +60,162 @@ const ServedBreakdown = ({ served }: { served: ServedCounts }) => { [t('app_insights.served_exp'), served.exp], ]; return ( -
    +
    {parts.map(([label, count]) => ( -
    - {label} - {formatInteger(count)} +
    + {label}: {formatInteger(count)}
    ))}
    ); }; -/** 下发 → 下载成功 → 激活 → 回滚 的漏斗条。 */ -const FunnelBars = ({ row }: { row: FunnelRow }) => { +const useEventColumns = (): ColumnsType => { const { t } = useTranslation(); - const steps: Array<{ label: string; value: number; alert?: boolean }> = [ - { label: t('app_insights.step_served'), value: row.servedTotal }, + const fields: Array<[keyof FunnelEventCounts, string]> = [ + ['downloadSuccess', 'app_insights.col_downloaded'], + ['downloadFail', 'app_insights.col_download_fail'], + ['patchFail', 'app_insights.col_patch_fail'], + ['markSuccess', 'app_insights.col_activated'], + ['rollback', 'app_insights.col_rollback'], + ]; + return [ { - label: t('app_insights.step_downloaded'), - value: row.events.downloadSuccess, + title: t('app_insights.col_served'), + key: 'offered', + align: 'right', + render: (_, row) => ( + }> + + {formatInteger(servedTotal(row.served))} + + + ), }, - { label: t('app_insights.step_activated'), value: row.events.markSuccess }, + ...fields.map(([key, label]) => ({ + title: t(label), + key, + align: 'right' as const, + render: (_: unknown, row: T) => formatInteger(row.events[key]), + })), { - label: t('app_insights.step_rolled_back'), - value: row.events.rollback, - alert: true, + title: t('app_insights.col_health'), + key: 'rollbackObservation', + render: (_, row) => ( + + ), }, ]; - const max = Math.max(...steps.map((step) => step.value), 1); - return ( -
      - {steps.map((step) => ( -
    • - {step.label} - - 0 ? Math.max((step.value / max) * 100, 1.5) : 0}%`, - }} - /> - - - {formatInteger(step.value)} - -
    • - ))} -
    - ); }; -const LagBars = ({ +const LagTable = ({ title, buckets, }: { title: string; - buckets: FunnelRow['lag']['downloadSuccess']; + buckets: LagBuckets['downloadSuccess']; }) => { const { t } = useTranslation(); const { total, shares } = lagShares(buckets); return (
    -
    - {title} - - {t('app_insights.lag_total', { count: formatInteger(total) })} - -
    - {total === 0 ? ( -
    {t('app_insights.no_lag')}
    +

    + {title} {t('app_insights.lag_total', { count: formatInteger(total) })} +

    + {total > 0 ? ( +
    t(LAG_LABEL_KEY[row.bucket]), + }, + { + title: t('app_insights.col_count'), + dataIndex: 'count', + align: 'right', + render: formatInteger, + }, + { + title: t('app_insights.col_share'), + dataIndex: 'percent', + align: 'right', + render: formatShare, + }, + ]} + /> ) : ( -
      - {shares.map((share) => ( -
    • - - {t(LAG_LABEL_KEY[share.bucket])} - - - 0 ? 2 : 0)}%`, - }} - /> - - - {formatShare(share.percent)} - -
    • - ))} -
    + {t('app_insights.no_observations')} )} ); }; -const VersionDetail = ({ row }: { row: FunnelRow }) => { +export const VersionDetail = ({ row }: { row: FunnelRow }) => { const { t } = useTranslation(); - const packageRows = row.byPackage.map((item) => ({ - ...item, - ...computeFunnelRates(item.events), - servedTotal: servedTotal(item.served), - })); + const packageRows = useMemo( + () => + row.byPackage.map((item) => ({ + ...item, + ...computeFunnelRates(item.events), + })), + [row.byPackage], + ); + const packageColumns = useEventColumns<(typeof packageRows)[number]>(); return ( -
    -
    -
    -
    - {t('app_insights.funnel_title')} -
    - -
    - {t('app_insights.funnel_line', { - downloadSuccess: formatPercent(row.downloadSuccessRate), - activation: formatPercent(row.adoptionRate), - rollback: formatPercent(row.rollbackRate), - })} -
    -
    -
    -
    - {t('app_insights.by_package_title')} +
    + {t('app_insights.by_package_title')} +
    + {row.retained ? ( + + {t('app_insights.retained_scope')} +
    + +
    - {packageRows.length === 0 ? ( -
    - {t('app_insights.no_package_rows')} -
    - ) : ( -
    ( - }> - - {formatInteger(value)} - - - ), - }, - { - title: t('app_insights.col_downloaded'), - align: 'right', - render: (_, record) => - formatInteger(record.events.downloadSuccess), - }, - { - title: t('app_insights.col_activated'), - align: 'right', - render: (_, record) => - formatInteger(record.events.markSuccess), - }, - { - title: t('app_insights.col_failures'), - align: 'right', - render: (_, record) => - record.failures > 0 ? ( - - {formatInteger(record.failures)} - - ) : ( - '0' - ), - }, - { - title: t('app_insights.col_rollback'), - align: 'right', - render: (_, record) => - record.events.rollback > 0 ? ( - - {formatInteger(record.events.rollback)} - - ) : ( - '0' - ), - }, - { - title: t('app_insights.col_rollback_rate'), - align: 'right', - render: (_, record) => formatPercent(record.rollbackRate), - }, - ]} + {t('app_insights.retained_hint')} +
    + - )} -
    - -
    -
    - - -
    - - +
    + {t('app_insights.lag_footnote')} + + ) : ( + -
    - {t('app_insights.lag_footnote')} -
    - + )} ); }; @@ -291,281 +229,145 @@ export const VersionsPanel = ({ }) => { const { t } = useTranslation(); const funnel = useAppVersionFunnel(appKey, days); - const [versionFilter, setVersionFilter] = useState(ALL); - const [packageFilter, setPackageFilter] = useState(ALL); - + const [versionFilter, setVersionFilter] = useState(ALL); + const [packageFilter, setPackageFilter] = useState(ALL); const allRows = useMemo(() => buildFunnelRows(funnel.data), [funnel.data]); - - const versionOptions = useMemo( - () => [ - { value: ALL, label: t('app_insights.filter_all_versions') }, - ...allRows.map((row) => ({ - value: row.hash, - label: row.name - ? `${row.name} (${row.hash.slice(0, 8)})` - : `${t('app_insights.version_deleted')} (${row.hash.slice(0, 8)})`, - })), - ], - [allRows, t], + const rows = useMemo( + () => + filterFunnelRows( + allRows, + versionFilter === ALL ? undefined : versionFilter, + packageFilter === ALL ? undefined : packageFilter, + ), + [allRows, versionFilter, packageFilter], ); - const packageOptions = useMemo(() => { - const packages = new Set(); - for (const row of allRows) { - for (const item of row.byPackage) packages.add(item.packageVersion); - } - return [ - { value: ALL, label: t('app_insights.filter_all_packages') }, - ...Array.from(packages) - .sort() - .map((value) => ({ value, label: value })), - ]; - }, [allRows, t]); - - // 按原生包筛选时,下发和事件改用该包的分项;累计的 adopted / lag 没有包维度,原样保留。 - const rows = useMemo(() => { - let filtered = allRows; - if (versionFilter !== ALL) { - filtered = filtered.filter((row) => row.hash === versionFilter); - } - if (packageFilter !== ALL) { - filtered = filtered.flatMap((row) => { - const item = row.byPackage.find( - (entry) => entry.packageVersion === packageFilter, - ); - if (!item) return []; - return [ - { - ...row, - ...computeFunnelRates(item.events), - served: item.served, - events: item.events, - servedTotal: servedTotal(item.served), - byPackage: [item], - }, - ]; - }); - } - return filtered; - }, [allRows, versionFilter, packageFilter]); - + const totals = versionTotals(funnel.data); + const eventColumns = useEventColumns(); + const packages = Array.from( + new Set( + allRows.flatMap((row) => + row.byPackage.map((item) => item.packageVersion), + ), + ), + ).sort(); const columns: ColumnsType = [ { title: t('app_insights.col_version'), key: 'version', fixed: 'left', - width: 200, + width: 220, render: (_, row) => , }, - { - title: t('app_insights.col_health'), - key: 'health', - width: 70, - render: (_, row) => , - }, - { - title: t('app_insights.col_coverage'), - key: 'coverage', - width: 150, - render: (_, row) => - row.coverage === null ? ( - - - ) : ( -
    - - - - - {formatPercent(row.coverage)} - -
    - ), - }, - { - title: t('app_insights.col_served'), - key: 'served', - align: 'right', - width: 90, - render: (_, row) => ( - }> - - {formatInteger(row.servedTotal)} - - - ), - }, - { - title: t('app_insights.col_downloaded'), - key: 'downloaded', - align: 'right', - width: 90, - render: (_, row) => formatInteger(row.events.downloadSuccess), - }, - { - title: t('app_insights.col_activated'), - key: 'activated', - align: 'right', - width: 90, - render: (_, row) => formatInteger(row.events.markSuccess), - }, - { - title: t('app_insights.col_failures'), - key: 'failures', - align: 'right', - width: 70, - render: (_, row) => - row.failures > 0 ? ( - {formatInteger(row.failures)} - ) : ( - '0' - ), - }, - { - title: t('app_insights.col_rollback'), - key: 'rollback', - align: 'right', - width: 70, - render: (_, row) => - row.events.rollback > 0 ? ( - - {formatInteger(row.events.rollback)} - - ) : ( - '0' - ), - }, - { - title: t('app_insights.col_activation_rate'), - key: 'activationRate', - align: 'right', - width: 100, - render: (_, row) => formatPercent(row.adoptionRate), - }, - { - title: t('app_insights.col_rollback_rate'), - key: 'rollbackRate', - align: 'right', - width: 80, - render: (_, row) => formatPercent(row.rollbackRate), - }, + ...eventColumns, ]; - - const data = funnel.data; - return ( -
    - {funnel.error ? : null} - {data?.truncated && ( - - )} +
    + {!!funnel.error && } + + + {t( + totals?.scope === 'all_observed' + ? 'app_insights.totals_all' + : 'app_insights.totals_returned', + )} + -
    - +
    sum + row.servedTotal, 0), - )} - hint={t('app_insights.window_served_hint')} + value={formatInteger(totals?.offeredTargets)} /> sum + row.events.rollback, 0), - )} - hint={t('app_insights.window_rollbacks_hint')} - tone={ - allRows.some((row) => row.health === 'critical') - ? 'error' - : allRows.some((row) => row.health === 'warning') - ? 'warning' - : undefined - } + value={formatInteger(totals?.events.rollback)} />
    - - - -
    - } - > - {t('app_insights.funnel_table_question')} + {totals?.unattributed && ( + + {t('app_insights.totals_unattributed', { + offers: formatInteger(totals.unattributedOffers), + reports: formatInteger( + Object.values(totals.unattributed).reduce( + (sum, count) => sum + count, + 0, + ), + ), + })} + + )} + {funnel.data?.truncated && ( + + )} + +
    + ({ value, label: value })), + ]} + /> +
    + {t('app_insights.events_not_funnel')} + {t('app_insights.filtered_note')} - {rows.length === 0 && !funnel.isLoading ? ( - - {t( - allRows.length === 0 - ? 'app_insights.no_versions' - : 'app_insights.no_versions_match', - )} - - ) : ( + {rows.length > 0 ? (
    20 ? { pageSize: 20 } : false} scroll={{ x: 'max-content' }} expandable={{ expandedRowRender: (row) => , }} /> + ) : ( + + {funnel.isLoading + ? '' + : t( + allRows.length + ? 'app_insights.no_versions_match' + : 'app_insights.no_observations', + )} + )} - - {t('app_insights.funnel_footnote', { - hourlyFrom: data?.hourlyFrom ?? '-', - })} - {packageFilter !== ALL && ( - <> {t('app_insights.package_filter_note')} - )} - + {t('app_insights.rollback_only')} );