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,5 +1,6 @@
// Workspace: app folders (one storebound.config.ts per app), which store accounts each app uses, and starting a new
// folder from the templates (`storebound init` with a profile, confirmed in a native dialog).
import { failureText } from '../lib/results';
import { useEffect, useRef, useState } from 'react';
import { api, errorText, newJob, onProgress, type Project, type ToolResult, type WriteOutcome } from '../api';
import { devSwitch } from '../dev/switches';
@ -69,7 +70,7 @@ export function AppFolders() {
const apple = d.accounts.filter(a => a.kind === 'apple');
const status = !run ? '' : run.error ? t('run.failed') : !run.result ? t('run.running') : run.outcome
? (run.outcome.confirmed ? t('run.applied') : run.outcome.stage === 'dialog' ? t('run.cancelled') : t('run.dryFailed'))
: run.result.ok ? t('run.done') : t('common.exit', { code: run.result.exitCode });
: run.result.ok ? t('run.done') : failureText(run.result);
return (
<div className="page">

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>}

View file

@ -9,6 +9,7 @@ import { Icon } from '../../ui/icons';
import { Bar, Card, Chip, Empty, ErrorFoot, Notice, Skeleton, Sparkline } from '../../ui/kit';
import { exportName, healthMd } from '../../lib/exports';
import { useExports } from '../../ui/Exports';
import { StoreReadNotice } from '../../ui/StoreError';
function Metric({ title, s }: { title: string; s: Series }) {
return (
@ -146,9 +147,9 @@ export function HealthTab({ app }: { app: UnifiedApp }) {
</div>
{r.error && <Notice tone="warn">{t('common.readFailedKeep', { error: r.error })}</Notice>}
<h2 className="label">Google Play</h2>
{h.play === undefined ? <Notice tone="info">{t('health.noPlay')}</Notice> : isError(h.play) ? <Notice tone="warn">{t('health.unreadable', { store: 'Google Play', error: h.play.error })}</Notice> : <Play h={h.play} />}
{h.play === undefined ? <Notice tone="info">{t('health.noPlay')}</Notice> : isError(h.play) ? <StoreReadNotice store="Google Play" e={h.play} app={app.android?.app.id ?? null} onRetry={r.refresh} busy={r.loading} unreadable="health.unreadable" /> : <Play h={h.play} />}
<h2 className="label">App Store</h2>
{h.apple === undefined ? <Notice tone="info">{t('health.noApple')}</Notice> : isError(h.apple) ? <Notice tone="warn">{t('health.unreadable', { store: 'App Store', error: h.apple.error })}</Notice> : <Apple h={h.apple} />}
{h.apple === undefined ? <Notice tone="info">{t('health.noApple')}</Notice> : isError(h.apple) ? <StoreReadNotice store="App Store" e={h.apple} onRetry={r.refresh} busy={r.loading} unreadable="health.unreadable" /> : <Apple h={h.apple} />}
</>
);
}

View file

@ -2,6 +2,7 @@
// rollout %), then every version read from the stores, then sending for review (Submit.tsx): App Store submit /
// resubmit / in-app purchases and the Google Play production release as owner actions, the Play steps without an API
// as console plans. Releasing an approved version (manual release) stays in App Store Connect.
import { plainError } from '../../../../../cli/lib/google-errors';
import { useState } from 'react';
import type { ReleaseTrain, StoreRelease } from '../../api';
import type { UnifiedApp } from '../../lib/identity';
@ -155,7 +156,7 @@ export function ReleasesTab({ app }: { app: UnifiedApp }) {
const errs = Object.entries(l.storeErrors);
return (
<>
{errs.map(([s, msg]) => <Notice key={s} tone="warn">{t('releases.storeUnreadable', { store: storeName(s as 'ios'), error: msg })}</Notice>)}
{errs.map(([s, msg]) => <Notice key={s} tone="warn">{t('releases.storeUnreadable', { store: storeName(s as 'ios'), error: plainError(msg) })}</Notice>)}
{l.release.next && <Train title={t('tl.next')} train={l.release.next} tf={tf.data} tone="accent" app={app} />}
{l.release.current && <Train title={t('tl.live')} train={l.release.current} tf={tf.data} tone="ok" app={app} />}
{!l.release.next && !l.release.current && <Card pad={false}><Empty compact illo="unknown" title={t('releases.noVersions')}>{t('releases.noVersionsText')}</Empty></Card>}