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,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">
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
||||
|
|
|
|||
|
|
@ -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} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue