From 5206e049dfac0e19682f251a78a8bf73454412b8 Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Sun, 27 Sep 2026 14:10:05 +0800 Subject: [PATCH 01/12] fix(insights): separate reports, retained UUIDs and request windows Remove unsupported coverage/adoption conversion metrics, label rollback-only observations with sample counts, correct complete-day means and package data availability, consume uncapped server summaries and disclose legacy scopes. Update bilingual metric definitions and add arithmetic/render regressions. --- .github/workflows/ci.yml | 22 + docs/insights-metric-contract.md | 35 + src/i18n/index.ts | 15 +- src/i18n/insights-metrics.ts | 183 ++++++ src/pages/app-insights/failures-panel.tsx | 286 ++------ src/pages/app-insights/logic.test.ts | 562 ++++++---------- src/pages/app-insights/logic.ts | 493 ++++++++------ .../app-insights/observation-ui.test.tsx | 93 +++ src/pages/app-insights/observation-ui.tsx | 104 +++ src/pages/app-insights/overview-panel.tsx | 445 ++++--------- src/pages/app-insights/traffic-panel.tsx | 262 +++----- src/pages/app-insights/types.ts | 67 +- src/pages/app-insights/versions-panel.tsx | 611 ++++-------------- 13 files changed, 1381 insertions(+), 1797 deletions(-) create mode 100644 docs/insights-metric-contract.md create mode 100644 src/i18n/insights-metrics.ts create mode 100644 src/pages/app-insights/observation-ui.test.tsx create mode 100644 src/pages/app-insights/observation-ui.tsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d6f6721..d58c7d13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,7 @@ jobs: - uses: oven-sh/setup-bun@v2 - name: Install + id: install run: bun install --frozen-lockfile - name: Typecheck @@ -33,3 +34,24 @@ jobs: run: bun run build:check env: CI: "true" + + # Diagnostic only: the failed check remains failed. No suggested edits + # are committed, pushed or used to turn a failed gate into a passing one. + - name: Export insights formatting suggestions + if: failure() && steps.install.outcome == 'success' + run: | + bunx --no-install biome check --write src/pages/app-insights src/i18n/insights-metrics.ts src/i18n/index.ts > biome-suggestions.log 2>&1 || true + git diff -- src/pages/app-insights src/i18n > biome-suggestions.patch + + - name: Upload diagnostic copies + if: failure() && steps.install.outcome == 'success' + uses: actions/upload-artifact@v7 + with: + name: insights-format-suggestions + retention-days: 3 + path: | + biome-suggestions.patch + biome-suggestions.log + src/pages/app-insights + src/i18n/insights-metrics.ts + src/i18n/index.ts diff --git a/docs/insights-metric-contract.md b/docs/insights-metric-contract.md new file mode 100644 index 00000000..658cfbec --- /dev/null +++ b/docs/insights-metric-contract.md @@ -0,0 +1,35 @@ +# 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 stage 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. + +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. Their actual included/eligible day counts are shown. Requests, reports, UUID estimates and people are not interchangeable. + +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 is explicitly ranked by event volume, not release recency. + +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. Failure reason free-text prefixes from legacy servers are normalized to `other` before display. + +## 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 are grouped in `src/i18n/insights-metrics.ts` and registered over the legacy resource at initialization. Both languages have identical keys, checked in tests. + +## 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. Render tests cover rollback-only wording, hidden out-of-scope retained metrics, the former 250% scenario, legacy totals, stale windows and package observation states. + +CI still checks types, lint, tests and bundle size. On failure, a short-lived diagnostic artifact exports Biome suggestions and formatted copies; it never commits edits or makes a failed validation pass. + +## 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/i18n/index.ts b/src/i18n/index.ts index a35b2afe..284284e4 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -1,6 +1,7 @@ import i18n from 'i18next'; import LanguageDetector from 'i18next-browser-languagedetector'; import { initReactI18next } from 'react-i18next'; +import { insightsEn, insightsZh } from './insights-metrics'; import en from './locales/en.json'; import zhCN from './locales/zh-CN.json'; @@ -9,8 +10,18 @@ i18n .use(initReactI18next) .init({ resources: { - en: { translation: en }, - 'zh-CN': { translation: zhCN }, + en: { + translation: { + ...en, + app_insights: { ...en.app_insights, ...insightsEn }, + }, + }, + 'zh-CN': { + translation: { + ...zhCN, + app_insights: { ...zhCN.app_insights, ...insightsZh }, + }, + }, }, fallbackLng: 'zh-CN', interpolation: { diff --git a/src/i18n/insights-metrics.ts b/src/i18n/insights-metrics.ts new file mode 100644 index 00000000..61bca42b --- /dev/null +++ b/src/i18n/insights-metrics.ts @@ -0,0 +1,183 @@ +// Metric-contract copy is kept together so both languages share the same +// definitions. Registered over the legacy app_insights resource at startup. +export const insightsZh = { + view_versions: '版本事件', + live: '每分钟刷新', + today_requests: '今日查询热更请求', + today_dau: '今日查询热更设备', + dau: '查询热更设备', + dau_hint: '完成更新检查并上报 UUID 的设备估算,不是全部 App 用户', + peak_dau: '可用日桶中的峰值查询设备', + window_requests: '近 {{days}} 个日桶观测请求', + daily_average: '完整日均 {{value}}(可用 {{samples}} / {{days}} 日)', + dau_average: '完整日均 {{value}}(可用 {{samples}} / {{days}} 日)', + 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: '没有可用观测,不能据此判断为零或正常', + today_live: '今日未完成日桶', + daily_footnote: '按上方时区的自然日统计;今天尚未结束,缺失设备数据不补零。', + hit_uptodate: '未提供更新', + hit_hdiff: '提供 hdiff 选项', + hit_pdiff: '提供 pdiff 选项', + hit_full: '提供整包选项', + hit_paused: '暂停或检查受限', + hit_expired: '原生包已过期', + outcome_footnote: '未提供更新包括已运行目标版本和未绑定版本;暂停或受限可能来自应用、原生包或额度。提供下载选项不代表客户端已经下载。', + update_share_hint: '提供了更新选项的请求占比,不是升级成功率', + refusal_blocked: '构建信息不匹配:核对客户端构建与已登记原生包,不应仅为消除告警而关闭校验。', + refusal_unknown_package: '当前未匹配到已登记的原生包;核对 App Key、版本与原生包上传记录。', + refused_packages_unattributed: '另有 {{count}} 次无可用原生包拆分,可能来自历史采集或明细缺失。', + versions_glance_title: '事件量最高的版本', + versions_glance_question: '按窗口内提供目标与客户端事件总量排序,不是按发布时间排序。', + glance_line: '提供目标 {{served}} 次 · 激活报告 {{activated}} 次 · 下载/Patch 失败 {{failed}} 次 · 回滚 {{rollback}} 次', + observed_glance: '留存累计激活设备观测', + col_served: '提供目标次数', + col_downloaded: '下载成功事件', + col_activated: '激活成功事件', + col_download_fail: '下载失败事件', + col_patch_fail: 'Patch 失败事件', + col_rollback: '回滚事件', + col_rollback_rate: '回滚报告占比', + col_failure_rate: '下载/Patch 失败事件占比', + col_health: '回滚观测', + served_exp: '实验版本选项(不代表灰度命中)', + events_not_funnel: '各项是未关联的事件次数,不是转化漏斗。一次响应可提供主版本和实验选项;Patch 失败后整包成功会产生两类报告。', + funnel_table_title: '版本更新事件', + funnel_title: '独立事件计数', + retained_title: '留存累计设备与事件到达分布', + retained_scope: '整个热更版本的累计观测,不受天数筛选影响;不是当前正在运行该版本的设备。', + retained_unavailable_package: '累计设备和时间分布没有原生包维度,已在原生包筛选下隐藏。', + adopted_mark: '留存累计激活设备观测', + adopted_download: '留存累计下载设备观测', + retained_hint: 'HLL 近似去重;35 天不活动可过期重计,缺失 UUID 或报告可能导致遗漏。', + lag_download: '版本创建后下载成功报告到达时间', + lag_mark: '版本创建后激活成功报告到达时间', + lag_footnote: '按报告次数分布,不是首次激活设备数;起点是版本创建,不是绑定发布时间或下载开始。时间桶 90 天不活动可过期重计。', + rollback_low: '回滚报告占比较低', + rollback_warning: '回滚报告占比需关注', + rollback_high: '回滚报告占比较高', + insufficient_samples: '回滚观测样本不足({{count}} / {{minimum}})', + rollback_only: '仅描述回滚报告:回滚 ÷(激活成功 + 回滚),不代表整体健康。达到 10 个报告后按 1% / 5% 提示。', + totals_all: '应用整体观测汇总(不受下方筛选或前 50 个版本截断影响)', + totals_returned: '旧接口:仅返回版本的小计,不是应用整体总量', + versions_in_window: '有事件的版本数', + window_served: '提供目标选项总次数', + window_rollbacks: '回滚报告总次数', + totals_unattributed: '其中无法归属版本:提供目标 {{offers}} 次,客户端报告 {{reports}} 次(已包含在汇总中)', + filtered_note: '下表仅显示当前版本/原生包筛选;上方汇总仍为应用整体或返回版本小计。', + request_share: '请求占比', + packages_question: '哪些原生包在查询更新?请求占比不是装机占比。', + col_peak_devices: '可用观测日中的设备峰值', + col_availability: '设备观测范围与状态', + package_observed_days: '可用 {{count}} 日:{{start}} 至 {{end}}', + package_missing_days: '过期 {{expired}} 日;无可用观测 {{missing}} 日', + package_partial: '触及采集限制,部分数据', + packages_footnote: '请求保留 {{requests}} 天,设备观测保留 {{devices}} 天。峰值仅来自可用日,不代表整个所选窗口;没有设备观测不能解释为无人使用。', + hourly_footnote: '按上方业务时区小时累加。包含尚未结束的今天,各小时可观测时长可能不同。', + failure_events: '近 {{days}} 日桶已分类失败事件', + failure_events_hint: '来自原因分类聚合:下载失败、Patch 失败和回滚;不是失败设备数。', + worst_os: '失败事件占比最高的平台(至少 10 个报告)', + no_ranked_os: '没有达到样本门槛且失败占比大于零的平台', + rates_footnote: '失败事件占比 = (下载失败 + Patch 失败) ÷ (下载成功 + 下载失败 + Patch 失败)。回滚占比 = 回滚 ÷ (激活成功 + 回滚)。括号显示分子/分母;均不是最终升级失败率。', + reasons_footnote: '原因由上报内容启发式归类;其他不展示自由文本前缀。未提供原因不是具体根因。各维度独立聚合,可能因采集遗漏而不完全对齐。', + breakdown_footnote: '按上方业务时区日桶,最多保留 {{retention}} 天;仅包含支持遥测并成功上报的 SDK 事件,不能代表所有设备。', + version_deleted: '未命名或已删除的版本', + version_deleted_hint: '当前未解析到名称,不能仅凭空名称确认已删除。', +}; + +export const insightsEn: Record = { + view_versions: 'Version events', + live: 'Refreshes every minute', + today_requests: 'Update checks today', + today_dau: 'Devices checking for updates today', + dau: 'Devices checking for updates', + dau_hint: 'Estimated UUIDs in completed update checks, not all app users', + peak_dau: 'Peak devices among available day observations', + window_requests: 'Observed checks in {{days}} calendar buckets', + daily_average: 'Complete-day mean {{value}} (available {{samples}} / {{days}} days)', + dau_average: 'Complete-day mean {{value}} (available {{samples}} / {{days}} days)', + 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; zeroes may include uncollected data.', + 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', + today_live: 'Today, incomplete calendar bucket', + daily_footnote: 'Calendar days in the timezone above. Today is incomplete; missing device data is not filled with zero.', + 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', + 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.', + update_share_hint: 'Share of requests offering an update option, not upgrade success', + 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_unattributed: '{{count}} requests lack an available package breakdown, including historical or missing detail.', + versions_glance_title: 'Highest-volume versions', + versions_glance_question: 'Ranked by offered targets and client reports in the window, not by release time.', + glance_line: '{{served}} targets offered · {{activated}} activation reports · {{failed}} download/Patch failures · {{rollback}} rollbacks', + observed_glance: 'Retained cumulative activation UUID estimate', + col_served: 'Targets offered', + col_downloaded: 'Download-success reports', + col_activated: 'Activation-success reports', + col_download_fail: 'Download-failure reports', + col_patch_fail: 'Patch-failure reports', + col_rollback: 'Rollback reports', + col_rollback_rate: 'Rollback report share', + col_failure_rate: 'Download/Patch failure report share', + col_health: 'Rollback observations', + served_exp: 'Experimental option (not gray-rollout participation)', + 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.', + funnel_table_title: 'Version update events', + funnel_title: 'Independent event counts', + 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.', + adopted_mark: 'Retained cumulative activation UUID estimate', + adopted_download: 'Retained cumulative download UUID estimate', + retained_hint: 'Approximate HLL counts can reset after 35 inactive days. Missing UUIDs or reports can omit devices.', + lag_download: 'Download-success report arrival after version creation', + lag_mark: 'Activation-success report arrival after version creation', + 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.', + 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', + versions_in_window: 'Versions with events', + window_served: 'Total target options offered', + window_rollbacks: 'Total rollback reports', + 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', + packages_question: 'Which native packages check for updates? Request share is not installation share.', + col_peak_devices: 'Peak among available device observations', + 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', + 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.', + hourly_footnote: 'Hours in the business timezone above, summed across days. Today is incomplete, so hours can have unequal observation durations.', + failure_events: 'Classified failure reports in {{days}} calendar buckets', + failure_events_hint: 'Reason aggregates: download failures, Patch failures and rollbacks, not failed devices.', + worst_os: 'Highest failure report share (at least 10 reports)', + no_ranked_os: 'No platform has both enough samples and a positive failure 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.', + 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.', + 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.', +}; diff --git a/src/pages/app-insights/failures-panel.tsx b/src/pages/app-insights/failures-panel.tsx index 99fbd03f..6765576d 100644 --- a/src/pages/app-insights/failures-panel.tsx +++ b/src/pages/app-insights/failures-panel.tsx @@ -5,14 +5,15 @@ import { useTranslation } from 'react-i18next'; import { type DimensionRow, FAILURE_EVENT_TYPES, + highestFailureDimension, type ReasonRow, summarizeBreakdown, } from './logic'; +import { ObservationNotice, ReportShare } from './observation-ui'; import { EmptyState, Footnote, formatInteger, - formatPercent, formatShare, InsightsError, Question, @@ -23,16 +24,10 @@ 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, -}: { +const DimensionTable = ({ rows, labelOf, keyTitle }: { rows: DimensionRow[]; labelOf: (key: string) => string; keyTitle: string; @@ -40,296 +35,109 @@ const DimensionTable = ({ 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 ( - - ); + return
; }; -export const FailuresPanel = ({ - appKey, - days, -}: { - appKey: string | undefined; - days: number; -}) => { +export const FailuresPanel = ({ appKey, days }: { appKey: string | undefined; days: number }) => { const { t } = useTranslation(); const reasonLabel = useFailureReasonLabel(); const carrierLabel = useCarrierLabel(); const eventLabel = useEventTypeLabel(); const breakdown = useAppEventBreakdown(appKey, days); - const [versionFilter, setVersionFilter] = useState(ALL); - - const all = useMemo( - () => summarizeBreakdown(breakdown.data?.days), - [breakdown.data], - ); + const [versionFilter, setVersionFilter] = useState(ALL); + const all = useMemo(() => summarizeBreakdown(breakdown.data?.days), [breakdown.data]); const summary = useMemo( - () => - versionFilter === ALL - ? all - : summarizeBreakdown(breakdown.data?.days, versionFilter), + () => versionFilter === ALL ? all : 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 hasObservations = summary.os.length > 0 || summary.reasons.length > 0 || summary.carriers.length > 0; + 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)} - ), - }, - { - title: t('app_insights.col_count'), - key: 'count', - align: 'right', - width: 100, - render: (_, row) => ( - {formatInteger(row.count)} - ), - }, - { - title: t('app_insights.col_share'), - key: 'share', - width: 180, - render: (_, row) => ( -
- - - - - {formatShare(row.percent)} - -
- ), - }, + { title: t('app_insights.col_reason'), key: 'reason', render: (_, row) => reasonLabel(row.reason) }, + { title: t('app_insights.col_count'), dataIndex: 'count', align: 'right', render: formatInteger }, + { title: t('app_insights.col_share'), dataIndex: 'percent', align: 'right', render: formatShare }, { title: t('app_insights.col_event_type'), key: 'types', - render: (_, row) => ( - - {Object.entries(row.byType).map(([type, count]) => ( - - {eventLabel(type as keyof typeof row.byType)}{' '} - {formatInteger(count)} - - ))} - - ), + render: (_, row) => {Object.entries(row.byType).map(([type, count]) => {eventLabel(type as ClientEventType)} {formatInteger(count)})}, }, { title: t('app_insights.col_versions'), key: 'versions', render: (_, row) => ( - - {row.versions.slice(0, REASON_VERSION_LIMIT).map((version) => ( - - - - {formatInteger(version.count)} - - - ))} - {row.versions.length > REASON_VERSION_LIMIT && ( - - {t('app_insights.more_versions', { - count: row.versions.length - REASON_VERSION_LIMIT, - })} - - )} - +
+ {row.versions.slice(0, 3).map((version) =>
{formatInteger(version.count)}
)} + {row.versions.length > 3 && {t('app_insights.more_versions', { 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' }} - /> - )} + {summary.reasons.length > 0 ? ( +
10 ? { pageSize: 10 } : false} scroll={{ x: 'max-content' }} /> + ) : {breakdown.isLoading ? '' : t('app_insights.no_observations')}} {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')} - ) : ( - - {summary.carriers.length === 0 ? ( - - {breakdown.isLoading ? '' : t('app_insights.no_events')} - - ) : ( - - )} - - )} - - {t('app_insights.breakdown_footnote', { - retention: breakdown.data?.retentionDays ?? 35, - })} - + {versionFilter === ALL ? : {t('app_insights.carrier_no_version_filter')}} + {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 9c2f8a59..c9c4bbfa 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,403 +15,225 @@ import { summarizeBreakdown, summarizeTraffic, trafficWarnings, + versionTotals, } from './logic'; import type { AppEventBreakdownDay, AppTrafficDay, + FunnelEventCounts, + VersionFunnel, VersionFunnelResponse, } from './types'; -const trafficDay = ( - overrides: Partial & { date: string }, -): AppTrafficDay => ({ - 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 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: [], hit: {}, ipVersion: {}, hosts: {}, carriers: {}, 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'); - }); - - 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'); + expect(parseInsightView('versions')).toBe('versions'); + expect(parseInsightView(null)).toBe('overview'); + expect(beijingToday(Date.UTC(2026, 8, 26, 20))).toBe('2026-09-27'); }); -}); - -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 }, - ], - refused: [ - { outcome: 'unknown_package', packageVersion: '0.8', requests: 4 }, - { outcome: 'unknown_package', packageVersion: '0.7', requests: 6 }, - ], - }), - trafficDay({ date: '2026-09-10', requests: 0, dau: 0 }), - ]; - - 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 }, - ], - }); - expect(summary.daily.map((day) => day.date)).toEqual([ - '2026-09-10', - '2026-09-11', - '2026-09-12', - ]); - expect(summary.daily[2]?.isToday).toBe(true); +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 })], '2026-09-27'); + expect(result.averageDau).toBeCloseTo(100 / 7); + expect(result.averageDailyRequests).toBeCloseTo(100 / 7); + expect(result.dauSampleDays).toBe(7); + expect(result.requestSampleDays).toBe(7); + expect(result.completedDays).toBe(7); }); - - 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 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' }), + ], '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('flags the refusals that explain missing updates', () => { - const summary = summarizeTraffic(days, '2026-09-12'); - expect(trafficWarnings(summary.hit)).toEqual([ - 'blocked', - 'unknown_package', - ]); - expect(trafficWarnings(summarizeTraffic([]).hit)).toEqual([]); + 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 observations and valid numeric zero days usable', () => { + const result = summarizeTraffic([day('2026-09-25', { dau: 10 }), day('2026-09-26')], '2026-09-27'); + expect(result.averageDau).toBe(5); }); }); -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, - }), - ).toMatchObject({ - downloadSuccessRate: null, - // 样本不足不判定 - health: null, +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( - computeFunnelRates({ - downloadSuccess: 50, - downloadFail: 0, - patchFail: 0, - markSuccess: 50, - rollback: 0, - }).health, - ).toBe('healthy'); }); - - 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: [], - }, - ], - }; - 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( - buildFunnelRows({ ...response, dauToday: 0 })[0]?.coverage, - ).toBeNull(); - expect(buildFunnelRows(undefined)).toEqual([]); + 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('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); - expect(lagShares(null).total).toBe(0); + 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(result.carriers[0]?.percent).toBe(100); }); }); -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 }, - ]); - 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'); +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('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([]); + it('honors explicit null observations rather than falling back to adopted', () => { + const row = buildFunnelRows(response({ versions: [{ ...version(), observed: { mark: null, download: null } }] }))[0]!; + expect(row.retained?.mark).toBeNull(); + expect(row.retained?.download).toBeNull(); }); - - it('is empty-safe', () => { - expect(summarizeBreakdown(undefined).failures).toBe(0); + 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 }, + })); + 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(computeFunnelRates(events({ markSuccess: 3, rollback: 1 })).health).toBeNull(); + expect(computeFunnelRates(events({ markSuccess: 90, rollback: 10 })).health).toBe('critical'); + }); + 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); + }); +}); - it('separates known reasons from the other: bucket', () => { - expect(parseFailureReason('crc_mismatch')).toEqual({ - kind: 'known', - reason: 'crc_mismatch', - }); - expect(parseFailureReason('other:ENOENT')).toEqual({ - kind: 'other', - detail: 'ENOENT', - }); - expect(parseFailureReason('weird')).toEqual({ - kind: 'other', - detail: 'weird', - }); +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(result.os[0]?.failureSamples).toBe(2); + expect(result.os[0]?.failureRate).toBe(0.5); + expect(highestFailureDimension(result.os)).toBeNull(); + }); + 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('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: '' }); + 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 d8da5aac..187e5c05 100644 --- a/src/pages/app-insights/logic.ts +++ b/src/pages/app-insights/logic.ts @@ -1,15 +1,17 @@ -// 数据分析页的纯逻辑:把三个接口按日返回的数据折叠成窗口内的汇总、 -// 排名与比率。这里不碰 React,也不做文案,方便单测。 - +// Events, offered targets and retained devices have different populations. +// Do not synthesize conversion rates from independent observation counters. 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 +20,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 +40,34 @@ 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; + 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 +76,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,15 +84,16 @@ 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; } @@ -91,13 +101,17 @@ export interface DailyTrafficPoint { export interface PackageTrafficSummary { packageVersion: string; requests: number; - /** 窗口内单日最高的去重设备数(HLL 不能跨日合并)。 */ - peakDevices: number; + peakDevices: number | null; percent: number; + 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 +120,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 +149,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 +159,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 +171,117 @@ 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 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; - } + 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 dayRequests = observationCount(day.requests, day.requestsStatus); + const dau = observationCount(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) ?? { + packageVersion: item.packageVersion, requests: 0, - peakDevices: 0, + 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); + entry.requests += countOf(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 ?? []) { 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 +289,16 @@ export const summarizeTraffic = ( ipVersion: rankCounts(ipVersion), hosts: rankCounts(hosts), carriers: rankCounts(carriers), - packages: packageRows, + packages: Array.from(packages.values()) + .map((entry) => ({ + ...entry, + percent: percentOf(entry.requests, requests), + })) + .sort( + (left, right) => + right.requests - left.requests || + left.packageVersion.localeCompare(right.packageVersion), + ), refused: { blocked: rankRefused(refused.blocked), unknown_package: rankRefused(refused.unknown_package), @@ -272,7 +307,6 @@ export const summarizeTraffic = ( }; }; -/** hit 里值得提醒的项:这两类是"为什么收不到更新"的直接证据。 */ export const trafficWarnings = ( hit: Readonly>, ): Array<'blocked' | 'unknown_package'> => { @@ -284,82 +318,147 @@ 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 const MIN_EVENT_SAMPLES = 10; 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 >= 0.05 ? 'critical' - : rollbackRate >= WARNING_ROLLBACK + : rollbackRate >= 0.01 ? '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, + }; + }); + +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 +466,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 +490,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 +503,53 @@ 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 +557,14 @@ export interface ReasonRow { byType: Partial>; versions: ReasonVersion[]; } - export interface BreakdownSummary { - /** 失败事件总数(byReason 之和) */ 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 +581,22 @@ export const summarizeBreakdown = ( const carriers = new Map(); const versionNames = new Map(); let failures = 0; - for (const day of days ?? []) { 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 reason = item.reason.startsWith('other:') ? 'other' : item.reason; + const entry = reasons.get(reason) ?? { count: 0, byType: {} as Partial>, versions: new Map(), @@ -498,64 +611,49 @@ 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 { 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', @@ -578,12 +676,8 @@ export const parseFailureReason = ( | { kind: 'other'; detail: string } => 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', detail: '' }; -/** 服务端写入的运营商标签是中文字面量;界面上按语言翻译,未知标签原样显示。 */ export const KNOWN_CARRIERS = [ '电信', '联通', @@ -596,6 +690,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 00000000..f12f6cdb --- /dev/null +++ b/src/pages/app-insights/observation-ui.test.tsx @@ -0,0 +1,93 @@ +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 { insightsEn, insightsZh } from '@/i18n/insights-metrics'; +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 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('metric language overrides 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(); + 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 00000000..b6000b74 --- /dev/null +++ b/src/pages/app-insights/observation-ui.tsx @@ -0,0 +1,104 @@ +import { Alert, Tag, Tooltip } from 'antd'; +import { useTranslation } from 'react-i18next'; +import { computeFunnelRates, MIN_EVENT_SAMPLES } from './logic'; +import { formatInteger, formatPercent } from './shared'; +import type { FunnelEventCounts, 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 = ({ + events, +}: { + events: FunnelEventCounts; +}) => { + const { t } = useTranslation(); + const rates = computeFunnelRates(events); + const label = + rates.health === null + ? t('app_insights.insufficient_samples', { + count: rates.rollbackSamples, + minimum: MIN_EVENT_SAMPLES, + }) + : t( + rates.health === 'critical' + ? 'app_insights.rollback_high' + : rates.health === 'warning' + ? 'app_insights.rollback_warning' + : 'app_insights.rollback_low', + ); + return ( + + + + {label} + + + + + ); +}; diff --git a/src/pages/app-insights/overview-panel.tsx b/src/pages/app-insights/overview-panel.tsx index 936330db..c67c4d48 100644 --- a/src/pages/app-insights/overview-panel.tsx +++ b/src/pages/app-insights/overview-panel.tsx @@ -1,26 +1,25 @@ 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, 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 +28,8 @@ import { useHitOutcomeLabel, VersionLabel, } from './shared'; -import { HIT_OUTCOMES, type HitOutcome } from './types'; - -type DailyMetric = 'requests' | 'dau'; - -const OVERVIEW_VERSION_LIMIT = 5; -const REFUSED_PACKAGE_LIMIT = 10; +import { HIT_OUTCOMES } from './types'; -/** 被拒绝的原生包版本及各自请求数;旧日桶没有按版本拆分,差额单独说明。 */ const RefusedPackages = ({ items, total, @@ -45,56 +38,24 @@ 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); + const unattributed = 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), - })} - + {t('app_insights.refused_packages_unattributed', { count: unattributed })} )}
); }; -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,322 +68,164 @@ 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( + () => 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" - > - - {t('app_insights.daily_requests')} - + setDailyMetric(event.target.value)} size="small"> + {t('app_insights.daily_requests')} {t('app_insights.dau')} } > - - {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/traffic-panel.tsx b/src/pages/app-insights/traffic-panel.tsx index 73783099..bf9413c6 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,201 +19,120 @@ import { useCarrierLabel, } from './shared'; -export const TrafficPanel = ({ - appKey, - days, - isAdmin, -}: { +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, isAdmin }: { appKey: string | undefined; days: number; isAdmin: boolean; }) => { 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 ? ( - - ) : ( - - {traffic.isLoading ? '' : t('app_insights.no_traffic')} - - )} + {summary.requests > 0 ? ( + + ) : {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 0f0fb0e1..fd702d6a 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 fbca3857..41c5df01 100644 --- a/src/pages/app-insights/versions-panel.tsx +++ b/src/pages/app-insights/versions-panel.tsx @@ -4,17 +4,21 @@ import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { buildFunnelRows, - computeFunnelRates, + filterFunnelRows, type FunnelRow, 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 +26,9 @@ 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 +38,8 @@ const LAG_LABEL_KEY: Record = { gt7d: 'app_insights.lag_gt7d', }; +type EventRow = { served: ServedCounts; events: FunnelEventCounts }; + const ServedBreakdown = ({ served }: { served: ServedCounts }) => { const { t } = useTranslation(); const parts: Array<[string, number]> = [ @@ -44,528 +49,172 @@ const ServedBreakdown = ({ served }: { served: ServedCounts }) => { [t('app_insights.served_full_pending'), served.fullPending], [t('app_insights.served_exp'), served.exp], ]; - return ( -
    - {parts.map(([label, count]) => ( -
    - {label} - {formatInteger(count)} -
    - ))} -
    - ); + return
    {parts.map(([label, 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 = ({ - title, - buckets, -}: { - title: string; - buckets: FunnelRow['lag']['downloadSuccess']; -}) => { +const LagTable = ({ title, buckets }: { title: string; 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')}
    - ) : ( -
      - {shares.map((share) => ( -
    • - - {t(LAG_LABEL_KEY[share.bucket])} - - - 0 ? 2 : 0)}%`, - }} - /> - - - {formatShare(share.percent)} - -
    • - ))} -
    - )} +

    {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 }, + ]} + /> + ) : {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 packageColumns = useEventColumns(); 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')} +
    + {row.retained ? ( + + {t('app_insights.retained_scope')} +
    + +
    - -
    -
    - {t('app_insights.by_package_title')} + {t('app_insights.retained_hint')} +
    + +
    - {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.lag_footnote')} -
    -
    + {t('app_insights.lag_footnote')} + + ) : ( + + )} ); }; -export const VersionsPanel = ({ - appKey, - days, -}: { - appKey: string | undefined; - days: number; -}) => { +export const VersionsPanel = ({ appKey, days }: { appKey: string | undefined; days: number }) => { 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, - 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), - }, + { title: t('app_insights.col_version'), key: 'version', fixed: 'left', width: 220, render: (_, row) => }, + ...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')} +
    + + + +
    + + {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.funnel_table_question')} + {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) => , - }} + 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')} ); From 9b657430906c86829c73f9f832d96131871e0b87 Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Sun, 27 Sep 2026 14:23:51 +0800 Subject: [PATCH 02/12] fix(insights): satisfy strict query-error typing and format regression coverage Render unknown query errors through boolean guards, keep arithmetic assertions compatible with the repository's test types, and apply the project's formatter. No lint, test, typecheck or bundle-size gate is disabled. --- src/i18n/insights-metrics.ts | 174 ++++++--- src/pages/app-insights/failures-panel.tsx | 192 ++++++++-- src/pages/app-insights/logic.test.ts | 339 ++++++++++++++---- src/pages/app-insights/logic.ts | 13 +- .../app-insights/observation-ui.test.tsx | 123 +++++-- src/pages/app-insights/observation-ui.tsx | 4 +- src/pages/app-insights/overview-panel.tsx | 108 ++++-- src/pages/app-insights/traffic-panel.tsx | 126 +++++-- src/pages/app-insights/versions-panel.tsx | 204 +++++++++-- 9 files changed, 1011 insertions(+), 272 deletions(-) diff --git a/src/i18n/insights-metrics.ts b/src/i18n/insights-metrics.ts index 61bca42b..fe08a2b4 100644 --- a/src/i18n/insights-metrics.ts +++ b/src/i18n/insights-metrics.ts @@ -11,14 +11,17 @@ export const insightsZh = { window_requests: '近 {{days}} 个日桶观测请求', daily_average: '完整日均 {{value}}(可用 {{samples}} / {{days}} 日)', dau_average: '完整日均 {{value}}(可用 {{samples}} / {{days}} 日)', - means_note: '两个日均都排除今天;有效零值计入,缺失值排除。可用日数不代表采集完整。', + means_note: + '两个日均都排除今天;有效零值计入,缺失值排除。可用日数不代表采集完整。', window_exact: '{{timezone}}:{{start}} 至 {{end}}(起含止不含)', window_utc_legacy: '旧接口:版本事件按 UTC 自然日;未提供精确窗口元数据。', - window_business_legacy: '旧接口未提供业务时区与可用性元数据;今日标记暂按 UTC+8,零值可能包含未采集数据。', + window_business_legacy: + '旧接口未提供业务时区与可用性元数据;今日标记暂按 UTC+8,零值可能包含未采集数据。', last_refreshed: '最近成功读取:{{time}};页面每分钟刷新', not_loaded: '尚未读取到数据', stale_data: '刷新失败,以下为上次成功读取的数据', - best_effort: '以下为尽力采集的运行观测,可能重复或遗漏;无数据不等于没有设备、没有故障。', + best_effort: + '以下为尽力采集的运行观测,可能重复或遗漏;无数据不等于没有设备、没有故障。', no_observations: '没有可用观测,不能据此判断为零或正常', today_live: '今日未完成日桶', daily_footnote: '按上方时区的自然日统计;今天尚未结束,缺失设备数据不补零。', @@ -28,14 +31,20 @@ export const insightsZh = { hit_full: '提供整包选项', hit_paused: '暂停或检查受限', hit_expired: '原生包已过期', - outcome_footnote: '未提供更新包括已运行目标版本和未绑定版本;暂停或受限可能来自应用、原生包或额度。提供下载选项不代表客户端已经下载。', + outcome_footnote: + '未提供更新包括已运行目标版本和未绑定版本;暂停或受限可能来自应用、原生包或额度。提供下载选项不代表客户端已经下载。', update_share_hint: '提供了更新选项的请求占比,不是升级成功率', - refusal_blocked: '构建信息不匹配:核对客户端构建与已登记原生包,不应仅为消除告警而关闭校验。', - refusal_unknown_package: '当前未匹配到已登记的原生包;核对 App Key、版本与原生包上传记录。', - refused_packages_unattributed: '另有 {{count}} 次无可用原生包拆分,可能来自历史采集或明细缺失。', + refusal_blocked: + '构建信息不匹配:核对客户端构建与已登记原生包,不应仅为消除告警而关闭校验。', + refusal_unknown_package: + '当前未匹配到已登记的原生包;核对 App Key、版本与原生包上传记录。', + refused_packages_unattributed: + '另有 {{count}} 次无可用原生包拆分,可能来自历史采集或明细缺失。', versions_glance_title: '事件量最高的版本', - versions_glance_question: '按窗口内提供目标与客户端事件总量排序,不是按发布时间排序。', - glance_line: '提供目标 {{served}} 次 · 激活报告 {{activated}} 次 · 下载/Patch 失败 {{failed}} 次 · 回滚 {{rollback}} 次', + versions_glance_question: + '按窗口内提供目标与客户端事件总量排序,不是按发布时间排序。', + glance_line: + '提供目标 {{served}} 次 · 激活报告 {{activated}} 次 · 下载/Patch 失败 {{failed}} 次 · 回滚 {{rollback}} 次', observed_glance: '留存累计激活设备观测', col_served: '提供目标次数', col_downloaded: '下载成功事件', @@ -47,30 +56,38 @@ export const insightsZh = { col_failure_rate: '下载/Patch 失败事件占比', col_health: '回滚观测', served_exp: '实验版本选项(不代表灰度命中)', - events_not_funnel: '各项是未关联的事件次数,不是转化漏斗。一次响应可提供主版本和实验选项;Patch 失败后整包成功会产生两类报告。', + events_not_funnel: + '各项是未关联的事件次数,不是转化漏斗。一次响应可提供主版本和实验选项;Patch 失败后整包成功会产生两类报告。', funnel_table_title: '版本更新事件', funnel_title: '独立事件计数', retained_title: '留存累计设备与事件到达分布', - retained_scope: '整个热更版本的累计观测,不受天数筛选影响;不是当前正在运行该版本的设备。', - retained_unavailable_package: '累计设备和时间分布没有原生包维度,已在原生包筛选下隐藏。', + retained_scope: + '整个热更版本的累计观测,不受天数筛选影响;不是当前正在运行该版本的设备。', + retained_unavailable_package: + '累计设备和时间分布没有原生包维度,已在原生包筛选下隐藏。', adopted_mark: '留存累计激活设备观测', adopted_download: '留存累计下载设备观测', - retained_hint: 'HLL 近似去重;35 天不活动可过期重计,缺失 UUID 或报告可能导致遗漏。', + retained_hint: + 'HLL 近似去重;35 天不活动可过期重计,缺失 UUID 或报告可能导致遗漏。', lag_download: '版本创建后下载成功报告到达时间', lag_mark: '版本创建后激活成功报告到达时间', - lag_footnote: '按报告次数分布,不是首次激活设备数;起点是版本创建,不是绑定发布时间或下载开始。时间桶 90 天不活动可过期重计。', + lag_footnote: + '按报告次数分布,不是首次激活设备数;起点是版本创建,不是绑定发布时间或下载开始。时间桶 90 天不活动可过期重计。', rollback_low: '回滚报告占比较低', rollback_warning: '回滚报告占比需关注', rollback_high: '回滚报告占比较高', insufficient_samples: '回滚观测样本不足({{count}} / {{minimum}})', - rollback_only: '仅描述回滚报告:回滚 ÷(激活成功 + 回滚),不代表整体健康。达到 10 个报告后按 1% / 5% 提示。', + rollback_only: + '仅描述回滚报告:回滚 ÷(激活成功 + 回滚),不代表整体健康。达到 10 个报告后按 1% / 5% 提示。', totals_all: '应用整体观测汇总(不受下方筛选或前 50 个版本截断影响)', totals_returned: '旧接口:仅返回版本的小计,不是应用整体总量', versions_in_window: '有事件的版本数', window_served: '提供目标选项总次数', window_rollbacks: '回滚报告总次数', - totals_unattributed: '其中无法归属版本:提供目标 {{offers}} 次,客户端报告 {{reports}} 次(已包含在汇总中)', - filtered_note: '下表仅显示当前版本/原生包筛选;上方汇总仍为应用整体或返回版本小计。', + totals_unattributed: + '其中无法归属版本:提供目标 {{offers}} 次,客户端报告 {{reports}} 次(已包含在汇总中)', + filtered_note: + '下表仅显示当前版本/原生包筛选;上方汇总仍为应用整体或返回版本小计。', request_share: '请求占比', packages_question: '哪些原生包在查询更新?请求占比不是装机占比。', col_peak_devices: '可用观测日中的设备峰值', @@ -78,15 +95,21 @@ export const insightsZh = { package_observed_days: '可用 {{count}} 日:{{start}} 至 {{end}}', package_missing_days: '过期 {{expired}} 日;无可用观测 {{missing}} 日', package_partial: '触及采集限制,部分数据', - packages_footnote: '请求保留 {{requests}} 天,设备观测保留 {{devices}} 天。峰值仅来自可用日,不代表整个所选窗口;没有设备观测不能解释为无人使用。', - hourly_footnote: '按上方业务时区小时累加。包含尚未结束的今天,各小时可观测时长可能不同。', + packages_footnote: + '请求保留 {{requests}} 天,设备观测保留 {{devices}} 天。峰值仅来自可用日,不代表整个所选窗口;没有设备观测不能解释为无人使用。', + hourly_footnote: + '按上方业务时区小时累加。包含尚未结束的今天,各小时可观测时长可能不同。', failure_events: '近 {{days}} 日桶已分类失败事件', - failure_events_hint: '来自原因分类聚合:下载失败、Patch 失败和回滚;不是失败设备数。', + failure_events_hint: + '来自原因分类聚合:下载失败、Patch 失败和回滚;不是失败设备数。', worst_os: '失败事件占比最高的平台(至少 10 个报告)', no_ranked_os: '没有达到样本门槛且失败占比大于零的平台', - rates_footnote: '失败事件占比 = (下载失败 + Patch 失败) ÷ (下载成功 + 下载失败 + Patch 失败)。回滚占比 = 回滚 ÷ (激活成功 + 回滚)。括号显示分子/分母;均不是最终升级失败率。', - reasons_footnote: '原因由上报内容启发式归类;其他不展示自由文本前缀。未提供原因不是具体根因。各维度独立聚合,可能因采集遗漏而不完全对齐。', - breakdown_footnote: '按上方业务时区日桶,最多保留 {{retention}} 天;仅包含支持遥测并成功上报的 SDK 事件,不能代表所有设备。', + rates_footnote: + '失败事件占比 = (下载失败 + Patch 失败) ÷ (下载成功 + 下载失败 + Patch 失败)。回滚占比 = 回滚 ÷ (激活成功 + 回滚)。括号显示分子/分母;均不是最终升级失败率。', + reasons_footnote: + '原因由上报内容启发式归类;其他不展示自由文本前缀。未提供原因不是具体根因。各维度独立聚合,可能因采集遗漏而不完全对齐。', + breakdown_footnote: + '按上方业务时区日桶,最多保留 {{retention}} 天;仅包含支持遥测并成功上报的 SDK 事件,不能代表所有设备。', version_deleted: '未命名或已删除的版本', version_deleted_hint: '当前未解析到名称,不能仅凭空名称确认已删除。', }; @@ -100,33 +123,49 @@ export const insightsEn: Record = { dau_hint: 'Estimated UUIDs in completed update checks, not all app users', peak_dau: 'Peak devices among available day observations', window_requests: 'Observed checks in {{days}} calendar buckets', - daily_average: 'Complete-day mean {{value}} (available {{samples}} / {{days}} days)', - dau_average: 'Complete-day mean {{value}} (available {{samples}} / {{days}} days)', - 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; zeroes may include uncollected data.', + daily_average: + 'Complete-day mean {{value}} (available {{samples}} / {{days}} days)', + dau_average: + 'Complete-day mean {{value}} (available {{samples}} / {{days}} days)', + 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; zeroes may include uncollected data.', 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', + 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', today_live: 'Today, incomplete calendar bucket', - daily_footnote: 'Calendar days in the timezone above. Today is incomplete; missing device data is not filled with zero.', + daily_footnote: + 'Calendar days in the timezone above. Today is incomplete; missing device data is not filled with zero.', 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', - 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.', - update_share_hint: 'Share of requests offering an update option, not upgrade success', - 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_unattributed: '{{count}} requests lack an available package breakdown, including historical or missing detail.', + 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.', + update_share_hint: + 'Share of requests offering an update option, not upgrade success', + 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_unattributed: + '{{count}} requests lack an available package breakdown, including historical or missing detail.', versions_glance_title: 'Highest-volume versions', - versions_glance_question: 'Ranked by offered targets and client reports in the window, not by release time.', - glance_line: '{{served}} targets offered · {{activated}} activation reports · {{failed}} download/Patch failures · {{rollback}} rollbacks', + versions_glance_question: + 'Ranked by offered targets and client reports in the window, not by release time.', + glance_line: + '{{served}} targets offered · {{activated}} activation reports · {{failed}} download/Patch failures · {{rollback}} rollbacks', observed_glance: 'Retained cumulative activation UUID estimate', col_served: 'Targets offered', col_downloaded: 'Download-success reports', @@ -138,46 +177,65 @@ export const insightsEn: Record = { col_failure_rate: 'Download/Patch failure report share', col_health: 'Rollback observations', served_exp: 'Experimental option (not gray-rollout participation)', - 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.', + 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.', funnel_table_title: 'Version update events', funnel_title: 'Independent event counts', 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_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.', adopted_mark: 'Retained cumulative activation UUID estimate', adopted_download: 'Retained cumulative download UUID estimate', - retained_hint: 'Approximate HLL counts can reset after 35 inactive days. Missing UUIDs or reports can omit devices.', + retained_hint: + 'Approximate HLL counts can reset after 35 inactive days. Missing UUIDs or reports can omit devices.', lag_download: 'Download-success report arrival after version creation', lag_mark: 'Activation-success report arrival after version creation', - 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.', + 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.', 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', + 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', versions_in_window: 'Versions with events', window_served: 'Total target options offered', window_rollbacks: 'Total rollback reports', - 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.', + 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', - packages_question: 'Which native packages check for updates? Request share is not installation share.', + packages_question: + 'Which native packages check for updates? Request share is not installation share.', col_peak_devices: 'Peak among available device observations', 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_missing_days: + '{{expired}} expired days; {{missing}} unavailable days', package_partial: 'Collection limit reached; partial data', - 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.', - hourly_footnote: 'Hours in the business timezone above, summed across days. Today is incomplete, so hours can have unequal observation durations.', + 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.', + hourly_footnote: + 'Hours in the business timezone above, summed across days. Today is incomplete, so hours can have unequal observation durations.', failure_events: 'Classified failure reports in {{days}} calendar buckets', - failure_events_hint: 'Reason aggregates: download failures, Patch failures and rollbacks, not failed devices.', + failure_events_hint: + 'Reason aggregates: download failures, Patch failures and rollbacks, not failed devices.', worst_os: 'Highest failure report share (at least 10 reports)', - no_ranked_os: 'No platform has both enough samples and a positive failure 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.', - 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.', - breakdown_footnote: 'Business calendar buckets above, retained up to {{retention}} days. Only supported SDK events successfully reported are observed; not all devices.', + no_ranked_os: + 'No platform has both enough samples and a positive failure 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.', + 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.', + 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.', }; diff --git a/src/pages/app-insights/failures-panel.tsx b/src/pages/app-insights/failures-panel.tsx index 6765576d..ba6f3fa7 100644 --- a/src/pages/app-insights/failures-panel.tsx +++ b/src/pages/app-insights/failures-panel.tsx @@ -27,7 +27,11 @@ import { import { CLIENT_EVENT_TYPES, type ClientEventType } from './types'; const ALL = '__all__'; -const DimensionTable = ({ rows, labelOf, keyTitle }: { +const DimensionTable = ({ + rows, + labelOf, + keyTitle, +}: { rows: DimensionRow[]; labelOf: (key: string) => string; keyTitle: string; @@ -41,7 +45,13 @@ const DimensionTable = ({ rows, labelOf, keyTitle }: { key: type, align: 'right' as const, render: (_: unknown, row: DimensionRow) => ( - 0 ? 'text-red-500' : undefined}> + 0 + ? 'text-red-500' + : undefined + } + > {formatInteger(row.counts[type])} ), @@ -49,56 +59,126 @@ const DimensionTable = ({ rows, labelOf, keyTitle }: { { title: t('app_insights.col_failure_rate'), key: 'failureShare', - render: (_, row) => , + render: (_, row) => ( + + ), }, { title: t('app_insights.col_rollback_rate'), key: 'rollbackShare', - render: (_, row) => , + render: (_, row) => ( + + ), }, ]; - return
    ; + return ( +
    + ); }; -export const FailuresPanel = ({ appKey, days }: { appKey: string | undefined; days: number }) => { +export const FailuresPanel = ({ + appKey, + days, +}: { + appKey: string | undefined; + days: number; +}) => { const { t } = useTranslation(); const reasonLabel = useFailureReasonLabel(); const carrierLabel = useCarrierLabel(); const eventLabel = useEventTypeLabel(); const breakdown = useAppEventBreakdown(appKey, days); const [versionFilter, setVersionFilter] = useState(ALL); - const all = useMemo(() => summarizeBreakdown(breakdown.data?.days), [breakdown.data]); + const all = useMemo( + () => summarizeBreakdown(breakdown.data?.days), + [breakdown.data], + ); const summary = useMemo( - () => versionFilter === ALL ? all : summarizeBreakdown(breakdown.data?.days, versionFilter), + () => + versionFilter === ALL + ? all + : summarizeBreakdown(breakdown.data?.days, versionFilter), [all, breakdown.data, versionFilter], ); - const hasObservations = summary.os.length > 0 || summary.reasons.length > 0 || summary.carriers.length > 0; + const hasObservations = + summary.os.length > 0 || + summary.reasons.length > 0 || + summary.carriers.length > 0; 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) }, - { title: t('app_insights.col_count'), dataIndex: 'count', align: 'right', render: formatInteger }, - { title: t('app_insights.col_share'), dataIndex: 'percent', align: 'right', render: formatShare }, + { + title: t('app_insights.col_reason'), + key: 'reason', + render: (_, row) => reasonLabel(row.reason), + }, + { + title: t('app_insights.col_count'), + dataIndex: 'count', + align: 'right', + render: formatInteger, + }, + { + title: t('app_insights.col_share'), + dataIndex: 'percent', + align: 'right', + render: formatShare, + }, { title: t('app_insights.col_event_type'), key: 'types', - render: (_, row) => {Object.entries(row.byType).map(([type, count]) => {eventLabel(type as ClientEventType)} {formatInteger(count)})}, + render: (_, row) => ( + + {Object.entries(row.byType).map(([type, count]) => ( + + {eventLabel(type as ClientEventType)} {formatInteger(count)} + + ))} + + ), }, { title: t('app_insights.col_versions'), key: 'versions', render: (_, row) => (
    - {row.versions.slice(0, 3).map((version) =>
    {formatInteger(version.count)}
    )} - {row.versions.length > 3 && {t('app_insights.more_versions', { count: row.versions.length - 3 })}} + {row.versions.slice(0, 3).map((version) => ( +
    + {' '} + {formatInteger(version.count)} +
    + ))} + {row.versions.length > 3 && ( + + {t('app_insights.more_versions', { + count: row.versions.length - 3, + })} + + )}
    ), }, ]; return (
    - {breakdown.error && } - + {!!breakdown.error && } +
    {t('app_insights.failures_intro')}
    10 ? { pageSize: 10 } : false} scroll={{ x: 'max-content' }} /> - ) : {breakdown.isLoading ? '' : t('app_insights.no_observations')}} +
    10 ? { pageSize: 10 } : false + } + scroll={{ x: 'max-content' }} + /> + ) : ( + + {breakdown.isLoading ? '' : t('app_insights.no_observations')} + + )} {t('app_insights.reasons_footnote')} - key} /> + key} + /> {t('app_insights.rates_footnote')} - {versionFilter === ALL ? : {t('app_insights.carrier_no_version_filter')}} + {versionFilter === ALL ? ( + + ) : ( + {t('app_insights.carrier_no_version_filter')} + )} {t('app_insights.rates_footnote')} - {t('app_insights.breakdown_footnote', { retention: breakdown.data?.retentionDays ?? 35 })} + + {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 c9c4bbfa..ad455965 100644 --- a/src/pages/app-insights/logic.test.ts +++ b/src/pages/app-insights/logic.test.ts @@ -25,25 +25,66 @@ import type { VersionFunnelResponse, } from './types'; -const events = (overrides: Partial = {}): FunnelEventCounts => ({ - downloadSuccess: 0, downloadFail: 0, patchFail: 0, markSuccess: 0, rollback: 0, ...overrides, +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 }, + 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 }) }], + 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 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: [], hit: {}, ipVersion: {}, hosts: {}, carriers: {}, packages: [], ...overrides, +const day = ( + date: string, + overrides: Partial = {}, +): AppTrafficDay => ({ + date, + requests: 0, + dau: 0, + hourly: [], + hit: {}, + ipVersion: {}, + hosts: {}, + carriers: {}, + packages: [], + ...overrides, }); -const breakdown = (overrides: Partial = {}): AppEventBreakdownDay => ({ - date: '2026-09-26', byOS: [], byReason: [], byCarrier: [], ...overrides, +const breakdown = ( + overrides: Partial = {}, +): AppEventBreakdownDay => ({ + date: '2026-09-26', + byOS: [], + byReason: [], + byCarrier: [], + ...overrides, }); describe('parameters and basic aggregation', () => { @@ -56,7 +97,8 @@ describe('parameters and basic aggregation', () => { }); 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 }, + { key: 'a', count: 2, percent: 50 }, + { key: 'b', count: 2, percent: 50 }, ]); expect(rankCounts(undefined)).toEqual([]); }); @@ -64,23 +106,48 @@ describe('parameters and basic aggregation', () => { 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 })], '2026-09-27'); - expect(result.averageDau).toBeCloseTo(100 / 7); - expect(result.averageDailyRequests).toBeCloseTo(100 / 7); + 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 }), + ], + '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' }), - ], '2026-09-27'); + 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', + }), + ], + '2026-09-27', + ); expect(result.averageDailyRequests).toBe(5); expect(result.averageDau).toBe(2.5); expect(result.dauSampleDays).toBe(2); @@ -91,7 +158,10 @@ describe('complete-day means and missing observations', () => { 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'); + const result = summarizeTraffic( + [day('2026-09-26', { dau: 9 })], + '2026-09-26', + ); expect(result.today?.dau).toBe(9); expect(result.averageDau).toBeNull(); }); @@ -104,38 +174,91 @@ describe('complete-day means and missing observations', () => { expect(result.hourly).toHaveLength(24); }); it('keeps legacy positive observations and valid numeric zero days usable', () => { - const result = summarizeTraffic([day('2026-09-25', { dau: 10 }), day('2026-09-26')], '2026-09-27'); + const result = summarizeTraffic( + [day('2026-09-25', { dau: 10 }), day('2026-09-26')], + '2026-09-27', + ); expect(result.averageDau).toBe(5); }); }); 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'); + 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, + requests: 130, + peakDevices: 3, + percent: 100, + observedDays: 2, + availableStart: '2026-09-26', + availableEnd: '2026-09-27', + partial: true, + expiredDays: 1, }); }); 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 }] })]); + 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('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 }, - })]); + 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(result.refused.blocked).toEqual([ + { packageVersion: '1.0', requests: 2 }, + ]); expect(result.carriers[0]?.percent).toBe(100); }); }); @@ -149,7 +272,11 @@ describe('independent version observations', () => { 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 } }] }))[0]!; + const row = buildFunnelRows( + response({ + versions: [{ ...version(), observed: { mark: null, download: null } }], + }), + )[0]!; expect(row.retained?.mark).toBeNull(); expect(row.retained?.download).toBeNull(); }); @@ -164,10 +291,18 @@ describe('independent version observations', () => { 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 result = versionTotals( + response({ + truncated: true, + summary: { + versionCount: 51, + offeredTargets: 52, + events: events({ rollback: 7 }), + unattributed: events({ rollback: 7 }), + unattributedOffers: 0, + }, + }), + ); expect(result?.versionCount).toBe(51); expect(result?.events.rollback).toBe(7); expect(result?.scope).toBe('all_observed'); @@ -180,12 +315,18 @@ describe('independent version observations', () => { expect(versionTotals(undefined)).toBeNull(); }); it('classifies only rollback reports and exposes sample counts separately', () => { - const result = computeFunnelRates(events({ downloadFail: 90, markSuccess: 10 })); + const result = computeFunnelRates( + events({ downloadFail: 90, markSuccess: 10 }), + ); expect(result.rollbackSamples).toBe(10); expect(result.failures).toBe(90); expect(result.rollbackRate).toBe(0); - expect(computeFunnelRates(events({ markSuccess: 3, rollback: 1 })).health).toBeNull(); - expect(computeFunnelRates(events({ markSuccess: 90, rollback: 10 })).health).toBe('critical'); + expect( + computeFunnelRates(events({ markSuccess: 3, rollback: 1 })).health, + ).toBeNull(); + expect( + computeFunnelRates(events({ markSuccess: 90, rollback: 10 })).health, + ).toBe('critical'); }); it('keeps lag reports distinct and discards invalid values', () => { const result = lagShares({ lt1h: 30, '6h-24h': 10, gt7d: Number.NaN }); @@ -198,32 +339,86 @@ describe('independent version observations', () => { 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 }, - ] })]); + 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(result.os[0]?.failureSamples).toBe(2); expect(result.os[0]?.failureRate).toBe(0.5); expect(highestFailureDimension(result.os)).toBeNull(); }); 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 }, - ] })]); + 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(); + expect( + highestFailureDimension(result.os.filter((row) => row.key === 'android')), + ).toBeNull(); }); 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 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); @@ -231,8 +426,14 @@ describe('diagnostic report shares, not attempt failure rates', () => { 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: '' }); - expect(parseFailureReason('timeout')).toEqual({ kind: 'known', reason: 'timeout' }); + expect(parseFailureReason('other:private')).toEqual({ + kind: 'other', + detail: '', + }); + 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 187e5c05..0ab4ad51 100644 --- a/src/pages/app-insights/logic.ts +++ b/src/pages/app-insights/logic.ts @@ -429,9 +429,7 @@ export const filterFunnelRows = ( : []; }); -export const versionTotals = ( - response: VersionFunnelResponse | undefined, -) => { +export const versionTotals = (response: VersionFunnelResponse | undefined) => { if (!response) return null; if (response.summary) { return { ...response.summary, scope: 'all_observed' as const }; @@ -535,8 +533,7 @@ export const highestFailureDimension = (rows: readonly DimensionRow[]) => [...rows] .filter( (row) => - row.failureSamples >= MIN_EVENT_SAMPLES && - (row.failureRate ?? 0) > 0, + row.failureSamples >= MIN_EVENT_SAMPLES && (row.failureRate ?? 0) > 0, ) .sort( (left, right) => @@ -644,9 +641,9 @@ export const summarizeBreakdown = ( (a, b) => b.count - a.count || a.hash.localeCompare(b.hash), ), })).sort((a, b) => b.count - a.count || a.reason.localeCompare(b.reason)), - os: Array.from(os, ([key, counts]) => - finishDimensionRow(key, counts), - ).sort(byCount), + os: Array.from(os, ([key, counts]) => finishDimensionRow(key, counts)).sort( + byCount, + ), carriers: Array.from(carriers, ([key, counts]) => finishDimensionRow(key, counts), ).sort(byCount), diff --git a/src/pages/app-insights/observation-ui.test.tsx b/src/pages/app-insights/observation-ui.test.tsx index f12f6cdb..27208f81 100644 --- a/src/pages/app-insights/observation-ui.test.tsx +++ b/src/pages/app-insights/observation-ui.test.tsx @@ -26,17 +26,46 @@ afterEach(() => { 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 } }], - }], + 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('metric language overrides have identical keys', () => { - expect(Object.keys(insightsEn).sort()).toEqual(Object.keys(insightsZh).sort()); + 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'); }); @@ -47,14 +76,28 @@ test('low rollback does not claim overall health and tiny samples are explicit', 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(); + 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.getByText(insightsEn.retained_unavailable_package), + ).not.toBeNull(); expect(screen.queryByText(insightsEn.retained_title)).toBeNull(); }); @@ -66,28 +109,66 @@ test('retained counts are rendered without a coverage or adoption percentage', ( }); test('legacy table labels returned-version totals and keeps download failures visible', () => { - const client = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false } } }); + const client = new QueryClient({ + defaultOptions: { queries: { staleTime: Infinity, retry: false } }, + }); clients.push(client); - client.setQueryData(metricsKeys.appVersionFunnel('metric-test', 7), { ...fixture(), truncated: true }); - render(); + 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(); + 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(); + 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 index b6000b74..f6268ebb 100644 --- a/src/pages/app-insights/observation-ui.tsx +++ b/src/pages/app-insights/observation-ui.tsx @@ -18,9 +18,7 @@ export const ObservationNotice = ({ const { t } = useTranslation(); return (
    - {stale && ( - - )} + {stale && }
    {window ? t('app_insights.window_exact', { diff --git a/src/pages/app-insights/overview-panel.tsx b/src/pages/app-insights/overview-panel.tsx index c67c4d48..bdccc38b 100644 --- a/src/pages/app-insights/overview-panel.tsx +++ b/src/pages/app-insights/overview-panel.tsx @@ -38,7 +38,8 @@ const RefusedPackages = ({ total: number; }) => { const { t } = useTranslation(); - const unattributed = total - items.reduce((sum, row) => sum + row.requests, 0); + const unattributed = + total - items.reduce((sum, row) => sum + row.requests, 0); return (
    {items.slice(0, 10).map((row) => ( @@ -47,10 +48,18 @@ const RefusedPackages = ({ ))} {items.length > 10 && ( - {t('app_insights.refused_packages_more', { count: items.length - 10 })} + + {t('app_insights.refused_packages_more', { + count: items.length - 10, + })} + )} {unattributed > 0 && ( - {t('app_insights.refused_packages_unattributed', { count: unattributed })} + + {t('app_insights.refused_packages_unattributed', { + count: unattributed, + })} + )}
    ); @@ -68,7 +77,9 @@ export const OverviewPanel = ({ const { t } = useTranslation(); const { isDark } = useThemeMode(); const hitLabel = useHitOutcomeLabel(); - const [dailyMetric, setDailyMetric] = useState<'requests' | 'dau'>('requests'); + const [dailyMetric, setDailyMetric] = useState<'requests' | 'dau'>( + 'requests', + ); const traffic = useAppTraffic(appKey, days); const funnel = useAppVersionFunnel(appKey, days); const summary = useMemo( @@ -96,14 +107,15 @@ export const OverviewPanel = ({ key, label: hitLabel(key), count: summary.hit[key], - percent: summary.requests > 0 ? (summary.hit[key] / summary.requests) * 100 : 0, + 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 && } + {!!traffic.error && }
    - - + + - {t(outcome === 'blocked' ? 'app_insights.refusal_blocked' : 'app_insights.refusal_unknown_package')} - + {t( + outcome === 'blocked' + ? 'app_insights.refusal_blocked' + : 'app_insights.refusal_unknown_package', + )} + } /> @@ -153,8 +181,14 @@ export const OverviewPanel = ({ size="small" title={t('app_insights.daily_title')} extra={ - setDailyMetric(event.target.value)} size="small"> - {t('app_insights.daily_requests')} + setDailyMetric(event.target.value)} + size="small" + > + + {t('app_insights.daily_requests')} + {t('app_insights.dau')} } @@ -173,33 +207,51 @@ export const OverviewPanel = ({ legend={{ color: { position: 'top' } }} /> ) : ( - {traffic.isLoading ? '' : t('app_insights.no_observations')} + + {traffic.isLoading ? '' : t('app_insights.no_observations')} + )} {t('app_insights.daily_footnote')} {t('app_insights.outcome_question')} - {outcomes.length > 0 ? : {t('app_insights.no_observations')}} + {outcomes.length > 0 ? ( + + ) : ( + {t('app_insights.no_observations')} + )} {t('app_insights.outcome_footnote')} onNavigate('versions')}> + } > {t('app_insights.versions_glance_question')} - {funnel.error && } - + {!!funnel.error && } + {rows.length > 0 ? (
    {rows.map((row) => ( -
    +
    @@ -210,21 +262,31 @@ export const OverviewPanel = ({ rollback: formatInteger(row.events.rollback), })}
    -
    +
    + +
    -
    {observedInteger(row.retained?.mark)}
    -
    {t('app_insights.observed_glance')}
    +
    + {observedInteger(row.retained?.mark)} +
    +
    + {t('app_insights.observed_glance')} +
    ))}
    ) : ( - {funnel.isLoading ? '' : t('app_insights.no_observations')} + + {funnel.isLoading ? '' : t('app_insights.no_observations')} + )} {t('app_insights.events_not_funnel')} - {t('app_insights.retained_scope')} {t('app_insights.retained_hint')} + + {t('app_insights.retained_scope')} {t('app_insights.retained_hint')} +
    ); diff --git a/src/pages/app-insights/traffic-panel.tsx b/src/pages/app-insights/traffic-panel.tsx index bf9413c6..b6f57ea9 100644 --- a/src/pages/app-insights/traffic-panel.tsx +++ b/src/pages/app-insights/traffic-panel.tsx @@ -24,24 +24,36 @@ export const PackageObservation = ({ row }: { row: PackageTrafficSummary }) => { 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')}} +
    + {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, - })}
    +
    + {t('app_insights.package_missing_days', { + expired: row.expiredDays, + missing: row.unavailableDays, + })} +
    )}
    ); }; -export const TrafficPanel = ({ appKey, days, isAdmin }: { +export const TrafficPanel = ({ + appKey, + days, + isAdmin, +}: { appKey: string | undefined; days: number; isAdmin: boolean; @@ -64,7 +76,10 @@ export const TrafficPanel = ({ appKey, days, isAdmin }: { 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) })), + items: summary.carriers.map((row) => ({ + ...row, + label: carrierLabel(row.key), + })), }, { key: 'hosts', @@ -80,21 +95,42 @@ export const TrafficPanel = ({ appKey, days, isAdmin }: { 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'), + label: + row.key === 'v4' + ? 'IPv4' + : row.key === 'v6' + ? 'IPv6' + : t('app_insights.ip_unknown'), })), }, ]; return (
    - {traffic.error && } - + {!!traffic.error && } + {t('app_insights.hourly_question', { days })} {summary.requests > 0 ? ( - - ) : {traffic.isLoading ? '' : t('app_insights.no_observations')}} + + ) : ( + + {traffic.isLoading ? '' : t('app_insights.no_observations')} + + )} {t('app_insights.hourly_footnote')} @@ -109,27 +145,59 @@ export const TrafficPanel = ({ appKey, days, isAdmin }: { 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) => }, + { + 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, - })} + + {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.items.length > 0 ? ( + + ) : ( + + {traffic.isLoading ? '' : t('app_insights.no_observations')} + + )} - {distribution.footnote} {t('app_insights.request_share')} + + {distribution.footnote} {t('app_insights.request_share')} + ))}
    diff --git a/src/pages/app-insights/versions-panel.tsx b/src/pages/app-insights/versions-panel.tsx index 41c5df01..4d8d96bd 100644 --- a/src/pages/app-insights/versions-panel.tsx +++ b/src/pages/app-insights/versions-panel.tsx @@ -4,8 +4,8 @@ import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { buildFunnelRows, - filterFunnelRows, type FunnelRow, + filterFunnelRows, lagShares, servedTotal, versionTotals, @@ -26,7 +26,12 @@ import { useAppVersionFunnel, VersionLabel, } from './shared'; -import type { FunnelEventCounts, LagBucket, LagBuckets, ServedCounts } from './types'; +import type { + FunnelEventCounts, + LagBucket, + LagBuckets, + ServedCounts, +} from './types'; const ALL = '__all__'; const LAG_LABEL_KEY: Record = { @@ -49,7 +54,15 @@ const ServedBreakdown = ({ served }: { served: ServedCounts }) => { [t('app_insights.served_full_pending'), served.fullPending], [t('app_insights.served_exp'), served.exp], ]; - return
    {parts.map(([label, count]) =>
    {label}: {formatInteger(count)}
    )}
    ; + return ( +
    + {parts.map(([label, count]) => ( +
    + {label}: {formatInteger(count)} +
    + ))} +
    + ); }; const useEventColumns = (): ColumnsType => { @@ -68,7 +81,9 @@ const useEventColumns = (): ColumnsType => { align: 'right', render: (_, row) => ( }> - {formatInteger(servedTotal(row.served))} + + {formatInteger(servedTotal(row.served))} + ), }, @@ -86,12 +101,20 @@ const useEventColumns = (): ColumnsType => { ]; }; -const LagTable = ({ title, buckets }: { title: string; buckets: LagBuckets['downloadSuccess'] }) => { +const LagTable = ({ + title, + buckets, +}: { + title: string; + buckets: LagBuckets['downloadSuccess']; +}) => { const { t } = useTranslation(); const { total, shares } = lagShares(buckets); return (
    -

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

    +

    + {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 }, + { + title, + key: 'bucket', + render: (_, row) => 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, + }, ]} /> - ) : {t('app_insights.no_observations')}} + ) : ( + {t('app_insights.no_observations')} + )} ); }; @@ -121,65 +160,139 @@ export const VersionDetail = ({ row }: { row: FunnelRow }) => { pagination={false} scroll={{ x: 'max-content' }} dataSource={row.byPackage} - columns={[{ title: t('app_insights.col_package'), dataIndex: 'packageVersion' }, ...packageColumns]} + columns={[ + { title: t('app_insights.col_package'), dataIndex: 'packageVersion' }, + ...packageColumns, + ]} /> {row.retained ? ( {t('app_insights.retained_scope')}
    - - + +
    {t('app_insights.retained_hint')}
    - - + +
    {t('app_insights.lag_footnote')}
    ) : ( - + )} ); }; -export const VersionsPanel = ({ appKey, days }: { appKey: string | undefined; days: number }) => { +export const VersionsPanel = ({ + appKey, + days, +}: { + appKey: string | undefined; + days: number; +}) => { const { t } = useTranslation(); const funnel = useAppVersionFunnel(appKey, days); const [versionFilter, setVersionFilter] = useState(ALL); const [packageFilter, setPackageFilter] = useState(ALL); const allRows = useMemo(() => buildFunnelRows(funnel.data), [funnel.data]); const rows = useMemo( - () => filterFunnelRows(allRows, versionFilter === ALL ? undefined : versionFilter, packageFilter === ALL ? undefined : packageFilter), + () => + filterFunnelRows( + allRows, + versionFilter === ALL ? undefined : versionFilter, + packageFilter === ALL ? undefined : packageFilter, + ), [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 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: 220, render: (_, row) => }, + { + title: t('app_insights.col_version'), + key: 'version', + fixed: 'left', + width: 220, + render: (_, row) => , + }, ...eventColumns, ]; return (
    - {funnel.error && } - - {t(totals?.scope === 'all_observed' ? 'app_insights.totals_all' : 'app_insights.totals_returned')} + {!!funnel.error && } + + + {t( + totals?.scope === 'all_observed' + ? 'app_insights.totals_all' + : 'app_insights.totals_returned', + )} +
    - - - + + +
    {totals?.unattributed && ( - {t('app_insights.totals_unattributed', { - offers: formatInteger(totals.unattributedOffers), - reports: formatInteger(Object.values(totals.unattributed).reduce((sum, count) => sum + count, 0)), - })} + + {t('app_insights.totals_unattributed', { + offers: formatInteger(totals.unattributedOffers), + reports: formatInteger( + Object.values(totals.unattributed).reduce( + (sum, count) => sum + count, + 0, + ), + ), + })} + + )} + {funnel.data?.truncated && ( + )} - {funnel.data?.truncated && }
    ({ value, label: value }))]} + options={[ + { value: ALL, label: t('app_insights.filter_all_packages') }, + ...packages.map((value) => ({ value, label: value })), + ]} />
    {t('app_insights.events_not_funnel')} @@ -210,9 +332,21 @@ export const VersionsPanel = ({ appKey, days }: { appKey: string | undefined; da columns={columns} pagination={rows.length > 20 ? { pageSize: 20 } : false} scroll={{ x: 'max-content' }} - expandable={{ expandedRowRender: (row) => }} + expandable={{ + expandedRowRender: (row) => , + }} /> - ) : {funnel.isLoading ? '' : t(allRows.length ? 'app_insights.no_versions_match' : 'app_insights.no_observations')}} + ) : ( + + {funnel.isLoading + ? '' + : t( + allRows.length + ? 'app_insights.no_versions_match' + : 'app_insights.no_observations', + )} + + )} {t('app_insights.rollback_only')}
    From 0aef31d628245b33950407594181b3b901988c9b Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Sun, 27 Sep 2026 14:26:28 +0800 Subject: [PATCH 03/12] fix(i18n): validate the same effective catalogs registered at runtime Share side-effect-free locale resources between initialization and validation. Retain base JSON parity checks and check effective keys and markup in both languages. Restore the original CI workflow after formatting diagnostics. --- .github/workflows/ci.yml | 22 ---------------- docs/insights-metric-contract.md | 4 +-- src/i18n/index.ts | 19 ++------------ src/i18n/locales.test.ts | 45 ++++++++++++++------------------ src/i18n/resources.ts | 20 ++++++++++++++ 5 files changed, 43 insertions(+), 67 deletions(-) create mode 100644 src/i18n/resources.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d58c7d13..7d6f6721 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,6 @@ jobs: - uses: oven-sh/setup-bun@v2 - name: Install - id: install run: bun install --frozen-lockfile - name: Typecheck @@ -34,24 +33,3 @@ jobs: run: bun run build:check env: CI: "true" - - # Diagnostic only: the failed check remains failed. No suggested edits - # are committed, pushed or used to turn a failed gate into a passing one. - - name: Export insights formatting suggestions - if: failure() && steps.install.outcome == 'success' - run: | - bunx --no-install biome check --write src/pages/app-insights src/i18n/insights-metrics.ts src/i18n/index.ts > biome-suggestions.log 2>&1 || true - git diff -- src/pages/app-insights src/i18n > biome-suggestions.patch - - - name: Upload diagnostic copies - if: failure() && steps.install.outcome == 'success' - uses: actions/upload-artifact@v7 - with: - name: insights-format-suggestions - retention-days: 3 - path: | - biome-suggestions.patch - biome-suggestions.log - src/pages/app-insights - src/i18n/insights-metrics.ts - src/i18n/index.ts diff --git a/docs/insights-metric-contract.md b/docs/insights-metric-contract.md index 658cfbec..6d597611 100644 --- a/docs/insights-metric-contract.md +++ b/docs/insights-metric-contract.md @@ -22,13 +22,13 @@ No-update includes already-current and no bound version. Paused/restricted can b 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 are grouped in `src/i18n/insights-metrics.ts` and registered over the legacy resource at initialization. Both languages have identical keys, checked in tests. +Metric-contract translations are grouped in `src/i18n/insights-metrics.ts`. Runtime registration and locale validation import the same side-effect-free `src/i18n/resources.ts` catalog. Tests retain base JSON key parity, validate effective bilingual key parity and all static source references, and reject plain-text rendering of tagged translations in either language. ## 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. Render tests cover rollback-only wording, hidden out-of-scope retained metrics, the former 250% scenario, legacy totals, stale windows and package observation states. -CI still checks types, lint, tests and bundle size. On failure, a short-lived diagnostic artifact exports Biome suggestions and formatted copies; it never commits edits or makes a failed validation pass. +The existing CI gates remain unchanged: typecheck, lint, tests, production build and bundle-size limits. Temporary formatting diagnostics used during development have been removed from the final workflow diff. ## Not fabricated by this correction diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 284284e4..78ccd1d3 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -1,28 +1,13 @@ import i18n from 'i18next'; import LanguageDetector from 'i18next-browser-languagedetector'; import { initReactI18next } from 'react-i18next'; -import { insightsEn, insightsZh } from './insights-metrics'; -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, - app_insights: { ...en.app_insights, ...insightsEn }, - }, - }, - 'zh-CN': { - translation: { - ...zhCN, - app_insights: { ...zhCN.app_insights, ...insightsZh }, - }, - }, - }, + resources, fallbackLng: 'zh-CN', interpolation: { escapeValue: false, diff --git a/src/i18n/locales.test.ts b/src/i18n/locales.test.ts index 187c9d70..3b1a06dc 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/resources.ts b/src/i18n/resources.ts new file mode 100644 index 00000000..b633715e --- /dev/null +++ b/src/i18n/resources.ts @@ -0,0 +1,20 @@ +import { insightsEn, insightsZh } from './insights-metrics'; +import en from './locales/en.json'; +import zhCN from './locales/zh-CN.json'; + +// Runtime registration and locale validation share exactly the same catalog. +// Keeping this module side-effect free also avoids language detection in tests. +export const resources = { + en: { + translation: { + ...en, + app_insights: { ...en.app_insights, ...insightsEn }, + }, + }, + 'zh-CN': { + translation: { + ...zhCN, + app_insights: { ...zhCN.app_insights, ...insightsZh }, + }, + }, +}; From 13d242a70193d75a7bf3f374b3890a9176ba8136 Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Sun, 27 Sep 2026 14:34:16 +0800 Subject: [PATCH 04/12] fix(insights): treat a null observed object as unavailable Guard the JSON boundary without falling back to legacy adoption counts when an explicit null object is present. Add a regression preventing panel crashes. --- src/pages/app-insights/logic.ts | 2 +- src/pages/app-insights/observed-null.test.ts | 33 ++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 src/pages/app-insights/observed-null.test.ts diff --git a/src/pages/app-insights/logic.ts b/src/pages/app-insights/logic.ts index 0ab4ad51..eccb7a95 100644 --- a/src/pages/app-insights/logic.ts +++ b/src/pages/app-insights/logic.ts @@ -387,7 +387,7 @@ export const buildFunnelRows = ( const value = version.observed === undefined ? version.adopted[kind] - : version.observed[kind]; + : version.observed?.[kind]; return validCount(value) && value > 0 ? value : null; }; return { 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 00000000..8b9aa659 --- /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, + }); +}); From 16ebc4fb620eee3808267ddb9a345e0be23a1f1d Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Sun, 27 Sep 2026 16:01:41 +0800 Subject: [PATCH 05/12] chore(review): stage scoped PR 64 corrections and regression fixtures --- .github/scripts/pr64-review.py | 336 +++++++++++++++++++++++++++++++++ 1 file changed, 336 insertions(+) create mode 100644 .github/scripts/pr64-review.py diff --git a/.github/scripts/pr64-review.py b/.github/scripts/pr64-review.py new file mode 100644 index 00000000..a8cfc9e0 --- /dev/null +++ b/.github/scripts/pr64-review.py @@ -0,0 +1,336 @@ +from pathlib import Path +import hashlib +import shutil + +# Exact blobs were read and reviewed before preparing these replacements. +expected = { + 'src/pages/app-insights/logic.ts': 'eccb7a956b85f5e060fcf467ab8604f49694aed4', + 'src/pages/app-insights/logic.test.ts': 'ad45596525f9ea714f5aa4f2026b85d28755e3f1', + 'src/pages/app-insights/observation-ui.tsx': 'f6268ebb02cc2200b0f631c72d4882d8c47a9546', + 'src/pages/app-insights/observation-ui.test.tsx': '27208f81596a18a7e823e7127bd1a64010e07ebc', + 'src/pages/app-insights/overview-panel.tsx': 'bdccc38bd2aa2efd7aa561cd0f9c45ef7358d707', + 'src/pages/app-insights/versions-panel.tsx': '4d8d96bd26a9523f900a89dec5e0be8849f4e3cd', + 'src/pages/app-insights/traffic-panel.tsx': 'b6f57ea942b72396082ba9b78d86da4cf57db652', + 'src/pages/app-insights/failures-panel.tsx': 'ba6f3fa765cd0aca504e867ae2f2df917d6724f6', + 'src/pages/app-insights/shared.tsx': '70f39f1e2f68b38e2028e0e6faed0c117a16ec1c', + 'src/constants/i18n-keys.ts': '5031d29bb7b556b75ed0c6a43de0edce8db0def2', + 'src/pages/admin-service-status/version-health-overview-panel.tsx': '542bda5153870edbd844fd79f098d59038938c83', + 'src/i18n/resources.ts': 'b633715e76bd0f5efaa67000b0318ae46a080583', + 'src/i18n/locales/en.json': '860f9cbf48e8eb552b1f73c054ba1b4166bf1759', + 'src/i18n/locales/zh-CN.json': '111d0d6446ba5fb9c59a2efb007577cf64e11063', +} +for path, sha in expected.items(): + data = Path(path).read_bytes() + actual = hashlib.sha1(f'blob {len(data)}\0'.encode() + data).hexdigest() + assert actual == sha, (path, actual, sha) + +def replace(path, old, new): + p = Path(path) + s = p.read_text() + assert s.count(old) == 1, (path, 'expected one match', s.count(old), old[:100]) + p.write_text(s.replace(old, new)) + +def write(path, text): + p = Path(path) + assert not p.exists(), f'{path} already exists' + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(text) + +logic = 'src/pages/app-insights/logic.ts' +replace(logic, "import {\n type AppEventBreakdownDay,", "import {\n CRITICAL_ROLLBACK,\n MIN_EVENT_SAMPLES,\n WARNING_ROLLBACK,\n} from '../../constants/metric-thresholds';\nimport {\n type AppEventBreakdownDay,") +replace(logic, 'export const MIN_EVENT_SAMPLES = 10;', 'export { MIN_EVENT_SAMPLES };') +replace(logic, 'rollbackRate >= 0.05', 'rollbackRate >= CRITICAL_ROLLBACK') +replace(logic, 'rollbackRate >= 0.01', 'rollbackRate >= WARNING_ROLLBACK') +replace(logic, 'export interface RankedItem {', '''/** 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 {''') +replace(logic, ''' const dayHit = emptyHit(); + for (const outcome of HIT_OUTCOMES) { + dayHit[outcome] = countOf(day.hit?.[outcome]); + hit[outcome] += dayHit[outcome]; + } + const dayRequests = observationCount(day.requests, day.requestsStatus); + const dau = observationCount(day.dau, day.dauStatus);''', ''' const dayRequests = observationCount(day.requests, day.requestsStatus); + const dayHit = emptyHit(); + // 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 dau = deviceObservationCount(day.dau, day.dauStatus);''') +replace(logic, '''export interface PackageTrafficSummary { + packageVersion: string; + requests: number; + peakDevices: number | null; + percent: number;''', '''export interface PackageTrafficSummary { + packageVersion: string; + requests: number | null; + peakDevices: number | null; + percent: number | null;''') +replace(logic, ''' const entry = packages.get(item.packageVersion) ?? { + packageVersion: item.packageVersion, + requests: 0,''', ''' const entry: PackageTrafficSummary = packages.get(item.packageVersion) ?? { + packageVersion: item.packageVersion, + requests: null,''') +replace(logic, ' entry.requests += countOf(item.requests);', ''' if (dayRequests !== null && validCount(item.requests)) { + entry.requests = (entry.requests ?? 0) + item.requests; + }''') +replace(logic, ' for (const item of day.refused ?? []) {', ' for (const item of dayRequests === null ? [] : day.refused ?? []) {') +replace(logic, ' percent: percentOf(entry.requests, requests),', ''' percent: + entry.requests !== null && requests > 0 + ? percentOf(entry.requests, requests) + : null,''') +replace(logic, ' right.requests - left.requests ||', ' (right.requests ?? 0) - (left.requests ?? 0) ||') +replace(logic, 'export const filterFunnelRows = (', '''/** 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 = (''') +replace(logic, '''export interface BreakdownSummary { + failures: number;''', '''export interface BreakdownSummary { + totalDays: number; + availableDays: number; + unavailableDays: number; + legacyDays: number; + failures: number;''') +replace(logic, ''' let failures = 0; + for (const day of days ?? []) { + for (const item of day.byReason ?? []) {''', ''' 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 ?? []) {''') +replace(logic, " const reason = item.reason.startsWith('other:') ? 'other' : item.reason;", " const parsed = parseFailureReason(item.reason);\n const reason = parsed.kind === 'known' ? parsed.reason : 'other';") +replace(logic, ''' return { + failures, + reasons:''', ''' return { + totalDays: days?.length ?? 0, + availableDays, + unavailableDays, + legacyDays, + failures, + reasons:''') +replace(logic, " | { kind: 'other'; detail: string } =>", " | { kind: 'other' } =>") +replace(logic, ": { kind: 'other', detail: '' };", ": { kind: 'other' };") +write('src/constants/metric-thresholds.ts', '''// 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; +''') +shared = 'src/pages/app-insights/shared.tsx' +replace(shared, "export const formatShare = (percent: number) => `${percent.toFixed(1)}%`;", '''export const formatShare = (percent: number | null | undefined) => + typeof percent === 'number' && Number.isFinite(percent) + ? `${percent.toFixed(1)}%` + : '-';''') +replace(shared, ''' return parsed.detail + ? t('app_insights.reason_other_detail', { detail: parsed.detail }) + : t('app_insights.reason_other');''', " return t('app_insights.reason_other');") +observation = 'src/pages/app-insights/observation-ui.tsx' +replace(observation, "import { computeFunnelRates, MIN_EVENT_SAMPLES } from './logic';", "import { FUNNEL_HEALTH_LABEL_KEY } from '@/constants/i18n-keys';\nimport { MIN_EVENT_SAMPLES } from '@/constants/metric-thresholds';\nimport type { BreakdownSummary, FunnelHealth } from './logic';") +replace(observation, "import type { FunnelEventCounts, ObservationWindow } from './types';", "import type { ObservationWindow } from './types';") +replace(observation, '''export const RollbackObservation = ({ + events, +}: { + events: FunnelEventCounts; +}) => { + const { t } = useTranslation(); + const rates = computeFunnelRates(events); + const label = + rates.health === null + ? t('app_insights.insufficient_samples', { + count: rates.rollbackSamples, + minimum: MIN_EVENT_SAMPLES, + }) + : t( + rates.health === 'critical' + ? 'app_insights.rollback_high' + : rates.health === 'warning' + ? 'app_insights.rollback_warning' + : 'app_insights.rollback_low', + );''', '''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]);''') +replace(observation, "rates.health === 'critical'", "health === 'critical'") +replace(observation, "rates.health === 'warning'", "health === 'warning'") +replace(observation, '', '') +with open(observation, 'a') as f: + f.write(''' +export const BreakdownAvailability = ({ + summary, +}: { + summary: Pick; +}) => { + 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 })}
    + )} +
    + ); +}; +''') +panel = 'src/pages/app-insights/overview-panel.tsx' +replace(panel, ' buildFunnelRows,', ' buildFunnelRows,\n rankFunnelRows,') +replace(panel, '() => buildFunnelRows(funnel.data).slice(0, 5)', '() => rankFunnelRows(buildFunnelRows(funnel.data)).slice(0, 5)') +rollback = '' +replace(panel, '', rollback) +versions = 'src/pages/app-insights/versions-panel.tsx' +replace(versions, ' buildFunnelRows,', ' buildFunnelRows,\n computeFunnelRates,\n type FunnelRates,') +replace(versions, 'type EventRow = { served: ServedCounts; events: FunnelEventCounts };', "type EventRow = { served: ServedCounts; events: FunnelEventCounts } & Pick;") +replace(versions, '', rollback) +replace(versions, " const packageColumns = useEventColumns();", ''' const packageRows = useMemo( + () => row.byPackage.map((item) => ({ ...item, ...computeFunnelRates(item.events) })), + [row.byPackage], + ); + const packageColumns = useEventColumns<(typeof packageRows)[number]>();''') +replace(versions, ' dataSource={row.byPackage}', ' dataSource={packageRows}') +traffic = 'src/pages/app-insights/traffic-panel.tsx' +replace(traffic, '{summary.requests > 0 ? (', '{summary.hourly.some((count) => count > 0) ? (') +failures = 'src/pages/app-insights/failures-panel.tsx' +replace(failures, "import { ObservationNotice, ReportShare } from './observation-ui';", "import { BreakdownAvailability, ObservationNotice, ReportShare } from './observation-ui';") +replace(failures, ''' keyTitle, +}: { + rows: DimensionRow[]; + labelOf: (key: string) => string; + keyTitle: string;''', ''' keyTitle, + emptyText, +}: { + rows: DimensionRow[]; + labelOf: (key: string) => string; + keyTitle: string; + emptyText: string;''') +replace(failures, "locale={{ emptyText: t('app_insights.no_observations') }}", 'locale={{ emptyText }}') +replace(failures, ''' const hasObservations = + summary.os.length > 0 || + summary.reasons.length > 0 || + summary.carriers.length > 0;''', ''' const hasAvailableDays = summary.availableDays > 0; + const emptyText = t(hasAvailableDays + ? 'app_insights.no_reports_in_scope' + : 'app_insights.breakdown_unavailable');''') +replace(failures, '''
    ''', ''' +
    ''') +replace(failures, 'hasObservations ? summary.failures : null', 'hasAvailableDays ? summary.failures : null') +replace(failures, " : t('app_insights.no_observations')\n", ' : emptyText\n') +replace(failures, "t('app_insights.no_ranked_os')", "hasAvailableDays ? t('app_insights.no_ranked_os') : emptyText") +replace(failures, "{breakdown.isLoading ? '' : t('app_insights.no_observations')}", "{breakdown.isLoading ? '' : emptyText}") +replace(failures, ''' rows={summary.os} + keyTitle=''',''' rows={summary.os} + emptyText={emptyText} + keyTitle=''') +replace(failures, ''' rows={summary.carriers} + keyTitle=''',''' rows={summary.carriers} + emptyText={emptyText} + keyTitle=''') +keys = 'src/constants/i18n-keys.ts' +replace(keys, '版本漏斗的健康标签(回滚率阈值判定,null 表示样本不足不判定)', '回滚报告占比标签(null 表示样本不足,不代表整体健康)') +for old,new in [('health_healthy','rollback_low'),('health_warning','rollback_warning'),('health_critical','rollback_high')]: + replace(keys, 'app_insights.'+old, 'app_insights.'+new) +admin = 'src/pages/admin-service-status/version-health-overview-panel.tsx' +replace(admin, "import { adminApi } from '@/services/admin-api';", "import { CRITICAL_ROLLBACK, WARNING_ROLLBACK, MIN_EVENT_SAMPLES } from '@/constants/metric-thresholds';\nimport { adminApi } from '@/services/admin-api';") +replace(admin, '''// 阈值:回滚率 >5% 异常、>1% 关注;样本太少(<10)不判定 +const CRITICAL_ROLLBACK = 0.05; +const WARNING_ROLLBACK = 0.01; +const MIN_SAMPLES = 10; + +''', '') +replace(admin, 'row.startSamples < MIN_SAMPLES', 'row.startSamples < MIN_EVENT_SAMPLES') +unit = 'src/pages/app-insights/logic.test.ts' +replace(unit, 'keeps legacy positive observations and valid numeric zero days usable', 'keeps legacy positive device observations but does not invent observed zeroes') +replace(unit, ''' [day('2026-09-25', { dau: 10 }), day('2026-09-26')], + '2026-09-27', + ); + expect(result.averageDau).toBe(5);''', ''' [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();''') +replace(unit, " kind: 'other',\n detail: '',", " kind: 'other',") +uiTest = 'src/pages/app-insights/observation-ui.test.tsx' +replace(uiTest, "import { insightsEn, insightsZh } from '@/i18n/insights-metrics';", "import en from '@/i18n/locales/en.json';\nimport zhCN from '@/i18n/locales/zh-CN.json';") +replace(uiTest, 'const clients: QueryClient[] = [];', 'const insightsEn = en.app_insights;\nconst insightsZh = zhCN.app_insights;\nconst clients: QueryClient[] = [];') +replace(uiTest, 'metric language overrides have identical keys', 'canonical metric language catalogs have identical keys') +replace(uiTest, ' render();', ''' const row = buildFunnelRows(response)[0]!; + render();''') +replace(uiTest, ''' events={{ + downloadSuccess: 0, + downloadFail: 0, + patchFail: 0, + markSuccess: 3, + rollback: 1, + }}''', ''' health={null} + samples={4} + count={1}''') +for name in ['review-regressions.test.ts', 'review-availability.test.tsx']: + destination = Path('src/pages/app-insights') / name + assert not destination.exists(), destination + shutil.copyfile(Path('.github/scripts/pr64-fixtures') / name, destination) +write('src/i18n/canonical-resources.test.ts', '''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); +}); +''') From 8f83132db8505efa0ac17f18816816058dc238cf Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Sun, 27 Sep 2026 16:02:16 +0800 Subject: [PATCH 06/12] test(insights): stage observation population and ranking regressions --- .../pr64-fixtures/review-regressions.test.ts | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 .github/scripts/pr64-fixtures/review-regressions.test.ts diff --git a/.github/scripts/pr64-fixtures/review-regressions.test.ts b/.github/scripts/pr64-fixtures/review-regressions.test.ts new file mode 100644 index 00000000..14e76c28 --- /dev/null +++ b/.github/scripts/pr64-fixtures/review-regressions.test.ts @@ -0,0 +1,159 @@ +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'); + }); +}); From 2ef9c90d3c8575e20d177c5d1d94df439ce36c62 Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Sun, 27 Sep 2026 16:02:33 +0800 Subject: [PATCH 07/12] test(insights): stage failure availability rendering regressions --- .../review-availability.test.tsx | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 .github/scripts/pr64-fixtures/review-availability.test.tsx diff --git a/.github/scripts/pr64-fixtures/review-availability.test.tsx b/.github/scripts/pr64-fixtures/review-availability.test.tsx new file mode 100644 index 00000000..8622e0aa --- /dev/null +++ b/.github/scripts/pr64-fixtures/review-availability.test.tsx @@ -0,0 +1,63 @@ +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(); +}); From a99a59f3d70d3562ea91c4ac4552deae5668e238 Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Sun, 27 Sep 2026 16:02:48 +0800 Subject: [PATCH 08/12] chore(i18n): stage canonical metric catalog migration --- .github/scripts/pr64-locales.ts | 56 +++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 .github/scripts/pr64-locales.ts diff --git a/.github/scripts/pr64-locales.ts b/.github/scripts/pr64-locales.ts new file mode 100644 index 00000000..73dcc90c --- /dev/null +++ b/.github/scripts/pr64-locales.ts @@ -0,0 +1,56 @@ +import { readdirSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import { insightsEn, insightsZh } from '../../src/i18n/insights-metrics'; + +function productionSources(dir: string): string { + return readdirSync(dir, { withFileTypes: true }).map((entry) => { + const path = join(dir, entry.name); + if (entry.isDirectory()) return productionSources(path); + return /\.tsx?$/.test(path) && !path.includes('.test.') && !path.endsWith('insights-metrics.ts') + ? readFileSync(path, 'utf8') : ''; + }).join('\n'); +} +const copy = { + en: { + 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.', + 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.', + versions_glance_question: 'Returned versions ranked by offered targets and client reports in the window, not by release time.', + 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.', + }, + 'zh-CN': { + breakdown_unavailable: '失败事件数据不可用,不能据此判断为零失败。', + breakdown_availability: '可用观测日 {{available}} / {{total}};不可用 {{unavailable}} 日。不可用日已排除,不补零。', + breakdown_legacy_days: '{{days}} 个旧接口日桶缺少可用性状态;仅保留有上报证据的观测。', + no_reports_in_scope: '当前筛选范围未观测到报告,不代表整体健康。', + window_business_legacy: '旧接口未提供业务时区与可用性元数据;今日标记暂按 UTC+8,含义不明的零设备数已排除,不作为有效零值。', + versions_glance_question: '在返回的版本中按窗口内提供目标与客户端事件总量排序,不是按发布时间排序。', + hourly_footnote: '按上方业务时区小时累加,使用独立于请求总量的小时观测。包含尚未结束的今天,各小时可观测时长可能不同。', + }, +}; +const obsolete = ['reason_other_detail', 'health_healthy', 'health_warning', 'health_critical', + 'col_coverage', 'coverage_hint', 'coverage_footnote', 'adoption_rate', 'adoption_rate_hint', + 'adoption_rate_footnote', 'col_adoption_rate']; +const sources = productionSources('src'); +for (const [language, overrides] of [['en', insightsEn], ['zh-CN', insightsZh]] as const) { + const path = `src/i18n/locales/${language}.json`; + const catalog = JSON.parse(readFileSync(path, 'utf8')); + catalog.app_insights = { ...catalog.app_insights, ...overrides, ...copy[language] }; + for (const key of obsolete) { + if (sources.includes(`app_insights.${key}`)) throw new Error(`Obsolete key still used: ${key}`); + delete catalog.app_insights[key]; + } + writeFileSync(path, JSON.stringify(catalog, null, 2) + '\n'); +} +writeFileSync('src/i18n/resources.ts', `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 }, +}; +`); +unlinkSync('src/i18n/insights-metrics.ts'); From 87f1d222a989720b73d166f7145e284a514c615d Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Sun, 27 Sep 2026 16:03:33 +0800 Subject: [PATCH 09/12] chore(ci): apply and verify scoped PR 64 review corrections --- .github/workflows/ci-repair-pr64.yml | 86 ++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 .github/workflows/ci-repair-pr64.yml diff --git a/.github/workflows/ci-repair-pr64.yml b/.github/workflows/ci-repair-pr64.yml new file mode 100644 index 00000000..4369ac6c --- /dev/null +++ b/.github/workflows/ci-repair-pr64.yml @@ -0,0 +1,86 @@ +name: PR 64 review repair + +on: + pull_request: + branches: [main] + paths: ['.github/workflows/ci-repair-pr64.yml', '.github/scripts/pr64-review.py', '.github/scripts/pr64-locales.ts', '.github/scripts/pr64-fixtures/**'] + +permissions: + contents: write + +jobs: + repair: + if: github.repository == 'reactnativecn/pushy-admin' && github.event.pull_request.number == 64 && github.event.pull_request.head.repo.full_name == github.repository && github.head_ref == 'fix/insights-metric-contract-20260927' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha }} + - uses: oven-sh/setup-bun@v2 + - name: Install locked dependencies + run: bun install --frozen-lockfile + - name: Apply exact reviewed edits and consolidate locale catalogs + run: | + python3 .github/scripts/pr64-review.py + bun .github/scripts/pr64-locales.ts + # Fixtures have now moved into their real source test locations. + rm -r .github/scripts/pr64-fixtures + - name: Format only edited source and test files + run: | + bunx --no-install biome check --write \ + src/constants/i18n-keys.ts src/constants/metric-thresholds.ts \ + src/pages/admin-service-status/version-health-overview-panel.tsx \ + src/i18n/resources.ts src/i18n/canonical-resources.test.ts \ + src/i18n/locales/en.json src/i18n/locales/zh-CN.json \ + src/pages/app-insights/logic.ts src/pages/app-insights/logic.test.ts \ + src/pages/app-insights/shared.tsx src/pages/app-insights/observation-ui.tsx \ + src/pages/app-insights/observation-ui.test.tsx \ + src/pages/app-insights/overview-panel.tsx src/pages/app-insights/versions-panel.tsx \ + src/pages/app-insights/traffic-panel.tsx src/pages/app-insights/failures-panel.tsx \ + src/pages/app-insights/review-regressions.test.ts src/pages/app-insights/review-availability.test.tsx + git diff --check + - name: All repository type, lint and test gates + run: bun run ci + - name: Production build and bundle size + run: bun run build:check + env: + CI: 'true' + - name: Push only verified changes to the same PR branch + env: + EXPECTED_HEAD: ${{ github.event.pull_request.head.sha }} + shell: bash + run: | + set -euo pipefail + branch=fix/insights-metric-contract-20260927 + actual=$(git ls-remote origin "refs/heads/$branch" | cut -f1) + test "$actual" = "$EXPECTED_HEAD" + git add -A -- \ + src/constants/i18n-keys.ts src/constants/metric-thresholds.ts \ + src/pages/admin-service-status/version-health-overview-panel.tsx \ + src/i18n/resources.ts src/i18n/canonical-resources.test.ts src/i18n/insights-metrics.ts \ + src/i18n/locales/en.json src/i18n/locales/zh-CN.json \ + src/pages/app-insights/logic.ts src/pages/app-insights/logic.test.ts \ + src/pages/app-insights/shared.tsx src/pages/app-insights/observation-ui.tsx \ + src/pages/app-insights/observation-ui.test.tsx \ + src/pages/app-insights/overview-panel.tsx src/pages/app-insights/versions-panel.tsx \ + src/pages/app-insights/traffic-panel.tsx src/pages/app-insights/failures-panel.tsx \ + src/pages/app-insights/review-regressions.test.ts src/pages/app-insights/review-availability.test.tsx + git diff --cached --stat + git config user.name 'ChatGPT' + git config user.email 'noreply@openai.com' + git commit -m 'fix(insights): align available populations, canonicalize labels and share thresholds' + git push origin "HEAD:refs/heads/$branch" + - name: Export diagnostics on failure + if: failure() + run: git diff > review-repair.patch + - uses: actions/upload-artifact@v7 + if: failure() + with: + name: pr64-review-diagnostics + retention-days: 3 + path: | + review-repair.patch + src/pages/app-insights + src/i18n + src/constants/metric-thresholds.ts From aecf805eeb8ab7133cfea76d05499a3e30cba0e9 Mon Sep 17 00:00:00 2001 From: ChatGPT Date: Sun, 27 Sep 2026 08:03:56 +0000 Subject: [PATCH 10/12] fix(insights): align available populations, canonicalize labels and share thresholds --- src/constants/i18n-keys.ts | 8 +- src/constants/metric-thresholds.ts | 5 + src/i18n/canonical-resources.test.ts | 9 + src/i18n/insights-metrics.ts | 241 ------------ src/i18n/locales/en.json | 154 ++++---- src/i18n/locales/zh-CN.json | 156 ++++---- src/i18n/resources.ts | 18 +- .../version-health-overview-panel.tsx | 12 +- src/pages/app-insights/failures-panel.tsx | 35 +- src/pages/app-insights/logic.test.ts | 7 +- src/pages/app-insights/logic.ts | 104 ++++-- .../app-insights/observation-ui.test.tsx | 26 +- src/pages/app-insights/observation-ui.tsx | 66 +++- src/pages/app-insights/overview-panel.tsx | 9 +- .../app-insights/review-availability.test.tsx | 158 ++++++++ .../app-insights/review-regressions.test.ts | 351 ++++++++++++++++++ src/pages/app-insights/shared.tsx | 9 +- src/pages/app-insights/traffic-panel.tsx | 2 +- src/pages/app-insights/versions-panel.tsx | 27 +- 19 files changed, 929 insertions(+), 468 deletions(-) create mode 100644 src/constants/metric-thresholds.ts create mode 100644 src/i18n/canonical-resources.test.ts delete mode 100644 src/i18n/insights-metrics.ts create mode 100644 src/pages/app-insights/review-availability.test.tsx create mode 100644 src/pages/app-insights/review-regressions.test.ts diff --git a/src/constants/i18n-keys.ts b/src/constants/i18n-keys.ts index 5031d29b..fa27afc7 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 00000000..dc172b6e --- /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 00000000..bd8efde1 --- /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/insights-metrics.ts b/src/i18n/insights-metrics.ts deleted file mode 100644 index fe08a2b4..00000000 --- a/src/i18n/insights-metrics.ts +++ /dev/null @@ -1,241 +0,0 @@ -// Metric-contract copy is kept together so both languages share the same -// definitions. Registered over the legacy app_insights resource at startup. -export const insightsZh = { - view_versions: '版本事件', - live: '每分钟刷新', - today_requests: '今日查询热更请求', - today_dau: '今日查询热更设备', - dau: '查询热更设备', - dau_hint: '完成更新检查并上报 UUID 的设备估算,不是全部 App 用户', - peak_dau: '可用日桶中的峰值查询设备', - window_requests: '近 {{days}} 个日桶观测请求', - daily_average: '完整日均 {{value}}(可用 {{samples}} / {{days}} 日)', - dau_average: '完整日均 {{value}}(可用 {{samples}} / {{days}} 日)', - 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: '没有可用观测,不能据此判断为零或正常', - today_live: '今日未完成日桶', - daily_footnote: '按上方时区的自然日统计;今天尚未结束,缺失设备数据不补零。', - hit_uptodate: '未提供更新', - hit_hdiff: '提供 hdiff 选项', - hit_pdiff: '提供 pdiff 选项', - hit_full: '提供整包选项', - hit_paused: '暂停或检查受限', - hit_expired: '原生包已过期', - outcome_footnote: - '未提供更新包括已运行目标版本和未绑定版本;暂停或受限可能来自应用、原生包或额度。提供下载选项不代表客户端已经下载。', - update_share_hint: '提供了更新选项的请求占比,不是升级成功率', - refusal_blocked: - '构建信息不匹配:核对客户端构建与已登记原生包,不应仅为消除告警而关闭校验。', - refusal_unknown_package: - '当前未匹配到已登记的原生包;核对 App Key、版本与原生包上传记录。', - refused_packages_unattributed: - '另有 {{count}} 次无可用原生包拆分,可能来自历史采集或明细缺失。', - versions_glance_title: '事件量最高的版本', - versions_glance_question: - '按窗口内提供目标与客户端事件总量排序,不是按发布时间排序。', - glance_line: - '提供目标 {{served}} 次 · 激活报告 {{activated}} 次 · 下载/Patch 失败 {{failed}} 次 · 回滚 {{rollback}} 次', - observed_glance: '留存累计激活设备观测', - col_served: '提供目标次数', - col_downloaded: '下载成功事件', - col_activated: '激活成功事件', - col_download_fail: '下载失败事件', - col_patch_fail: 'Patch 失败事件', - col_rollback: '回滚事件', - col_rollback_rate: '回滚报告占比', - col_failure_rate: '下载/Patch 失败事件占比', - col_health: '回滚观测', - served_exp: '实验版本选项(不代表灰度命中)', - events_not_funnel: - '各项是未关联的事件次数,不是转化漏斗。一次响应可提供主版本和实验选项;Patch 失败后整包成功会产生两类报告。', - funnel_table_title: '版本更新事件', - funnel_title: '独立事件计数', - retained_title: '留存累计设备与事件到达分布', - retained_scope: - '整个热更版本的累计观测,不受天数筛选影响;不是当前正在运行该版本的设备。', - retained_unavailable_package: - '累计设备和时间分布没有原生包维度,已在原生包筛选下隐藏。', - adopted_mark: '留存累计激活设备观测', - adopted_download: '留存累计下载设备观测', - retained_hint: - 'HLL 近似去重;35 天不活动可过期重计,缺失 UUID 或报告可能导致遗漏。', - lag_download: '版本创建后下载成功报告到达时间', - lag_mark: '版本创建后激活成功报告到达时间', - lag_footnote: - '按报告次数分布,不是首次激活设备数;起点是版本创建,不是绑定发布时间或下载开始。时间桶 90 天不活动可过期重计。', - rollback_low: '回滚报告占比较低', - rollback_warning: '回滚报告占比需关注', - rollback_high: '回滚报告占比较高', - insufficient_samples: '回滚观测样本不足({{count}} / {{minimum}})', - rollback_only: - '仅描述回滚报告:回滚 ÷(激活成功 + 回滚),不代表整体健康。达到 10 个报告后按 1% / 5% 提示。', - totals_all: '应用整体观测汇总(不受下方筛选或前 50 个版本截断影响)', - totals_returned: '旧接口:仅返回版本的小计,不是应用整体总量', - versions_in_window: '有事件的版本数', - window_served: '提供目标选项总次数', - window_rollbacks: '回滚报告总次数', - totals_unattributed: - '其中无法归属版本:提供目标 {{offers}} 次,客户端报告 {{reports}} 次(已包含在汇总中)', - filtered_note: - '下表仅显示当前版本/原生包筛选;上方汇总仍为应用整体或返回版本小计。', - request_share: '请求占比', - packages_question: '哪些原生包在查询更新?请求占比不是装机占比。', - col_peak_devices: '可用观测日中的设备峰值', - col_availability: '设备观测范围与状态', - package_observed_days: '可用 {{count}} 日:{{start}} 至 {{end}}', - package_missing_days: '过期 {{expired}} 日;无可用观测 {{missing}} 日', - package_partial: '触及采集限制,部分数据', - packages_footnote: - '请求保留 {{requests}} 天,设备观测保留 {{devices}} 天。峰值仅来自可用日,不代表整个所选窗口;没有设备观测不能解释为无人使用。', - hourly_footnote: - '按上方业务时区小时累加。包含尚未结束的今天,各小时可观测时长可能不同。', - failure_events: '近 {{days}} 日桶已分类失败事件', - failure_events_hint: - '来自原因分类聚合:下载失败、Patch 失败和回滚;不是失败设备数。', - worst_os: '失败事件占比最高的平台(至少 10 个报告)', - no_ranked_os: '没有达到样本门槛且失败占比大于零的平台', - rates_footnote: - '失败事件占比 = (下载失败 + Patch 失败) ÷ (下载成功 + 下载失败 + Patch 失败)。回滚占比 = 回滚 ÷ (激活成功 + 回滚)。括号显示分子/分母;均不是最终升级失败率。', - reasons_footnote: - '原因由上报内容启发式归类;其他不展示自由文本前缀。未提供原因不是具体根因。各维度独立聚合,可能因采集遗漏而不完全对齐。', - breakdown_footnote: - '按上方业务时区日桶,最多保留 {{retention}} 天;仅包含支持遥测并成功上报的 SDK 事件,不能代表所有设备。', - version_deleted: '未命名或已删除的版本', - version_deleted_hint: '当前未解析到名称,不能仅凭空名称确认已删除。', -}; - -export const insightsEn: Record = { - view_versions: 'Version events', - live: 'Refreshes every minute', - today_requests: 'Update checks today', - today_dau: 'Devices checking for updates today', - dau: 'Devices checking for updates', - dau_hint: 'Estimated UUIDs in completed update checks, not all app users', - peak_dau: 'Peak devices among available day observations', - window_requests: 'Observed checks in {{days}} calendar buckets', - daily_average: - 'Complete-day mean {{value}} (available {{samples}} / {{days}} days)', - dau_average: - 'Complete-day mean {{value}} (available {{samples}} / {{days}} days)', - 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; zeroes may include uncollected data.', - 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', - today_live: 'Today, incomplete calendar bucket', - daily_footnote: - 'Calendar days in the timezone above. Today is incomplete; missing device data is not filled with zero.', - 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', - 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.', - update_share_hint: - 'Share of requests offering an update option, not upgrade success', - 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_unattributed: - '{{count}} requests lack an available package breakdown, including historical or missing detail.', - versions_glance_title: 'Highest-volume versions', - versions_glance_question: - 'Ranked by offered targets and client reports in the window, not by release time.', - glance_line: - '{{served}} targets offered · {{activated}} activation reports · {{failed}} download/Patch failures · {{rollback}} rollbacks', - observed_glance: 'Retained cumulative activation UUID estimate', - col_served: 'Targets offered', - col_downloaded: 'Download-success reports', - col_activated: 'Activation-success reports', - col_download_fail: 'Download-failure reports', - col_patch_fail: 'Patch-failure reports', - col_rollback: 'Rollback reports', - col_rollback_rate: 'Rollback report share', - col_failure_rate: 'Download/Patch failure report share', - col_health: 'Rollback observations', - served_exp: 'Experimental option (not gray-rollout participation)', - 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.', - funnel_table_title: 'Version update events', - funnel_title: 'Independent event counts', - 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.', - adopted_mark: 'Retained cumulative activation UUID estimate', - adopted_download: 'Retained cumulative download UUID estimate', - retained_hint: - 'Approximate HLL counts can reset after 35 inactive days. Missing UUIDs or reports can omit devices.', - lag_download: 'Download-success report arrival after version creation', - lag_mark: 'Activation-success report arrival after version creation', - 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.', - 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', - versions_in_window: 'Versions with events', - window_served: 'Total target options offered', - window_rollbacks: 'Total rollback reports', - 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', - packages_question: - 'Which native packages check for updates? Request share is not installation share.', - col_peak_devices: 'Peak among available device observations', - 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', - 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.', - hourly_footnote: - 'Hours in the business timezone above, summed across days. Today is incomplete, so hours can have unequal observation durations.', - failure_events: 'Classified failure reports in {{days}} calendar buckets', - failure_events_hint: - 'Reason aggregates: download failures, Patch failures and rollbacks, not failed devices.', - worst_os: 'Highest failure report share (at least 10 reports)', - no_ranked_os: - 'No platform has both enough samples and a positive failure 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.', - 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.', - 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.', -}; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 860f9cbf..77890c15 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 111d0d64..01e59abb 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 index b633715e..1079d1e9 100644 --- a/src/i18n/resources.ts +++ b/src/i18n/resources.ts @@ -1,20 +1,8 @@ -import { insightsEn, insightsZh } from './insights-metrics'; import en from './locales/en.json'; import zhCN from './locales/zh-CN.json'; -// Runtime registration and locale validation share exactly the same catalog. -// Keeping this module side-effect free also avoids language detection in tests. +// Canonical JSON catalogs are the single source for runtime and validation. export const resources = { - en: { - translation: { - ...en, - app_insights: { ...en.app_insights, ...insightsEn }, - }, - }, - 'zh-CN': { - translation: { - ...zhCN, - app_insights: { ...zhCN.app_insights, ...insightsZh }, - }, - }, + 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 542bda51..2df9fa5a 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 ba6f3fa7..dd8a0834 100644 --- a/src/pages/app-insights/failures-panel.tsx +++ b/src/pages/app-insights/failures-panel.tsx @@ -9,7 +9,11 @@ import { type ReasonRow, summarizeBreakdown, } from './logic'; -import { ObservationNotice, ReportShare } from './observation-ui'; +import { + BreakdownAvailability, + ObservationNotice, + ReportShare, +} from './observation-ui'; import { EmptyState, Footnote, @@ -31,10 +35,12 @@ const DimensionTable = ({ rows, labelOf, keyTitle, + emptyText, }: { rows: DimensionRow[]; labelOf: (key: string) => string; keyTitle: string; + emptyText: string; }) => { const { t } = useTranslation(); const eventLabel = useEventTypeLabel(); @@ -82,7 +88,7 @@ const DimensionTable = ({ columns={columns} pagination={false} scroll={{ x: 'max-content' }} - locale={{ emptyText: t('app_insights.no_observations') }} + locale={{ emptyText }} /> ); }; @@ -111,10 +117,12 @@ export const FailuresPanel = ({ : summarizeBreakdown(breakdown.data?.days, versionFilter), [all, breakdown.data, versionFilter], ); - const hasObservations = - summary.os.length > 0 || - summary.reasons.length > 0 || - summary.carriers.length > 0; + 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 = [ @@ -179,6 +187,7 @@ export const FailuresPanel = ({ updatedAt={breakdown.dataUpdatedAt} stale={!!breakdown.error && !!breakdown.data} /> +
    {t('app_insights.failures_intro')}