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:
parent
f69c10d3a0
commit
89256f1e80
28 changed files with 666 additions and 47 deletions
|
|
@ -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>}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue