155 lines
9.2 KiB
TypeScript
155 lines
9.2 KiB
TypeScript
// Health: Google Play vitals (user-perceived crash and ANR rate per day against the bad behavior thresholds, worst
|
|
// devices, top issues) and App Store diagnostics (power and performance metrics, signatures, TestFlight crashes).
|
|
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 { 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 (
|
|
<Card title={title} icon="activity" sub={t('health.window', { from: date(s.dates[0]), to: date(s.dates.at(-1)) })}
|
|
actions={<Chip tone={s.tone === 'neutral' ? 'neutral' : s.tone}>{s.tone === 'bad' ? t('health.over') : s.tone === 'warn' ? t('health.near') : s.tone === 'ok' ? t('health.under') : t('common.unknown')}</Chip>}>
|
|
<div className="metric">
|
|
<div className="metric-top">
|
|
<div>
|
|
<div className={`metric-val ${s.tone}`}>{pct(s.current)}</div>
|
|
<div className="small muted">{t('health.current28')}</div>
|
|
</div>
|
|
<div className="metric-legend">
|
|
<span className="lg"><span className={`sw ${s.tone}`} />{t('health.daily')}</span>
|
|
<span className="lg"><span className="sw thr" />{t('health.threshold', { value: pct(s.threshold) })}</span>
|
|
</div>
|
|
</div>
|
|
<Sparkline values={s.values} dates={s.dates} threshold={s.threshold} tone={s.tone} label={t('health.chartAria', { metric: title, value: pct(s.current) })} />
|
|
<div className="axis"><span>{date(s.dates[0])}</span><span>{date(s.dates[Math.floor(s.dates.length / 2)])}</span><span>{date(s.dates.at(-1))}</span></div>
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function Play({ h }: { h: PlayHealth }) {
|
|
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);
|
|
const worst = Math.max(PLAY_THRESHOLDS.perDevice, ...devices.map(x => Math.max(x.crash ?? 0, x.anr ?? 0)));
|
|
return (
|
|
<>
|
|
<div className="grid g2">
|
|
<Metric title={t('health.crash')} s={crash} />
|
|
<Metric title={t('health.anr')} s={anr} />
|
|
</div>
|
|
<div className="grid g2">
|
|
<Card title={t('health.devices')} icon="phone" sub={t('health.devicesSub', { value: pct(PLAY_THRESHOLDS.perDevice, 0) })}>
|
|
{devices.length ? (
|
|
<div className="hbars">
|
|
{devices.map(x => {
|
|
const v = Math.max(x.crash ?? 0, x.anr ?? 0);
|
|
return (
|
|
<div className="hbar" key={x.model}>
|
|
<span className="nm">{x.model}</span>
|
|
<Bar value={v / worst} tone={v >= PLAY_THRESHOLDS.perDevice ? 'bad' : v >= PLAY_THRESHOLDS.perDevice * 0.8 ? 'warn' : 'ok'} label={x.model} />
|
|
<span className="num right small">{pct(v)}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : <p className="muted">{t('health.noDevices')}</p>}
|
|
</Card>
|
|
<Card title={t('health.issues')} icon="alert" sub={t('health.issuesSub')} pad={false}>
|
|
{h.issues.length ? (
|
|
<table className="table">
|
|
<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>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
) : <Empty compact illo="shield" title={t('health.noIssues')} />}
|
|
</Card>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function Apple({ h }: { h: AppleHealth }) {
|
|
const maxCrash = Math.max(1, ...h.betaCrashes.map(b => b.count));
|
|
return (
|
|
<div className="grid g2">
|
|
<Card title={t('health.appleMetrics')} icon="activity" sub={t('health.appleMetricsSub')} pad={false}>
|
|
{h.metrics.length ? (
|
|
<table className="table">
|
|
<thead><tr><th>{t('col.metric')}</th><th className="right">{t('col.value')}</th><th>{t('col.version')}</th><th>{t('col.goal')}</th></tr></thead>
|
|
<tbody>
|
|
{h.metrics.slice(0, 8).map((m, i) => (
|
|
<tr key={i}><td><div className="strong">{m.metric}</div><div className="small muted">{m.category}</div></td><td className="right num strong">{num(m.value, m.value < 10 ? 2 : 0)} <span className="muted small">{m.unit}</span></td>
|
|
<td className="num muted">{m.version}</td><td>{m.goal ? <Chip tone={m.goal === 'poor' ? 'bad' : m.goal === 'fair' ? 'warn' : 'ok'}>{m.goal}</Chip> : '–'}</td></tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
) : <Empty compact illo="pulse" title={t('health.noMetrics')} />}
|
|
</Card>
|
|
<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>
|
|
) : <Empty compact illo="shield" title={t('health.noSignatures')} />}
|
|
</Card>
|
|
<Card title={t('health.betaCrashes')} icon="plane">
|
|
{h.betaCrashes.length ? (
|
|
<div className="hbars">{h.betaCrashes.slice(0, 5).map(b => (
|
|
<div className="hbar" key={b.build}><span className="nm">{t('health.build', { build: b.build })}</span><Bar value={b.count / maxCrash} tone="warn" label={b.build} /><span className="num right small">{num(b.count)}</span></div>
|
|
))}</div>
|
|
) : <p className="muted">{t('health.noBetaCrashes')}</p>}
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<Card pad={false}>
|
|
<Empty illo="pulse" title={t('health.emptyTitle')} actions={<button className="btn btn-primary" onClick={r.refresh}><Icon name="refresh" />{t('health.read')}</button>}>{t('health.emptyText')}</Empty>
|
|
{r.error && <ErrorFoot error={r.error} />}
|
|
</Card>
|
|
);
|
|
}
|
|
const h = r.data;
|
|
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}
|
|
</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} />}
|
|
<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} />}
|
|
</>
|
|
);
|
|
}
|