Google API errors as typed findings, never raw JSON: cli/lib/google-errors.ts parses SERVICE_DISABLED (api-disabled: service, Cloud project, console page), PERMISSION_DENIED (no-app-access: which app, Users and permissions), 401 / invalid_grant (auth-revoked) and 429 (quota); health / analytics print one line with the URL, raw text in detail and the log; desktop Health tab: the line, 'Turn it on in Google Cloud' and 'Check again', raw text in a collapsed Details; Portfolio shows a Cloud project blocking several apps once. Findings exit codes are results: the sidecar reports outcome (ok / findings / declined / failed) and the command's own error line; scan / check / validate / health exit 1 with findings show and cache the findings; no bare 'Exit code N' anywhere. tests/google-errors.test.ts, boot check errors cases (api-disabled, scan-findings, scan-failed) (pitfall 57)

This commit is contained in:
CRTkafa 2026-10-13 23:20:04 +08:00
parent f69c10d3a0
commit 89256f1e80
28 changed files with 666 additions and 47 deletions

View file

@ -1,8 +1,10 @@
// Portfolio: every app of every studio on both stores. KPIs, a toolbar (search, studio chips with counts, sort,
// grouping), one row per app (live version and state per store with its place on the release track, next version,
// crash rate, catalog parity, config check), grouped by studio; then what needs attention and the workspace state.
import { plainError } from '../../../../cli/lib/google-errors';
import { BlockedProjectsNotice, blockedProjects } from '../ui/StoreError';
import { useEffect, useMemo, useState } from 'react';
import { api, type AppStatus, type StoreRelease, type ToolResult } from '../api';
import { api, type AppStatus, newJob, type StoreRelease, type ToolResult } from '../api';
import { type Key, t } from '../i18n';
import { ago, pct, share } from '../lib/format';
import { filterSort, groupByStudio, type ListRow, type SortBy, studioCounts, type UnifiedApp } from '../lib/identity';
@ -37,7 +39,7 @@ function StoreCell({ e, store, on }: { e: AppStatus | undefined; store: 'ios' |
if (!on) return <span className="muted small">{t('app.notOnStore')}</span>;
if (!e) return <span className="muted small">{t('common.unknown')}</span>;
const err = e.storeErrors[store];
if (err) return <span title={err}><Chip tone="warn">{t('portfolio.unreadable')}</Chip></span>;
if (err) return <span title={plainError(err)}><Chip tone="warn">{t('portfolio.unreadable')}</Chip></span>;
const all = e.release.all ?? [e.release.current?.ios, e.release.current?.android, e.release.next?.ios, e.release.next?.android].filter((x): x is StoreRelease => !!x);
const { now, next } = storeNow(all, store);
const r = now ?? next;
@ -67,9 +69,9 @@ function NextCell({ e }: { e: AppStatus | undefined }) {
}
/** Last cached health read of every app (the Health tab reads it; the portfolio only shows what is saved). */
function useCachedHealth(targets: string[]): Record<string, { h: HealthSnapshot; at: string } | null> {
function useCachedHealth(targets: string[], round = 0): Record<string, { h: HealthSnapshot; at: string } | null> {
const [m, set] = useState<Record<string, { h: HealthSnapshot; at: string } | null>>({});
const key = targets.join();
const key = targets.join() + '#' + round;
useEffect(() => {
for (const id of targets) {
api.cached<ToolResult>('health', id).then(c => set(x => ({ ...x, [id]: c?.value.data ? { h: c.value.data as HealthSnapshot, at: c.at } : null })), () => {});
@ -131,7 +133,21 @@ export function Portfolio() {
const [query, setQuery] = useState('');
const setView = (v: Partial<View>) => setViewState(x => { const n = { ...x, ...v }; try { localStorage.setItem(VIEW_KEY, JSON.stringify(n)); } catch { /* private mode */ } return n; });
const visible = useMemo(() => d.apps.filter(a => !a.ignored), [d.apps]);
const health = useCachedHealth(visible.map(a => a.target));
const [round, setRound] = useState(0);
const [rechecking, setRechecking] = useState(false);
const health = useCachedHealth(visible.map(a => a.target), round);
const blocked = useMemo(() => blockedProjects(visible.map(a => {
const h = health[a.target]?.h, l = d.liveFor(a);
return { app: a.name, errors: [h?.play && isError(h.play) ? h.play : null, l?.storeErrors.android] };
})), [visible, health, d]);
// "Check again": read health of the blocked apps again (one at a time: the same Cloud project's quota), then the cache
const recheck = async () => {
setRechecking(true);
const names = new Set(blocked.flatMap(b => b.apps));
for (const a of visible.filter(x => names.has(x.name))) await api.call<ToolResult>('health', a.target, { args: {} }, newJob('health')).catch(() => null);
setRechecking(false);
setRound(n => n + 1);
};
const open = (a: UnifiedApp) => nav.go({ screen: 'app', appId: a.key, tab: 'releases' });
const all = useMemo(() => visible.map(app => {
@ -222,6 +238,7 @@ export function Portfolio() {
</div>
{d.portfolioError && <Notice tone="bad">{d.portfolioError}</Notice>}
<BlockedProjectsNotice blocked={blocked} onRetry={recheck} busy={rechecking} />
{neverImported > 0 && <Notice tone="info" icon="download" action={<button className="btn btn-sm" onClick={() => d.importApps()} disabled={d.importBusy}>{t('action.importApps')}</button>}>{t('portfolio.notImported', { count: neverImported })}</Notice>}
{!known && d.ready && <Notice tone="info" action={<button className="btn btn-sm" onClick={() => d.refreshPortfolio(true)}>{t('action.readStores')}</button>}>{t('portfolio.notReadNotice')}</Notice>}