desktop reads everything by itself: one background read scheduler (4 per store, starts spaced per account, App Store Connect X-Rate-Limit slows it below 20 % and pauses at 100 left, backoff with jitter on 429 / RESOURCE_EXHAUSTED, visible app first, one read per key), per-method TTLs over the saved reads, Settings auto-read (on) and interval (15 min, paused while hidden), Refresh all, a refresh icon and 'read 3 min ago' / 'couldn't refresh' per card, writes invalidate, status line with progress and quota; quota reaches the window through ToolResult.quota (cli/lib/quota.ts); Health top issues open with versions, sample stack (health --issue / --signature, read health_issue), Open in Play Console, Copy for AI and likely-cause hints (cli/lib/crash-hints.ts); tests/scheduler.test.ts, tests/quota-crash-hints.test.ts, boot check auto-read case
This commit is contained in:
parent
89256f1e80
commit
0fb4ce0001
46 changed files with 1531 additions and 141 deletions
|
|
@ -225,6 +225,7 @@ export function Portfolio() {
|
|||
);
|
||||
};
|
||||
|
||||
const notRead = d.portfolioBusy === 'live' ? t('read.waiting') : t('kpi.notRead');
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="ph">
|
||||
|
|
@ -240,13 +241,13 @@ export function Portfolio() {
|
|||
{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>}
|
||||
{!known && d.ready && d.portfolioBusy !== 'live' && <Notice tone="info" action={<button className="btn btn-sm" onClick={() => d.refreshPortfolio(true)}>{t('action.readStores')}</button>}>{t('portfolio.notReadNotice')}</Notice>}
|
||||
|
||||
<div className="kpis">
|
||||
<Kpi icon="layers" label={t('kpi.apps')} value={rows.length} sub={t('kpi.appsSubStore', { ios: iosApps, android: androidApps, store: storeOnly })} />
|
||||
<Kpi icon="check" label={t('kpi.live')} value={known ? liveCount : '–'} sub={known ? t('kpi.liveSub', { count: stores }) : t('kpi.notRead')} />
|
||||
<Kpi icon="rocket" label={t('kpi.rolling')} value={known ? rolling.length : '–'} sub={!known ? t('kpi.notRead') : rolling.length ? [...new Set(rolling.map(r => r.version))].map(v => `${v}: ${rolling.filter(r => r.version === v).map(r => `${r.store === 'ios' ? 'iOS' : 'Android'}${r.fraction !== null ? ` ${share(r.fraction)}` : ''}`).join(', ')}`).join(' · ') : t('kpi.none')} />
|
||||
<Kpi icon="clock" label={t('kpi.review')} value={known ? review.length : '–'} sub={!known ? t('kpi.notRead') : review.length ? review.map(r => `${r.store === 'ios' ? 'iOS' : 'Android'} ${r.version}`).join(' · ') : t('kpi.none')} tone={review.length ? 'warn' : undefined} />
|
||||
<Kpi icon="check" label={t('kpi.live')} value={known ? liveCount : '–'} sub={known ? t('kpi.liveSub', { count: stores }) : notRead} />
|
||||
<Kpi icon="rocket" label={t('kpi.rolling')} value={known ? rolling.length : '–'} sub={!known ? notRead : rolling.length ? [...new Set(rolling.map(r => r.version))].map(v => `${v}: ${rolling.filter(r => r.version === v).map(r => `${r.store === 'ios' ? 'iOS' : 'Android'}${r.fraction !== null ? ` ${share(r.fraction)}` : ''}`).join(', ')}`).join(' · ') : t('kpi.none')} />
|
||||
<Kpi icon="clock" label={t('kpi.review')} value={known ? review.length : '–'} sub={!known ? notRead : review.length ? review.map(r => `${r.store === 'ios' ? 'iOS' : 'Android'} ${r.version}`).join(' · ') : t('kpi.none')} tone={review.length ? 'warn' : undefined} />
|
||||
<Kpi icon="alert" label={t('kpi.attention')} value={att.length} sub={blockers ? t('kpi.blockers', { count: blockers }) : t('kpi.noBlockers')} tone={blockers ? 'bad' : att.length ? 'warn' : undefined} />
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -122,11 +122,11 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;
|
|||
|
||||
<Card id="set-refresh" title={t('set.refresh')} icon="refresh" sub={t('set.refreshSub')} className="set-card">
|
||||
<div className="form">
|
||||
<div className="field"><span className="legend">{t('set.autoRefresh')}</span>
|
||||
<Seg value={String(s.refresh.autoMinutes) as '0'} label={t('set.autoRefresh')} onChange={v => set(x => ({ ...x, refresh: { ...x.refresh, autoMinutes: Number(v) as S['refresh']['autoMinutes'] } }))}
|
||||
options={[['0', t('set.off')], ['15', t('set.min', { n: 15 })], ['30', t('set.min', { n: 30 })], ['60', t('set.hour', { n: 1 })], ['180', t('set.hour', { n: 3 })], ['720', t('set.hour', { n: 12 })]]} />
|
||||
<span className="hint">{t('set.autoHint')}</span></div>
|
||||
<Toggle id="on-launch" label={t('set.onLaunch')} hint={t('set.onLaunchHint')} checked={s.refresh.onLaunch} onChange={v => set(x => ({ ...x, refresh: { ...x.refresh, onLaunch: v } }))} />
|
||||
<Toggle id="auto-read" label={t('set.autoRead')} hint={t('set.autoReadHint')} checked={s.refresh.autoRead} onChange={v => set(x => ({ ...x, refresh: { ...x.refresh, autoRead: v } }))} />
|
||||
<div className="field"><span className="legend">{t('set.everyMinutes')}</span>
|
||||
<Seg value={String(s.refresh.everyMinutes) as '0'} label={t('set.everyMinutes')} onChange={v => set(x => ({ ...x, refresh: { ...x.refresh, everyMinutes: Number(v) as S['refresh']['everyMinutes'] } }))}
|
||||
options={[['0', t('set.onlyOnStart')], ['5', t('set.min', { n: 5 })], ['15', t('set.min', { n: 15 })], ['30', t('set.min', { n: 30 })], ['60', t('set.hour', { n: 1 })]]} />
|
||||
<span className="hint">{t('set.everyHint')}</span></div>
|
||||
<Toggle id="icons" label={t('set.icons')} hint={t('set.iconsHint')} checked={s.refresh.icons} onChange={v => set(x => ({ ...x, refresh: { ...x.refresh, icons: v } }))} />
|
||||
<div className="field"><label htmlFor="parallel">{t('set.parallel')}</label>
|
||||
<select id="parallel" className="input" style={{ width: 120 }} value={s.refresh.parallel} onChange={e => set(x => ({ ...x, refresh: { ...x.refresh, parallel: Number(e.target.value) } }))}>
|
||||
|
|
@ -134,7 +134,7 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;
|
|||
</select><span className="hint">{t('set.parallelHint')}</span></div>
|
||||
<div className="actions">
|
||||
<button className="btn btn-sm" onClick={() => d.importApps()} disabled={d.importBusy || !d.accounts.length}><Icon name="download" />{t('action.importApps')}</button>
|
||||
<button className="btn btn-sm" onClick={() => d.refreshPortfolio(true)} disabled={!!d.portfolioBusy}><Icon name="refresh" />{t('action.readStores')}</button>
|
||||
<button className="btn btn-sm" onClick={() => { d.readAll(true).catch(() => {}); }}><Icon name="refresh" />{t('action.refreshAll')}</button>
|
||||
<span className="small muted">{d.live ? t('foot.lastRead', { when: ago(d.live.at) }) : t('foot.neverRead')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { useData, useRead, useWrite } from '../../store';
|
|||
import { Icon, type IconName, StoreGlyph } from '../../ui/icons';
|
||||
import { OwnerAction } from '../../ui/OwnerAction';
|
||||
import { WriteButton } from '../../ui/WriteButton';
|
||||
import { Card, Chip, Empty, ErrorNotice, Kpi, Skeleton } from '../../ui/kit';
|
||||
import { Card, Chip, Empty, ErrorNotice, Kpi, ReadControl, Skeleton } from '../../ui/kit';
|
||||
import { catalogMd, exportName } from '../../lib/exports';
|
||||
import { useExports } from '../../ui/Exports';
|
||||
import { useNav } from '../../nav';
|
||||
|
|
@ -87,13 +87,13 @@ export function CatalogTab({ project }: { project: Project }) {
|
|||
<div className="kpis" style={{ gridTemplateColumns: 'repeat(4, minmax(0, 1fr))' }}>
|
||||
<Kpi icon="tag" label={t('cat.products')} value={s?.products ?? 0} sub={t('cat.productsSub')} />
|
||||
<Kpi icon="refresh" label={t('cat.subscriptions')} value={s?.subscriptions ?? 0} sub={t('cat.subsSub')} />
|
||||
<Kpi icon="check" label={t('cat.parity')} value={read ? `${inParity}/${rows.filter(r => r.kind !== 'extra').length}` : '–'} sub={read ? t('cat.parityRead', { when: ago(diff.at) }) : t('kpi.notRead')} tone={read && inParity < rows.length ? 'warn' : undefined} />
|
||||
<Kpi icon="globe" label={t('cat.gaps')} value={read ? gaps.length : '–'} sub={read ? t('cat.gapsSub') : t('kpi.notRead')} />
|
||||
<Kpi icon="check" label={t('cat.parity')} value={read ? `${inParity}/${rows.filter(r => r.kind !== 'extra').length}` : '–'} sub={read ? t('cat.parityRead', { when: ago(diff.at) }) : diff.loading ? t('read.waiting') : t('kpi.notRead')} tone={read && inParity < rows.length ? 'warn' : undefined} />
|
||||
<Kpi icon="globe" label={t('cat.gaps')} value={read ? gaps.length : '–'} sub={read ? t('cat.gapsSub') : diff.loading ? t('read.waiting') : t('kpi.notRead')} />
|
||||
</div>
|
||||
{diff.error && <ErrorNotice error={diff.error} keep />}
|
||||
<CatalogActions project={project} products={s?.products ?? 0} subs={s?.subscriptions ?? 0} refresh={() => { diff.refresh(); }} />
|
||||
<Card title={t('cat.matrix')} icon="layers" sub={read ? (diff.fromCache ? t('common.cachedFrom', { when: ago(diff.at) }) : t('common.readWhen', { when: ago(diff.at) })) : t('cat.configOnly')} pad={false}
|
||||
actions={<button className="btn btn-sm" onClick={diff.refresh} disabled={diff.loading}><Icon name="refresh" className={diff.loading ? 'spin' : undefined} />{read ? t('action.refresh') : t('cat.compare')}</button>}>
|
||||
actions={<ReadControl r={diff} label={t('cat.compare')} />}>
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead><tr><th>{t('col.item')}</th><th>{t('col.type')}</th><th>{t('col.config')}</th><th>App Store</th><th>Google Play</th><th className="right">{t('col.priceGap')}</th></tr></thead>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { ago } from '../../lib/format';
|
|||
import type { Finding, ScanResult, ScanSeverity, ValidateResult } from '../../lib/model';
|
||||
import { useData, useRead } from '../../store';
|
||||
import { Icon } from '../../ui/icons';
|
||||
import { Card, Chip, Empty, ErrorNotice, Skeleton } from '../../ui/kit';
|
||||
import { Card, Chip, Empty, ErrorNotice, ReadControl, Skeleton } from '../../ui/kit';
|
||||
import { exportName, validateMd } from '../../lib/exports';
|
||||
import { useExports } from '../../ui/Exports';
|
||||
|
||||
|
|
@ -22,7 +22,7 @@ function ScanCard({ project }: { project: Project }) {
|
|||
const asks = (Array.isArray(s.data?.declarations?.items) ? s.data!.declarations.items : []).filter(x => x.status === 'ask').length;
|
||||
return (
|
||||
<Card title={t('checks.scan')} icon="search" sub={s.data && sdks.length ? t('checks.scanSdks', { list: sdks.map(x => x.name).join(', ') }) : t('checks.scanSub')} pad={false}
|
||||
actions={<button className="btn btn-sm" onClick={s.refresh} disabled={s.loading}><Icon name="refresh" className={s.loading ? 'spin' : undefined} />{t('checks.scanAgain')}</button>}>
|
||||
actions={<ReadControl r={s} label={t('checks.scanAgain')} />}>
|
||||
{s.error && <div className="card-body"><ErrorNotice error={s.error} keep={!!s.data} /></div>}
|
||||
{!s.data ? (s.loading && !s.error ? <div className="card-body"><Skeleton lines={4} /></div> : null)
|
||||
: !findings.length ? <Empty compact illo="shield" title={t('checks.scanClean')}>{t('checks.scanCleanText')}</Empty> : (
|
||||
|
|
@ -80,7 +80,7 @@ export function ChecksTab({ project }: { project: Project }) {
|
|||
)}
|
||||
</Card>
|
||||
<Card title={t('checks.validate')} icon="layers" sub={v.data ? (v.fromCache ? t('common.cachedFrom', { when: ago(v.at) }) : t('common.readWhen', { when: ago(v.at) })) : t('checks.validateSub')} pad={false}
|
||||
actions={<button className="btn btn-sm" onClick={v.refresh} disabled={v.loading}><Icon name="refresh" />{v.data ? t('action.refresh') : t('checks.run')}</button>}>
|
||||
actions={<ReadControl r={v} label={t('checks.run')} />}>
|
||||
{v.error && <div className="card-body"><ErrorNotice error={v.error} keep={!!v.data} /></div>}
|
||||
{!v.data ? (
|
||||
v.loading && !v.error ? <div className="card-body"><Skeleton lines={4} /></div> : <Empty compact illo="cloud" title={t('checks.validateEmpty')}>{t('checks.validateEmptyText')}</Empty>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { useNav } from '../../nav';
|
|||
import { useData, useRead, useWrite } from '../../store';
|
||||
import { useExports } from '../../ui/Exports';
|
||||
import { Icon, StoreGlyph } from '../../ui/icons';
|
||||
import { Card, Chip, Empty, ErrorNotice, Kpi, Notice, Seg, Skeleton } from '../../ui/kit';
|
||||
import { Card, Chip, Empty, ErrorNotice, Kpi, Notice, ReadControl, Seg, Skeleton } from '../../ui/kit';
|
||||
import { LineChart } from '../../ui/LineChart';
|
||||
import { RankChart } from '../../ui/RankChart';
|
||||
import { WriteButton } from '../../ui/WriteButton';
|
||||
|
|
@ -107,7 +107,7 @@ function Aso({ projectId }: { projectId: string }) {
|
|||
})) : [];
|
||||
return (
|
||||
<Card title={t('gr.aso')} icon="search" sub={a ? t('gr.asoSub', { date: date(a.generated) }) : t('gr.asoSubNone')} pad={false}
|
||||
actions={<button className="btn btn-sm" onClick={r.refresh} disabled={r.loading}><Icon name="refresh" className={r.loading ? 'spin' : undefined} />{t('action.refresh')}</button>}>
|
||||
actions={<ReadControl r={r} />}>
|
||||
{r.error ? <div className="card-body"><ErrorNotice error={r.error} /></div> : !a ? <div className="card-body"><Skeleton lines={4} block /></div> : (
|
||||
<div className="card-body stack" style={{ gap: 16 }}>
|
||||
<div className="kpis" style={{ gridTemplateColumns: 'repeat(3, minmax(0, 1fr))' }}>
|
||||
|
|
@ -167,7 +167,7 @@ function Experiments({ app }: { app: UnifiedApp }) {
|
|||
<Card title={t('gr.ppo')} icon="flask" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="ios" className="ic13" />{r.data ? (r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })) : t('gr.ppoSub')}</span>} pad={false}
|
||||
actions={<>
|
||||
<button className="btn btn-sm btn-ghost" onClick={() => api.openExternal(asc).catch(() => {})}><Icon name="external" />{t('gr.results')}</button>
|
||||
<button className="btn btn-sm" onClick={r.refresh} disabled={r.loading}><Icon name="refresh" className={r.loading ? 'spin' : undefined} />{r.data ? t('action.refresh') : t('gr.ppoRead')}</button>
|
||||
<ReadControl r={r} label={t('gr.ppoRead')} />
|
||||
</>}>
|
||||
{r.error && <div className="card-body"><ErrorNotice error={r.error} keep={!!r.data} /></div>}
|
||||
{!r.data ? (r.loading && !r.error ? <div className="card-body"><Skeleton lines={4} /></div> : <Empty compact illo="cloud" title={t('gr.ppoNotRead')}>{t('gr.ppoNotReadText')}</Empty>)
|
||||
|
|
|
|||
|
|
@ -4,9 +4,13 @@ import type { UnifiedApp } from '../../lib/identity';
|
|||
import { t } from '../../i18n';
|
||||
import { ago, date, num, pct } from '../../lib/format';
|
||||
import { type AppleHealth, healthSeries, type HealthSnapshot, isError, PLAY_THRESHOLDS, type PlayHealth, type Series } from '../../lib/model';
|
||||
import { useRead } from '../../store';
|
||||
import { useState } from 'react';
|
||||
import { api } from '../../api';
|
||||
import { appleSignatureView, hintFor, type IssueView, issueMarkdown, playIssueView } from '../../lib/issues';
|
||||
import type { IssueSample } from '../../lib/model';
|
||||
import { useData, useRead } from '../../store';
|
||||
import { Icon } from '../../ui/icons';
|
||||
import { Bar, Card, Chip, Empty, ErrorFoot, Notice, Skeleton, Sparkline } from '../../ui/kit';
|
||||
import { Bar, Card, Chip, Empty, ErrorFoot, ErrorNotice, Notice, ReadControl, Skeleton, Sparkline } from '../../ui/kit';
|
||||
import { exportName, healthMd } from '../../lib/exports';
|
||||
import { useExports } from '../../ui/Exports';
|
||||
import { StoreReadNotice } from '../../ui/StoreError';
|
||||
|
|
@ -33,7 +37,47 @@ function Metric({ title, s }: { title: string; s: Series }) {
|
|||
);
|
||||
}
|
||||
|
||||
function Play({ h }: { h: PlayHealth }) {
|
||||
/** One issue's detail: full title and type, counts, versions, last report, a sample stack (read and cached like the
|
||||
* rest), "Open in Play Console" and "Copy for AI" (compact markdown for an agent). */
|
||||
function IssueDetail({ app, v }: { app: UnifiedApp; v: IssueView }) {
|
||||
const d = useData();
|
||||
const s = useRead<{ sample: IssueSample | null }>(app.target, 'health.issue', { network: true, args: v.sampleArgs ?? {}, skip: !v.sampleArgs });
|
||||
const sample = s.data?.sample ?? null;
|
||||
const copy = () => {
|
||||
navigator.clipboard?.writeText(issueMarkdown(app.name, v, sample)).then(() => d.toast({ kind: 'ok', title: t('health.copied') }), () => d.toast({ kind: 'bad', title: t('health.copyFailed') }));
|
||||
};
|
||||
const range = (a?: string, b?: string) => (a && b && a !== b ? `${a} – ${b}` : a ?? b ?? '–');
|
||||
return (
|
||||
<div className="issue-detail" data-testid="issue-detail">
|
||||
<div className="strong issue-title">{v.title}</div>
|
||||
<dl className="kv">
|
||||
<dt>{t('health.issueType')}</dt><dd>{v.kind}</dd>
|
||||
{v.where && v.where !== v.title && <><dt>{t('health.issueWhere')}</dt><dd className="mono-id">{v.where}</dd></>}
|
||||
{v.reports !== undefined && <><dt>{t('col.reports')}</dt><dd className="num">{num(v.reports)}{v.users !== undefined ? ` · ${t('col.users')} ${num(v.users)}` : ''}</dd></>}
|
||||
{v.weight !== undefined && <><dt>{t('health.issueShare')}</dt><dd className="num">{num(v.weight * 100, 1)}%</dd></>}
|
||||
{(v.versions?.first || v.versions?.last) && <><dt>{t('health.issueVersions')}</dt><dd className="num">{range(v.versions.first, v.versions.last)}</dd></>}
|
||||
{v.build && <><dt>{t('health.issueBuild')}</dt><dd className="num">{v.build}</dd></>}
|
||||
{v.os && <><dt>{t('health.issueOs')}</dt><dd className="num">{range(v.os.first, v.os.last)}</dd></>}
|
||||
{v.lastSeen && <><dt>{t('health.issueLastSeen')}</dt><dd>{ago(v.lastSeen)}</dd></>}
|
||||
</dl>
|
||||
{(() => { const h = hintFor(v, sample); return h ? <Notice tone="info" icon="wand"><div className="stack" style={{ gap: 4 }} data-testid="issue-hint"><span><b>{t('health.likelyCause')}</b> {h.cause}</span><span><b>{t('health.suggestedFix')}</b> {h.fix}</span></div></Notice> : null; })()}
|
||||
{v.sampleArgs && (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<div className="row between"><span className="label">{t('health.sample')}{sample ? ` · ${[sample.version, sample.device, sample.os].filter(Boolean).join(' · ')}` : ''}</span><ReadControl r={s} /></div>
|
||||
{sample ? <pre className="stack-trace" data-testid="issue-stack">{sample.stack.join('\n')}</pre>
|
||||
: s.loading ? <Skeleton lines={4} /> : !s.error ? <p className="small muted">{t('health.noSample')}</p> : null}
|
||||
</div>
|
||||
)}
|
||||
<div className="actions">
|
||||
<button className="btn btn-sm" onClick={copy}><Icon name="clipboard" />{t('health.copyForAi')}</button>
|
||||
{v.url && <button className="btn btn-sm btn-ghost" onClick={() => api.openExternal(v.url!).catch(() => {})}><Icon name="external" />{t('health.openConsole')}</button>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Play({ h, app }: { h: PlayHealth; app: UnifiedApp }) {
|
||||
const [open, setOpen] = useState<number | null>(null);
|
||||
const crash = healthSeries(h.crash, 'userPerceivedCrashRate', PLAY_THRESHOLDS.crash);
|
||||
const anr = healthSeries(h.anr, 'userPerceivedAnrRate', PLAY_THRESHOLDS.anr);
|
||||
const devices = (h.devices ?? []).slice(0, 6);
|
||||
|
|
@ -67,11 +111,11 @@ function Play({ h }: { h: PlayHealth }) {
|
|||
<thead><tr><th>{t('col.issue')}</th><th className="right">{t('col.reports')}</th><th className="right">{t('col.users')}</th></tr></thead>
|
||||
<tbody>
|
||||
{h.issues.slice(0, 6).map((i, n) => (
|
||||
<tr key={n}>
|
||||
<td><div className="row" style={{ gap: 8 }}><Chip tone={i.type === 'ANR' ? 'warn' : 'bad'}>{i.type === 'ANR' ? 'ANR' : t('health.crashShort')}</Chip><span className="strong" style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 260 }}>{i.cause || '?'}</span></div>
|
||||
<div className="mono-id muted" style={{ marginTop: 4, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 360 }}>{i.location}</div></td>
|
||||
<td className="right num">{num(i.reports)}</td><td className="right num">{num(i.users)}</td>
|
||||
</tr>
|
||||
<IssueRows key={n} open={open === n} onToggle={() => setOpen(open === n ? null : n)} cols={3}
|
||||
head={<><div className="row" style={{ gap: 8, minWidth: 0 }}><Chip tone={i.type === 'ANR' ? 'warn' : 'bad'}>{i.type === 'ANR' ? 'ANR' : t('health.crashShort')}</Chip><span className="strong ellipsis" title={i.cause || '?'}>{i.cause || '?'}</span></div>
|
||||
<div className="mono-id muted ellipsis" style={{ marginTop: 4 }} title={i.location}>{i.location}</div></>}
|
||||
cells={<><td className="right num">{num(i.reports)}</td><td className="right num">{num(i.users)}</td></>}
|
||||
detail={<IssueDetail app={app} v={playIssueView(i)} />} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
@ -82,7 +126,21 @@ function Play({ h }: { h: PlayHealth }) {
|
|||
);
|
||||
}
|
||||
|
||||
function Apple({ h }: { h: AppleHealth }) {
|
||||
/** A table row that opens its detail row below it (click or Enter / Space). */
|
||||
function IssueRows({ open, onToggle, head, cells, detail, cols }: { open: boolean; onToggle: () => void; head: React.ReactNode; cells: React.ReactNode; detail: React.ReactNode; cols: number }) {
|
||||
return (
|
||||
<>
|
||||
<tr className={`issue-row${open ? ' open' : ''}`} onClick={onToggle} onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggle(); } }} tabIndex={0} aria-expanded={open} role="button">
|
||||
<td style={{ maxWidth: 380 }}><div className="row" style={{ gap: 6, minWidth: 0 }}><Icon name="chevronRight" className={`ic13 chev${open ? ' rot' : ''}`} /><div style={{ minWidth: 0 }}>{head}</div></div></td>
|
||||
{cells}
|
||||
</tr>
|
||||
{open && <tr className="edit-row"><td colSpan={cols}>{detail}</td></tr>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Apple({ h, app }: { h: AppleHealth; app: UnifiedApp }) {
|
||||
const [open, setOpen] = useState<number | null>(null);
|
||||
const maxCrash = Math.max(1, ...h.betaCrashes.map(b => b.count));
|
||||
return (
|
||||
<div className="grid g2">
|
||||
|
|
@ -102,15 +160,16 @@ function Apple({ h }: { h: AppleHealth }) {
|
|||
<div className="stack" style={{ gap: 16 }}>
|
||||
<Card title={t('health.signatures')} icon="layers" sub={t('health.signaturesSub')} pad={false}>
|
||||
{h.signatures.length ? (
|
||||
<div className="list">
|
||||
{h.signatures.slice(0, 5).map((s, i) => (
|
||||
<div className="li" key={i}>
|
||||
<span className="li-icon tone-bg-neutral"><Icon name="alertCircle" /></span>
|
||||
<div className="li-main"><span className="li-title" style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.signature || s.type}</span><span className="li-sub">{s.type} · {t('health.build', { build: s.build })}</span></div>
|
||||
<span className="num small strong">{num(s.weight * 100, 1)}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<table className="table">
|
||||
<tbody>
|
||||
{h.signatures.slice(0, 5).map((s, i) => (
|
||||
<IssueRows key={i} open={open === i} onToggle={() => setOpen(open === i ? null : i)} cols={2}
|
||||
head={<><div className="strong ellipsis" title={s.signature || s.type}>{s.signature || s.type}</div><div className="small muted">{s.type} · {t('health.build', { build: s.build })}</div></>}
|
||||
cells={<td className="right num small strong">{num(s.weight * 100, 1)}%</td>}
|
||||
detail={<IssueDetail app={app} v={appleSignatureView(s)} />} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : <Empty compact illo="shield" title={t('health.noSignatures')} />}
|
||||
</Card>
|
||||
<Card title={t('health.betaCrashes')} icon="plane">
|
||||
|
|
@ -128,7 +187,6 @@ function Apple({ h }: { h: AppleHealth }) {
|
|||
export function HealthTab({ app }: { app: UnifiedApp }) {
|
||||
const r = useRead<HealthSnapshot>(app.target, 'health', { network: true, title: t('tab.health'), appName: app.name });
|
||||
useExports('health', [{ id: 'health-md', label: t('export.health'), icon: 'activity', kind: 'md', file: exportName(app.name, 'health'), disabled: r.data ? false : t('export.readFirst'), build: () => healthMd(app.name, r.data!) }]);
|
||||
const refresh = <button className="btn btn-sm" onClick={r.refresh} disabled={r.loading}><Icon name="refresh" className={r.loading && !r.data ? 'spin' : undefined} />{r.data ? t('action.refresh') : t('health.read')}</button>;
|
||||
if (!r.data) {
|
||||
if (r.loading && !r.error) return <Card><Skeleton lines={4} block /></Card>;
|
||||
return (
|
||||
|
|
@ -142,14 +200,13 @@ export function HealthTab({ app }: { app: UnifiedApp }) {
|
|||
return (
|
||||
<>
|
||||
<div className="row between">
|
||||
<span className="stamp"><Icon name="clock" />{r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })}</span>
|
||||
{refresh}
|
||||
<span />
|
||||
<ReadControl r={r} />
|
||||
</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) ? <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} />}
|
||||
{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" /> : <>{h.play.issuesError && <ErrorNotice error={t('health.issuesUnread', { error: h.play.issuesError })} />}<Play h={h.play} app={app} /></>}
|
||||
<h2 className="label">App Store</h2>
|
||||
{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} />}
|
||||
{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} app={app} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { changePct, diffSummary, priceMatrix, priceStats, type RegionRow, region
|
|||
import { type ReadState, useData, useRead, useWrite } from '../../store';
|
||||
import { type ExportItem, useExports, useRunExport } from '../../ui/Exports';
|
||||
import { Icon, StoreGlyph } from '../../ui/icons';
|
||||
import { Card, Chip, Empty, ErrorNotice, Kpi, Notice, Skeleton } from '../../ui/kit';
|
||||
import { Card, Chip, Empty, ErrorNotice, Kpi, Notice, ReadControl, Skeleton } from '../../ui/kit';
|
||||
import { WriteButton } from '../../ui/WriteButton';
|
||||
|
||||
const GAP = 10;
|
||||
|
|
@ -158,7 +158,7 @@ export function PricesTab({ project }: { project: Project }) {
|
|||
<Card title={t('prices.overview')} icon="grid" sub={cur.fromCache ? t('common.cachedFrom', { when: ago(cur.at) }) : t('common.readWhen', { when: ago(cur.at) })} pad={false}
|
||||
actions={<>
|
||||
<label className="check"><input type="checkbox" checked={withAuto} onChange={ev => setWithAuto(ev.target.checked)} />{t('prices.withAuto')}</label>
|
||||
<button className="btn btn-sm" onClick={cur.refresh} disabled={cur.loading}><Icon name="refresh" className={cur.loading ? 'spin' : undefined} />{t('action.refresh')}</button>
|
||||
<ReadControl r={cur} />
|
||||
</>}>
|
||||
<div className="table-wrap">
|
||||
<table className="table px-matrix">
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { type Lane, STAGES, type TestFlightStatus, trainLanes, versionHistory }
|
|||
import { useData, useRead, useWrite } from '../../store';
|
||||
import { Icon, StoreGlyph } from '../../ui/icons';
|
||||
import { RolloutBar } from '../../ui/charts';
|
||||
import { Card, Chip, Empty, Notice, PhaseChip, storeName } from '../../ui/kit';
|
||||
import { Card, Chip, Empty, Notice, PhaseChip, Skeleton, storeName } from '../../ui/kit';
|
||||
import { AppleSubmitCard, PlayProductionCard } from './Submit';
|
||||
|
||||
const STAGE_KEY: Record<(typeof STAGES)[number], Key> = { build: 'stage.build', testing: 'stage.testing', review: 'stage.review', live: 'stage.live', rollout: 'stage.rollout' };
|
||||
|
|
@ -139,6 +139,7 @@ export function ReleasesTab({ app }: { app: UnifiedApp }) {
|
|||
</section>
|
||||
) : null;
|
||||
|
||||
if (!l && d.portfolioBusy === 'live') return <><Card><Skeleton lines={5} block /></Card>{submit}</>;
|
||||
if (!l) {
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -5,12 +5,12 @@ import { useEffect, useMemo, useState } from 'react';
|
|||
import type { DraftsResult, ReplyDraft } from '../../api';
|
||||
import type { UnifiedApp } from '../../lib/identity';
|
||||
import { t } from '../../i18n';
|
||||
import { ago, date, num } from '../../lib/format';
|
||||
import { date, num } from '../../lib/format';
|
||||
import { draftName, filterReviews, type Review, type ReviewFilter, starStats } from '../../lib/model';
|
||||
import { useRead, useWrite } from '../../store';
|
||||
import { WriteButton } from '../../ui/WriteButton';
|
||||
import { Icon, StoreGlyph } from '../../ui/icons';
|
||||
import { Bar, Card, Chip, Empty, ErrorFoot, Notice, Seg, Skeleton, Stars } from '../../ui/kit';
|
||||
import { Bar, Card, Chip, Empty, ErrorFoot, ReadControl, Seg, Skeleton, Stars } from '../../ui/kit';
|
||||
import { exportName, reviewsCsv } from '../../lib/exports';
|
||||
import { useExports } from '../../ui/Exports';
|
||||
|
||||
|
|
@ -111,13 +111,11 @@ export function ReviewsTab({ app }: { app: UnifiedApp }) {
|
|||
return (
|
||||
<>
|
||||
<div className="row between">
|
||||
<span className="stamp"><Icon name="clock" />{list.fromCache ? t('common.cachedFrom', { when: ago(list.at) }) : t('common.readWhen', { when: ago(list.at) })}</span>
|
||||
<ReadControl r={list} />
|
||||
<div className="actions">
|
||||
{project && <WriteButton run={w} icon="text" hint={t('reviews.makeDraftsHint')} w={{ method: 'reviews.draft', args: { days: 30 }, title: t('reviews.makeDrafts'), done: t('reviews.draftsMade'), after: changed }}>{t('reviews.makeDrafts')}</WriteButton>}
|
||||
<button className="btn btn-sm" onClick={list.refresh} disabled={list.loading}><Icon name="refresh" />{t('action.refresh')}</button>
|
||||
</div>
|
||||
</div>
|
||||
{list.error && <Notice tone="warn">{t('common.readFailedKeep', { error: list.error })}</Notice>}
|
||||
<div className="rv">
|
||||
<div className="stack" style={{ gap: 16 }}>
|
||||
<Card title={t('reviews.ratings')} icon="star">
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import type { UnifiedApp } from '../../lib/identity';
|
|||
import { useData, useRead } from '../../store';
|
||||
import { ConsolePlanButton } from '../../ui/ConsolePlan';
|
||||
import { Icon, StoreGlyph } from '../../ui/icons';
|
||||
import { Card, Chip, Empty, ErrorNotice, Notice, Seg, Skeleton } from '../../ui/kit';
|
||||
import { Card, Chip, Empty, ErrorNotice, Notice, ReadControl, Seg, Skeleton } from '../../ui/kit';
|
||||
import { OwnerAction } from '../../ui/OwnerAction';
|
||||
import { ascUrl, PLAY_CONSOLE } from '../AppView';
|
||||
|
||||
|
|
@ -40,9 +40,9 @@ export function AppleSubmitCard({ app, project }: { app: UnifiedApp; project: Pr
|
|||
const args = { build, ...(version ? { version: version.version } : {}), ...(resolveIaps ? { resolve_iaps: true } : {}) };
|
||||
const refresh = () => { r.refresh(); };
|
||||
return (
|
||||
<Card title={t('sub.appleTitle')} icon="rocket" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="ios" className="ic13" />{r.data ? (r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })) : t('sub.appleSub')}</span>}
|
||||
<Card title={t('sub.appleTitle')} icon="rocket" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="ios" className="ic13" />{r.data ? (r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })) : r.loading ? t('read.waiting') : t('sub.appleSub')}</span>}
|
||||
actions={<>
|
||||
<button className="btn btn-sm" onClick={refresh} disabled={r.loading && !!r.data}><Icon name="refresh" className={r.loading && r.data ? 'spin' : undefined} />{r.data ? t('action.refresh') : t('sub.read')}</button>
|
||||
<ReadControl r={{ ...r, refresh: async () => refresh() }} label={t('sub.read')} />
|
||||
<button className="btn btn-sm btn-ghost" onClick={() => api.openExternal(ascUrl(app.ios?.app.id ?? summary?.apple?.appId)).catch(() => {})}><Icon name="external" />App Store Connect</button>
|
||||
</>}>
|
||||
{r.error && <ErrorNotice error={r.error} keep={!!r.data} />}
|
||||
|
|
@ -142,9 +142,9 @@ export function PlayProductionCard({ app, project }: { app: UnifiedApp; project:
|
|||
const picked = s?.codes.find(c => c.code === code) ?? null;
|
||||
const args = { version_code: code, status, ...(status === 'inProgress' ? { percent: Number(share) } : {}) };
|
||||
return (
|
||||
<Card title={t('sub.playTitle')} icon="rocket" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="android" className="ic13" />{r.data ? (r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })) : t('sub.playSub')}</span>}
|
||||
<Card title={t('sub.playTitle')} icon="rocket" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="android" className="ic13" />{r.data ? (r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })) : r.loading ? t('read.waiting') : t('sub.playSub')}</span>}
|
||||
actions={<>
|
||||
<button className="btn btn-sm" onClick={() => { r.refresh(); }} disabled={r.loading && !!r.data}><Icon name="refresh" className={r.loading && r.data ? 'spin' : undefined} />{r.data ? t('action.refresh') : t('sub.read')}</button>
|
||||
<ReadControl r={r} label={t('sub.read')} />
|
||||
<button className="btn btn-sm btn-ghost" onClick={() => api.openExternal(PLAY_CONSOLE).catch(() => {})}><Icon name="external" />Play Console</button>
|
||||
</>}>
|
||||
{r.error && <ErrorNotice error={r.error} keep={!!r.data} />}
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@ import { Fragment, useState } from 'react';
|
|||
import type { UnifiedApp } from '../../lib/identity';
|
||||
import { WriteButton } from '../../ui/WriteButton';
|
||||
import { t } from '../../i18n';
|
||||
import { ago, date, num } from '../../lib/format';
|
||||
import { date, num } from '../../lib/format';
|
||||
import type { TestFlightStatus, TfBuild, TfGroup, Tone } from '../../lib/model';
|
||||
import { useData, useRead, useWrite } from '../../store';
|
||||
import { Icon } from '../../ui/icons';
|
||||
import { Card, Chip, Empty, Notice, Skeleton } from '../../ui/kit';
|
||||
import { Card, Chip, Empty, Notice, ReadControl, Skeleton } from '../../ui/kit';
|
||||
|
||||
/** Beta state word -> tone and a short label. */
|
||||
export function betaState(s: string | null): { tone: Tone; text: string } {
|
||||
|
|
@ -96,10 +96,9 @@ export function TestFlightTab({ app }: { app: UnifiedApp }) {
|
|||
return (
|
||||
<>
|
||||
<div className="row between">
|
||||
<span className="stamp"><Icon name="clock" />{r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })}</span>
|
||||
<button className="btn btn-sm" onClick={r.refresh} disabled={r.loading}><Icon name="refresh" />{t('action.refresh')}</button>
|
||||
<span />
|
||||
<ReadControl r={r} />
|
||||
</div>
|
||||
{r.error && <Notice tone="warn">{t('common.readFailedKeep', { error: r.error })}</Notice>}
|
||||
{s.missingGroups.length > 0 && (
|
||||
<Notice tone="warn" action={writable ? <WriteButton run={w} icon="plus" w={{ method: 'apple.testflight_groups', args: {}, title: t('tf.groupsSync'), done: t('tf.groupsSynced'), after: r.refresh }} hint={t('tf.groupsSyncHint')}>{t('tf.groupsSync')}</WriteButton> : undefined}>
|
||||
{t('tf.missingGroups', { groups: s.missingGroups.join(', ') })}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue