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
|
|
@ -251,6 +251,12 @@ pub struct RefreshSettings {
|
|||
pub parallel: u8,
|
||||
/// Read app icons from the stores when importing (an extra request per app).
|
||||
pub icons: bool,
|
||||
/// Background reading of every app (status, releases, catalog, prices, listings, TestFlight, health, reviews,
|
||||
/// analytics, checks): on start, when the window shows again and every `every_minutes` while it is open.
|
||||
pub auto_read: bool,
|
||||
/// Minutes between background passes while the window is visible (only reads past their TTL run); 0 = only on
|
||||
/// start and when the window shows again.
|
||||
pub every_minutes: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
@ -282,7 +288,7 @@ pub struct Settings {
|
|||
// build that lacks a newer setting keeps the rest.
|
||||
impl Default for RefreshSettings {
|
||||
fn default() -> RefreshSettings {
|
||||
RefreshSettings { auto_minutes: 0, on_launch: false, parallel: 4, icons: true }
|
||||
RefreshSettings { auto_minutes: 0, on_launch: false, parallel: 4, icons: true, auto_read: true, every_minutes: 15 }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -306,11 +312,15 @@ impl Default for Settings {
|
|||
|
||||
pub const AUTO_MINUTES: &[u32] = &[0, 15, 30, 60, 180, 720];
|
||||
pub const KEEP_DAYS: &[u32] = &[0, 7, 30, 90, 365];
|
||||
pub const EVERY_MINUTES: &[u32] = &[0, 5, 15, 30, 60];
|
||||
|
||||
pub fn check_settings(s: &Settings) -> Result<(), String> {
|
||||
if !AUTO_MINUTES.contains(&s.refresh.auto_minutes) {
|
||||
return Err("refresh: 0 (off), 15, 30, 60, 180 or 720 minutes".into());
|
||||
}
|
||||
if !EVERY_MINUTES.contains(&s.refresh.every_minutes) {
|
||||
return Err("background reading: 0 (only on start), 5, 15, 30 or 60 minutes".into());
|
||||
}
|
||||
if !(1..=8).contains(&s.refresh.parallel) {
|
||||
return Err("parallel reads: 1 to 8".into());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,6 +106,15 @@ fn settings_have_safe_defaults_and_bounds() {
|
|||
s = d.clone();
|
||||
s.refresh.auto_minutes = 1;
|
||||
assert!(check_settings(&s).is_err(), "no tight polling of the store APIs");
|
||||
assert_eq!((d.refresh.auto_read, d.refresh.every_minutes), (true, 15), "background reading is on by default, every 15 minutes");
|
||||
s = d.clone();
|
||||
s.refresh.every_minutes = 1;
|
||||
assert!(check_settings(&s).is_err(), "no background pass more often than every 5 minutes");
|
||||
s = d.clone();
|
||||
s.refresh.every_minutes = 0;
|
||||
assert!(check_settings(&s).is_ok());
|
||||
let old: Settings = serde_json::from_value(serde_json::json!({ "refresh": { "autoMinutes": 15 } })).unwrap();
|
||||
assert_eq!((old.refresh.auto_read, old.refresh.every_minutes, old.refresh.auto_minutes), (true, 15, 15), "an older settings file gets the new defaults");
|
||||
s = d.clone();
|
||||
s.cache.keep_days = 3;
|
||||
assert!(check_settings(&s).is_err());
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
|||
import { FIXTURES, onDeepLink, onDeepLinkFolder } from './api';
|
||||
import { devSwitch } from './dev/switches';
|
||||
import { type Key, t } from './i18n';
|
||||
import { ago } from './lib/format';
|
||||
import { ago, num } from './lib/format';
|
||||
import { groupByStudio, type UnifiedApp } from './lib/identity';
|
||||
import { NavCtx, parseStart, type Route, sameRoute, TAB_ICON, TAB_KEY, tabsFor, WORKSPACE, type WorkspaceScreen } from './nav';
|
||||
import { usePrefs } from './prefs-ctx';
|
||||
|
|
@ -133,6 +133,9 @@ export function App({ onTheme }: { onTheme: () => void }) {
|
|||
: route.screen === 'app' ? [{ label: t('nav.portfolio'), to: { screen: 'portfolio' } }, { label: app?.name ?? '', to: app ? { screen: 'app', appId: app.key, tab: 'releases' } : undefined }, { label: t(TAB_KEY[route.tab]) }]
|
||||
: [{ label: t('nav.workspace') }, { label: t(WS_KEY[route.screen]) }];
|
||||
const lastRead = d.live?.at ?? null;
|
||||
// the app on screen is read first by the background reader
|
||||
const { setFocus } = d;
|
||||
useEffect(() => { setFocus(app?.key ?? null); }, [app?.key, setFocus]);
|
||||
const nav = useMemo(() => ({ route, go, openPalette: () => setPalette(true), openWelcome: () => setWelcome(true) }), [route, go]);
|
||||
const current = (r: Route) => (sameRoute(r, route) || (r.screen === 'app' && route.screen === 'app' && r.appId === route.appId) || (r.screen !== 'app' && r.screen !== 'portfolio' && r.screen === route.screen) ? 'page' : undefined);
|
||||
const groups = d.studios.length ? groupByStudio(visible.map(a => ({ app: a })), d.studios) : [{ studio: null as string | null, rows: visible.map(a => ({ app: a })) }];
|
||||
|
|
@ -202,6 +205,7 @@ export function App({ onTheme }: { onTheme: () => void }) {
|
|||
<div className="row"><i className={`dot ${d.sidecar?.running ? 'ok' : d.sidecarError ? 'bad' : ''}`} />
|
||||
<span>{d.sidecar?.running ? t('foot.cli', { version: d.sidecar.version }) : d.sidecarError ? t('foot.cliDown') : t('foot.cliStarting')}</span></div>
|
||||
<div className="row"><Icon name="clock" className="sr-icon" /><span>{lastRead ? t('foot.lastRead', { when: ago(lastRead) }) : t('foot.neverRead')}</span></div>
|
||||
<ReaderLine />
|
||||
</div>
|
||||
</nav>
|
||||
<div className="main">
|
||||
|
|
@ -217,8 +221,8 @@ export function App({ onTheme }: { onTheme: () => void }) {
|
|||
<span className="spacer" />
|
||||
{FIXTURES && <span className="dev-pill" title={t('dev.fixturesHint')}>{t('dev.fixtures')}</span>}
|
||||
<span className="stamp"><Icon name="clock" />{lastRead ? t('top.storesRead', { when: ago(lastRead) }) : t('top.storesNotRead')}</span>
|
||||
<button className="btn btn-sm" onClick={() => d.refreshPortfolio(true)} disabled={!!d.portfolioBusy || !visible.length} title={t('action.readStoresHint')}>
|
||||
<Icon name="refresh" className={d.portfolioBusy === 'live' ? 'spin' : undefined} />{d.portfolioBusy === 'live' ? t('action.reading') : t('action.readStores')}
|
||||
<button className="btn btn-sm" onClick={() => { d.readAll(true).catch(() => {}); }} disabled={!visible.length} title={t('action.refreshAllHint')} data-testid="refresh-all">
|
||||
<Icon name="refresh" className={d.reader.running ? 'spin' : undefined} />{t('action.refreshAll')}
|
||||
</button>
|
||||
<ExportMenu />
|
||||
</header>
|
||||
|
|
@ -242,3 +246,18 @@ export function App({ onTheme }: { onTheme: () => void }) {
|
|||
</NavCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** The background reader in one line: "Reading 12 of 48 · App Store limit 3 412/3 600 left this hour". */
|
||||
function ReaderLine() {
|
||||
const d = useData();
|
||||
const r = d.reader;
|
||||
const q = Object.values(r.quota).sort((a, b) => a.remaining - b.remaining)[0];
|
||||
const parts: string[] = [];
|
||||
if (r.total > 0 && (r.running || r.queued)) parts.push(t('reader.progress', { done: r.done, total: r.total }));
|
||||
else if (!d.reader.autoRead) parts.push(t('reader.off'));
|
||||
else if (d.ready && d.apps.length) parts.push(t('reader.upToDate'));
|
||||
if (Object.keys(r.paused).length) parts.push(t('reader.backoff'));
|
||||
if (q) parts.push(t('reader.appleQuota', { left: num(q.remaining), limit: num(q.limit) }));
|
||||
if (!parts.length) return null;
|
||||
return <div className="row reader-line" data-testid="reader-line" aria-live="polite"><Icon name="refresh" className={`sr-icon${r.running ? ' spin' : ''}`} /><span title={parts.join(' · ')}>{parts.join(' · ')}</span></div>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,12 +28,13 @@ export type StudioColor = (typeof STUDIO_COLORS)[number];
|
|||
/** A user-named group of store accounts (and so of apps). */
|
||||
export interface Studio { id: string; name: string; color: StudioColor; logo: string | null; created: string }
|
||||
export interface Settings {
|
||||
refresh: { autoMinutes: 0 | 15 | 30 | 60 | 180 | 720; onLaunch: boolean; parallel: number; icons: boolean };
|
||||
/** autoRead / everyMinutes: the background reader (store.tsx); autoMinutes / onLaunch: older builds' settings, kept for them. */
|
||||
refresh: { autoMinutes: 0 | 15 | 30 | 60 | 180 | 720; onLaunch: boolean; parallel: number; icons: boolean; autoRead: boolean; everyMinutes: 0 | 5 | 15 | 30 | 60 };
|
||||
cache: { keepDays: 0 | 7 | 30 | 90 | 365 };
|
||||
notify: { reviewState: boolean; lowReviews: boolean; crash: boolean };
|
||||
channel: 'stable' | 'beta';
|
||||
}
|
||||
export const DEFAULT_SETTINGS: Settings = { refresh: { autoMinutes: 0, onLaunch: false, parallel: 4, icons: true }, cache: { keepDays: 30 }, notify: { reviewState: true, lowReviews: true, crash: true }, channel: 'stable' };
|
||||
export const DEFAULT_SETTINGS: Settings = { refresh: { autoMinutes: 0, onLaunch: false, parallel: 4, icons: true, autoRead: true, everyMinutes: 15 }, cache: { keepDays: 30 }, notify: { reviewState: true, lowReviews: true, crash: true }, channel: 'stable' };
|
||||
export interface Workspace { studios: Studio[]; overrides: Overrides; settings: Settings }
|
||||
export interface AccountApps { account: string; entry: CacheEntry<AppsListResult> | null }
|
||||
export interface CacheInfo { rows: number; bytes: number; oldest: string | null; file_bytes: number }
|
||||
|
|
|
|||
|
|
@ -216,8 +216,8 @@ const HEALTH: Record<string, HealthSnapshot> = {
|
|||
anr: rows('Anr', series(28, 0.0024, 0.0004, 2), 0.0027),
|
||||
devices: [{ model: 'Galaxy A12', crash: 0.061, users: 1900 }, { model: 'Redmi 9A', crash: 0.042, users: 1400 }, { model: 'Moto G Power (2021)', crash: 0.019, users: 900 }, { model: 'Pixel 6a', crash: 0.008, users: 2100 }, { model: 'Galaxy S23', crash: 0.004, users: 2600 }],
|
||||
issues: [
|
||||
{ type: 'CRASH', cause: 'java.lang.IllegalStateException', location: 'com.example.prismpuddle.combo.ComboMeter.update', reports: 412, users: 268, lastVersion: '231' },
|
||||
{ type: 'ANR', cause: 'Input dispatching timed out', location: 'com.example.prismpuddle.MainActivity', reports: 133, users: 120, lastVersion: '231' },
|
||||
{ type: 'CRASH', cause: 'java.lang.IllegalStateException: ComboMeter.update called after the level ended (com.example.prismpuddle.combo.ComboMeter)', location: 'com.example.prismpuddle.combo.ComboMeter.update', reports: 412, users: 268, lastVersion: '231', firstVersion: '224', firstOs: '26', lastOs: '35', lastSeen: iso(95), id: 'a1b2c3', uri: 'https://play.google.com/console/developers/app/vitals/crashes/a1b2c3/details' },
|
||||
{ type: 'ANR', cause: 'Input dispatching timed out', location: 'com.example.prismpuddle.MainActivity', reports: 133, users: 120, lastVersion: '231', firstVersion: '229', lastSeen: iso(300), id: 'd4e5f6', uri: 'https://play.google.com/console/developers/app/vitals/crashes/d4e5f6/details' },
|
||||
{ type: 'CRASH', cause: 'java.lang.OutOfMemoryError', location: 'android.graphics.Bitmap.createBitmap', reports: 74, users: 61, lastVersion: '221' },
|
||||
],
|
||||
},
|
||||
|
|
@ -230,7 +230,7 @@ const HEALTH: Record<string, HealthSnapshot> = {
|
|||
{ category: 'Battery', metric: 'Foreground energy', unit: '%/h', version: '2.3.0', value: 9.4, goal: 'poor' },
|
||||
],
|
||||
signatures: [
|
||||
{ build: '48', type: 'HANGS', signature: 'ComboMeter.update(delta:) + 212', weight: 0.31 },
|
||||
{ build: '48', type: 'HANGS', signature: 'ComboMeter.update(delta:) + 212', weight: 0.31, id: 'sig-hang-1' },
|
||||
{ build: '48', type: 'DISK_WRITES', signature: 'SaveStore.flush() + 88', weight: 0.12 },
|
||||
{ build: '47', type: 'LAUNCHES', signature: 'AssetCache.warmUp() + 64', weight: 0.07 },
|
||||
],
|
||||
|
|
@ -411,7 +411,23 @@ const STORE_REVIEWS: Review[] = [
|
|||
R('play', 's-p2', 2, 'Too many ads', 'An ad after every round is too much.', 'en', 5),
|
||||
R('apple', 's-a3', 5, 'Harika', 'Her gün kullanıyorum, çok sade.', 'TUR', 7),
|
||||
];
|
||||
/** A sample report of a Health issue (`health --issue / --signature --json`). */
|
||||
const ISSUE_SAMPLE = (args: Record<string, unknown>) => ({ sample: args.signature ? {
|
||||
store: 'apple', version: '2.3.0 (48)', device: 'iPhone15,2', os: 'iOS 18.6', stack: ['PrismPuddle ComboMeter.update(delta:) + 212', 'PrismPuddle GameLoop.tick() + 88', 'QuartzCore CA::Display::DisplayLink::dispatch_items + 1012', 'UIKitCore -[UIApplication _run] + 888', 'PrismPuddle main + 64'],
|
||||
} : {
|
||||
store: 'play', type: 'CRASH', version: '231', device: 'Galaxy A12', os: 'API 31', time: iso(95),
|
||||
stack: ['java.lang.IllegalStateException: ComboMeter.update called after the level ended', '\tat com.example.prismpuddle.combo.ComboMeter.update(ComboMeter.kt:88)', '\tat com.example.prismpuddle.level.Level.tick(Level.kt:214)', '\tat com.example.prismpuddle.GameLoop.run(GameLoop.kt:51)', '\tat android.os.Handler.handleCallback(Handler.java:958)', '\tat android.os.Looper.loop(Looper.java:294)'],
|
||||
} });
|
||||
|
||||
/** One read as the sidecar would answer it (exported for the desktop boot check's auto-read case). */
|
||||
export function fixtureRead(method: string, id: string, args: Record<string, unknown> = {}): ToolResult {
|
||||
const r = read(method, id, args);
|
||||
// App Store Connect reads carry the key's hourly quota (X-Rate-Limit), so the status line has something to show
|
||||
return method.startsWith('apple.') && r.ok ? { ...r, quota: { store: 'apple', limit: 3600, remaining: 3412 } } : r;
|
||||
}
|
||||
|
||||
function read(method: string, id: string, args: Record<string, unknown> = {}): ToolResult {
|
||||
if (method === 'health.issue') return ok(method, ISSUE_SAMPLE(args));
|
||||
if (id.startsWith('store:')) {
|
||||
const play = /android:/.test(id);
|
||||
switch (method) {
|
||||
|
|
@ -475,7 +491,7 @@ const CONSOLE_FILL: Record<ConsoleForm, ConsoleFillResult> = {
|
|||
};
|
||||
|
||||
const CACHED_AGO: Record<string, number> = { health: 60 * 3, 'reviews.list': 45, 'apple.testflight_status': 60 * 5, 'catalog.diff': 60 * 26, validate: 60 * 2, 'prices.current': 60 * 4, 'apple.ppo_status': 60 * 6 };
|
||||
const NETWORK = new Set(['health', 'reviews.list', 'apple.testflight_status', 'catalog.diff', 'validate', 'prices.current', 'prices.diff', 'apple.ppo_status', 'apple.status', 'play.status', 'console.verify']);
|
||||
const NETWORK = new Set(['health', 'health.issue', 'reviews.list', 'apple.testflight_status', 'catalog.diff', 'validate', 'prices.current', 'prices.diff', 'apple.ppo_status', 'apple.status', 'play.status', 'console.verify']);
|
||||
|
||||
async function progressLines(job: string | null, lines: string[]) {
|
||||
for (const line of lines) { await sleep(260); if (job) emit('sidecar-progress', { params: { requestId: 1, line, stream: 'stderr' }, job }); }
|
||||
|
|
@ -549,7 +565,7 @@ async function answer(cmd: string, a: Record<string, unknown>): Promise<unknown>
|
|||
if (NETWORK.has(method)) await progressLines(job, [`[${method}] reading App Store Connect`, `[${method}] reading Google Play`, `[${method}] done`]);
|
||||
else await sleep(150);
|
||||
if (job && cancelled.has(job)) throw { code: -32800, message: 'Request cancelled' };
|
||||
return read(method, id, ((a.params as { args?: Record<string, unknown> } | null)?.args) ?? {});
|
||||
return fixtureRead(method, id, ((a.params as { args?: Record<string, unknown> } | null)?.args) ?? {});
|
||||
}
|
||||
case 'job_cancel': cancelled.add(a.job as string); return true;
|
||||
case 'write_with_confirm': {
|
||||
|
|
|
|||
|
|
@ -753,6 +753,39 @@ export const en = {
|
|||
'set.refreshSub': 'Reads use each account\'s API quota: they run when you ask, or on the schedule here',
|
||||
'set.autoRefresh': 'Read the stores automatically',
|
||||
'set.autoHint': 'While the app is open. Off by default.',
|
||||
'health.likelyCause': 'Likely cause:',
|
||||
'health.suggestedFix': 'Fix:',
|
||||
'health.copied': "Copied for AI: paste it into your agent",
|
||||
'health.copyFailed': "Could not copy to the clipboard",
|
||||
'health.issueType': "Type",
|
||||
'health.issueWhere': "Location",
|
||||
'health.issueShare': "Share of reports",
|
||||
'health.issueVersions': "versionCode",
|
||||
'health.issueBuild': "Build",
|
||||
'health.issueOs': "Android API",
|
||||
'health.issueLastSeen': "Last report",
|
||||
'health.sample': "Sample stack trace",
|
||||
'health.noSample': "No sample report in the last 28 days.",
|
||||
'health.copyForAi': "Copy for AI",
|
||||
'health.openConsole': "Open in Play Console",
|
||||
'health.issuesUnread': "Top issues not read: {error}",
|
||||
'set.autoRead': "Read everything automatically",
|
||||
'set.autoReadHint': "Status, releases, catalog, prices, listings, TestFlight, health, reviews and checks of every app, in the background, inside the stores' limits",
|
||||
'set.everyMinutes': "While the app is open, read again every",
|
||||
'set.onlyOnStart': "Only on start",
|
||||
'set.everyHint': "Only reads past their age limit run (status 5 min, reviews 15 min, catalog and prices 30 min, health and analytics 6 h). Paused while the window is hidden.",
|
||||
'action.refreshAll': "Refresh all",
|
||||
'action.refreshAllHint': "Read everything of every app again now (read-only, inside the stores' limits)",
|
||||
'read.ago': "read {when}",
|
||||
'read.refreshing': "Refreshing…",
|
||||
'read.couldNotRefresh': "couldn't refresh: {reason}",
|
||||
'read.refresh': "Refresh",
|
||||
'read.waiting': "Reading…",
|
||||
'reader.progress': "Reading {done} of {total}",
|
||||
'reader.upToDate': "Everything read",
|
||||
'reader.off': "Auto-read is off",
|
||||
'reader.appleQuota': "App Store limit {left}/{limit} left this hour",
|
||||
'reader.backoff': "The store asked to slow down: waiting",
|
||||
'set.off': 'Off',
|
||||
'set.min': '{n} min',
|
||||
'set.hour': '{n} h',
|
||||
|
|
|
|||
|
|
@ -755,6 +755,39 @@ export const tr: Record<keyof typeof en, string> = {
|
|||
'set.refreshSub': 'Okumalar her hesabın API kotasını kullanır: siz istediğinizde ya da buradaki zamanlamayla çalışır',
|
||||
'set.autoRefresh': 'Mağazaları otomatik oku',
|
||||
'set.autoHint': 'Uygulama açıkken. Varsayılan olarak kapalı.',
|
||||
'health.likelyCause': 'Olası neden:',
|
||||
'health.suggestedFix': 'Çözüm:',
|
||||
'health.copied': "Yapay zekâ için kopyalandı: ajanınıza yapıştırın",
|
||||
'health.copyFailed': "Panoya kopyalanamadı",
|
||||
'health.issueType': "Tür",
|
||||
'health.issueWhere': "Konum",
|
||||
'health.issueShare': "Raporlardaki payı",
|
||||
'health.issueVersions': "versionCode",
|
||||
'health.issueBuild': "Derleme",
|
||||
'health.issueOs': "Android API",
|
||||
'health.issueLastSeen': "Son rapor",
|
||||
'health.sample': "Örnek yığın izi",
|
||||
'health.noSample': "Son 28 günde örnek rapor yok.",
|
||||
'health.copyForAi': "Yapay zekâ için kopyala",
|
||||
'health.openConsole': "Play Console'da aç",
|
||||
'health.issuesUnread': "Öne çıkan hatalar okunamadı: {error}",
|
||||
'set.autoRead': "Her şeyi otomatik oku",
|
||||
'set.autoReadHint': "Her uygulamanın durumu, sürümleri, kataloğu, fiyatları, mağaza metinleri, TestFlight, sağlık, yorumlar ve kontrolleri arka planda, mağaza limitlerinin içinde okunur",
|
||||
'set.everyMinutes': "Uygulama açıkken yeniden okuma aralığı",
|
||||
'set.onlyOnStart': "Sadece açılışta",
|
||||
'set.everyHint': "Yalnızca süresi dolan okumalar çalışır (durum 5 dk, yorumlar 15 dk, katalog ve fiyatlar 30 dk, sağlık ve analiz 6 sa). Pencere gizliyken durur.",
|
||||
'action.refreshAll': "Hepsini yenile",
|
||||
'action.refreshAllHint': "Her uygulamanın her şeyini şimdi yeniden oku (salt okunur, mağaza limitlerinin içinde)",
|
||||
'read.ago': "{when} okundu",
|
||||
'read.refreshing': "Yenileniyor…",
|
||||
'read.couldNotRefresh': "yenilenemedi: {reason}",
|
||||
'read.refresh': "Yenile",
|
||||
'read.waiting': "Okunuyor…",
|
||||
'reader.progress': "{total} okumadan {done} tamam",
|
||||
'reader.upToDate': "Her şey okundu",
|
||||
'reader.off': "Otomatik okuma kapalı",
|
||||
'reader.appleQuota': "App Store limiti: bu saat {left}/{limit} kaldı",
|
||||
'reader.backoff': "Mağaza yavaşlamamızı istedi: bekleniyor",
|
||||
'set.off': 'Kapalı',
|
||||
'set.min': '{n} dk',
|
||||
'set.hour': '{n} sa',
|
||||
|
|
|
|||
77
apps/desktop/src/lib/freshness.ts
Normal file
77
apps/desktop/src/lib/freshness.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
// How long a saved read stays fresh, which store lanes a read uses, and the reads the background reader does for
|
||||
// every app. Pure (tests/scheduler.test.ts).
|
||||
import type { Lane } from './scheduler';
|
||||
|
||||
const MIN = 60_000;
|
||||
|
||||
/** Time to live of a saved read per method: status and submission state 5 min, reviews 15 min, catalog / prices /
|
||||
* listings 30 min, health and analytics 6 h (the stores update them daily). */
|
||||
export const TTL: Record<string, number> = {
|
||||
'portfolio.status': 5 * MIN,
|
||||
'apple.status': 5 * MIN,
|
||||
'play.status': 5 * MIN,
|
||||
'apple.testflight_status': 5 * MIN,
|
||||
'reviews.list': 15 * MIN,
|
||||
'catalog.diff': 30 * MIN,
|
||||
'prices.current': 30 * MIN,
|
||||
'project.listings': 30 * MIN,
|
||||
'apple.ppo_status': 30 * MIN,
|
||||
validate: 30 * MIN,
|
||||
scan: 30 * MIN,
|
||||
health: 6 * 60 * MIN,
|
||||
'health.issue': 6 * 60 * MIN,
|
||||
'analytics.compare': 6 * 60 * MIN,
|
||||
'aso.audit': 6 * 60 * MIN,
|
||||
};
|
||||
export const DEFAULT_TTL = 30 * MIN;
|
||||
|
||||
export const ttlOf = (method: string): number => TTL[method] ?? DEFAULT_TTL;
|
||||
|
||||
/** True when a read saved at `at` (ISO) is older than its TTL, or there is none. */
|
||||
export function isStale(method: string, at: string | null | undefined, now = Date.now()): boolean {
|
||||
if (!at) return true;
|
||||
const t = Date.parse(at);
|
||||
return !Number.isFinite(t) || now - t >= ttlOf(method);
|
||||
}
|
||||
|
||||
/** The store lanes a read takes a slot in. */
|
||||
export function lanesOf(method: string, o: { ios: boolean; android: boolean }): Lane[] {
|
||||
if (method.startsWith('apple.')) return ['apple'];
|
||||
if (method.startsWith('play.')) return ['play'];
|
||||
if (['project.listings', 'scan', 'analytics.compare', 'aso.audit', 'reviews.drafts', 'project.declarations', 'project.renders', 'project.icon'].includes(method)) return ['local'];
|
||||
const l: Lane[] = [];
|
||||
if (o.ios) l.push('apple');
|
||||
if (o.android) l.push('play');
|
||||
return l.length ? l : ['local'];
|
||||
}
|
||||
|
||||
export interface PlannedRead { method: string; args: Record<string, unknown> }
|
||||
|
||||
/** The reads the background reader does for one app (the ones its tabs show), by what the app has. */
|
||||
export function readsFor(o: { folder: boolean; ios: boolean; android: boolean }): PlannedRead[] {
|
||||
const r: PlannedRead[] = [];
|
||||
const add = (method: string, args: Record<string, unknown> = {}) => r.push({ method, args });
|
||||
if (o.ios) { add('apple.status'); add('apple.testflight_status'); }
|
||||
if (o.android) add('play.status');
|
||||
if (o.ios || o.android) { add('reviews.list', { days: 30 }); add('health'); }
|
||||
if (o.folder) {
|
||||
add('scan'); add('project.listings');
|
||||
if (o.ios || o.android) { add('catalog.diff'); add('prices.current'); add('validate'); }
|
||||
add('analytics.compare'); add('aso.audit');
|
||||
}
|
||||
if (o.ios) add('apple.ppo_status');
|
||||
return r;
|
||||
}
|
||||
|
||||
/** Key of one read in the scheduler and the read bus. */
|
||||
export const readKey = (target: string, method: string, args: Record<string, unknown> = {}): string => `${target}\u001f${method}\u001f${JSON.stringify(args)}`;
|
||||
|
||||
/** "429", "RESOURCE_EXHAUSTED", "rate limit": the store refused for its quota (not the read itself). */
|
||||
export const looksThrottled = (text: string | undefined): boolean => !!text && /\b429\b|RESOURCE_EXHAUSTED|rate.?limit|quota exceeded/i.test(text);
|
||||
|
||||
/** The one-line reason of a failed read, for "couldn't refresh: <reason>". */
|
||||
export function oneLine(text: string, max = 140): string {
|
||||
const l = text.trim().split('\n').map(s => s.trim()).filter(Boolean).at(-1) ?? '';
|
||||
const s = l.replace(/^\S+ \[[^\]]+\] /, '');
|
||||
return s.length > max ? `${s.slice(0, max - 1)}…` : s;
|
||||
}
|
||||
60
apps/desktop/src/lib/issues.ts
Normal file
60
apps/desktop/src/lib/issues.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
// One crash / ANR issue in detail (Health tab): the Play error issue or App Store diagnostic signature, its sample
|
||||
// stack (`health.issue`), and the compact "Copy for AI" markdown. Pure (tests/model.test.ts).
|
||||
import { type CrashHint, crashHint } from '../../../../cli/lib/crash-hints';
|
||||
import type { IssueSample, PlayIssue, AppleSignature } from './model';
|
||||
|
||||
/** Likely cause of a known signature, from the issue text and its sample stack. */
|
||||
export const hintFor = (v: IssueView, sample: IssueSample | null): CrashHint | null => crashHint(v.title, v.where, ...(sample?.stack.slice(0, 30) ?? []));
|
||||
|
||||
export interface IssueView {
|
||||
store: 'play' | 'apple';
|
||||
title: string;
|
||||
/** Exception type / diagnostic type. */
|
||||
kind: string;
|
||||
where?: string;
|
||||
reports?: number;
|
||||
users?: number;
|
||||
weight?: number;
|
||||
versions?: { first?: string; last?: string };
|
||||
os?: { first?: string; last?: string };
|
||||
lastSeen?: string;
|
||||
build?: string;
|
||||
url?: string;
|
||||
/** Args of the `health.issue` read for its sample stack; null when there is no id to ask for. */
|
||||
sampleArgs: Record<string, string> | null;
|
||||
}
|
||||
|
||||
export function playIssueView(i: PlayIssue): IssueView {
|
||||
return {
|
||||
store: 'play', title: i.cause || i.location || '?', kind: i.type === 'ANR' ? 'ANR' : i.type, where: i.location || undefined, reports: i.reports, users: i.users,
|
||||
versions: { first: i.firstVersion, last: i.lastVersion }, os: i.firstOs || i.lastOs ? { first: i.firstOs, last: i.lastOs } : undefined, lastSeen: i.lastSeen, url: i.uri,
|
||||
sampleArgs: i.id ? { issue: i.id } : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function appleSignatureView(s: AppleSignature): IssueView {
|
||||
return { store: 'apple', title: s.signature || s.type, kind: s.type, weight: s.weight, build: s.build, sampleArgs: s.id ? { signature: s.id } : null };
|
||||
}
|
||||
|
||||
/** The top frames worth pasting (the full sample stays in the window). */
|
||||
export const TOP_FRAMES = 15;
|
||||
|
||||
/** Compact markdown for an AI agent: title, type, where, counts, versions, top frames. */
|
||||
export function issueMarkdown(app: string, v: IssueView, sample: IssueSample | null): string {
|
||||
const out = [`## ${v.store === 'play' ? 'Google Play' : 'App Store'} ${v.kind}: ${v.title}`, '', `- App: ${app}`];
|
||||
if (v.where && v.where !== v.title) out.push(`- Location: ${v.where}`);
|
||||
if (v.reports !== undefined) out.push(`- Reports: ${v.reports}${v.users !== undefined ? `, users: ${v.users}` : ''}`);
|
||||
if (v.weight !== undefined) out.push(`- Share of reports: ${Math.round(v.weight * 1000) / 10} %`);
|
||||
if (v.versions?.first || v.versions?.last) out.push(`- versionCode: ${v.versions.first ?? '?'} to ${v.versions.last ?? '?'}`);
|
||||
if (v.build) out.push(`- Build: ${v.build}`);
|
||||
if (v.os) out.push(`- Android API: ${v.os.first ?? '?'} to ${v.os.last ?? '?'}`);
|
||||
if (v.lastSeen) out.push(`- Last report: ${v.lastSeen}`);
|
||||
const hint = hintFor(v, sample);
|
||||
if (hint) out.push(`- Likely cause: ${hint.cause}`, `- Suggested fix: ${hint.fix}`);
|
||||
if (sample) {
|
||||
const meta = [sample.version && `version ${sample.version}`, sample.device, sample.os].filter(Boolean).join(', ');
|
||||
out.push('', `Sample report${meta ? ` (${meta})` : ''}, top frames:`, '', '```', ...sample.stack.slice(0, TOP_FRAMES), ...(sample.stack.length > TOP_FRAMES ? [`... ${sample.stack.length - TOP_FRAMES} more frames`] : []), '```');
|
||||
}
|
||||
if (v.url) out.push('', `Console: ${v.url}`);
|
||||
return out.join('\n') + '\n';
|
||||
}
|
||||
|
|
@ -17,11 +17,17 @@ export interface PlayHealth {
|
|||
crash: MetricRow[];
|
||||
anr: MetricRow[];
|
||||
devices?: { model: string; crash?: number; anr?: number; users?: number }[];
|
||||
issues: { type: string; cause: string; location: string; reports: number; users: number; lastVersion?: string }[];
|
||||
issues: PlayIssue[];
|
||||
issuesError?: string;
|
||||
}
|
||||
/** One Play error issue (errorIssues:search; id / versions / API levels / last report when the API sent them). */
|
||||
export interface PlayIssue { type: string; cause: string; location: string; reports: number; users: number; lastVersion?: string; uri?: string; id?: string; firstVersion?: string; firstOs?: string; lastOs?: string; lastSeen?: string }
|
||||
export interface AppleSignature { build: string; type: string; signature: string; weight: number; id?: string }
|
||||
/** `health --issue / --signature --json`: one sample report's stack, top frame first. */
|
||||
export interface IssueSample { store: 'play' | 'apple'; time?: string; version?: string; os?: string; device?: string; type?: string; stack: string[] }
|
||||
export interface AppleHealth {
|
||||
metrics: { category: string; metric: string; unit: string; version: string; value: number; goal?: string }[];
|
||||
signatures: { build: string; type: string; signature: string; weight: number }[];
|
||||
signatures: AppleSignature[];
|
||||
betaCrashes: { build: string; count: number }[];
|
||||
}
|
||||
export type StoreReadError = import('../../../../cli/lib/health').StoreReadError;
|
||||
|
|
|
|||
|
|
@ -55,7 +55,8 @@ export function normalizeSettings(v: unknown): Settings {
|
|||
if (!isRec(v)) return d;
|
||||
const r = isRec(v.refresh) ? v.refresh : {}, c = isRec(v.cache) ? v.cache : {}, n = isRec(v.notify) ? v.notify : {};
|
||||
return {
|
||||
refresh: { autoMinutes: pick(r.autoMinutes, d.refresh.autoMinutes, isNum), onLaunch: pick(r.onLaunch, d.refresh.onLaunch, isBool), parallel: pick(r.parallel, d.refresh.parallel, isNum), icons: pick(r.icons, d.refresh.icons, isBool) },
|
||||
refresh: { autoMinutes: pick(r.autoMinutes, d.refresh.autoMinutes, isNum), onLaunch: pick(r.onLaunch, d.refresh.onLaunch, isBool), parallel: pick(r.parallel, d.refresh.parallel, isNum), icons: pick(r.icons, d.refresh.icons, isBool),
|
||||
autoRead: pick(r.autoRead, d.refresh.autoRead, isBool), everyMinutes: pick(r.everyMinutes, d.refresh.everyMinutes, (x: unknown) => [0, 5, 15, 30, 60].includes(x as number)) },
|
||||
cache: { keepDays: pick(c.keepDays, d.cache.keepDays, isNum) },
|
||||
notify: { reviewState: pick(n.reviewState, d.notify.reviewState, isBool), lowReviews: pick(n.lowReviews, d.notify.lowReviews, isBool), crash: pick(n.crash, d.notify.crash, isBool) },
|
||||
channel: v.channel === 'beta' ? 'beta' : 'stable',
|
||||
|
|
|
|||
243
apps/desktop/src/lib/scheduler.ts
Normal file
243
apps/desktop/src/lib/scheduler.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
// The background read scheduler: every store read of the window goes through one queue, so reading "everything for
|
||||
// every app" stays far inside the stores' limits. Pure logic with an injectable clock (tests/scheduler.test.ts).
|
||||
//
|
||||
// - Lanes: 'apple' (App Store Connect), 'play' (Google Play Developer + Reporting API), 'local' (offline project
|
||||
// reads). A task takes one slot in each of its lanes; each lane has its own concurrency limit (default 4).
|
||||
// - Credentials: starts on one key are spaced (default 250 ms apart). App Store Connect sends X-Rate-Limit
|
||||
// (user-hour-lim / user-hour-rem, about 3600 per key per hour): below 20 % left, the spacing spreads the rest over the
|
||||
// hour; at the reserve (default 100, kept for the owner's own commands) the key pauses for 10 minutes.
|
||||
// - 429 / RESOURCE_EXHAUSTED: the task waits and runs again with exponential backoff and jitter (and its key waits too),
|
||||
// at most `maxAttempts` times.
|
||||
// - Priority: 0 the visible app and tab, 1 portfolio status, 2 the rest; FIFO within a priority.
|
||||
// - Dedupe: one in-flight (or queued) read per key; enqueueing it again returns the same promise (and may raise its
|
||||
// priority).
|
||||
|
||||
export type Lane = 'apple' | 'play' | 'local';
|
||||
export type Priority = 0 | 1 | 2;
|
||||
|
||||
export interface Clock {
|
||||
now(): number;
|
||||
setTimeout(fn: () => void, ms: number): unknown;
|
||||
clearTimeout(h: unknown): void;
|
||||
random(): number;
|
||||
}
|
||||
|
||||
export const realClock: Clock = {
|
||||
now: () => Date.now(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms),
|
||||
clearTimeout: h => clearTimeout(h as ReturnType<typeof setTimeout>),
|
||||
random: () => Math.random(),
|
||||
};
|
||||
|
||||
export interface QuotaSeen { limit?: number; remaining?: number; throttled?: boolean }
|
||||
|
||||
/** What one finished run says about the store's limits. */
|
||||
export interface Outcome {
|
||||
/** 429 / RESOURCE_EXHAUSTED: run it again later. */
|
||||
throttled?: boolean;
|
||||
/** Quota per credential (App Store Connect X-Rate-Limit). */
|
||||
quota?: { credential: string; limit: number; remaining: number };
|
||||
}
|
||||
|
||||
export interface ReadTask<R> {
|
||||
key: string;
|
||||
lanes: Lane[];
|
||||
/** Keys of the store credentials it uses ("apple:<account>", "play:<account>"). */
|
||||
credentials: string[];
|
||||
priority: Priority;
|
||||
run: () => Promise<R>;
|
||||
}
|
||||
|
||||
export interface SchedulerOptions {
|
||||
limits?: Partial<Record<Lane, number>>;
|
||||
/** Minimum gap between two starts on one credential. */
|
||||
spacingMs?: number;
|
||||
/** Requests per hour kept free on an App Store Connect key for the owner's own commands. */
|
||||
reserve?: number;
|
||||
/** Below this share of the hourly limit left, starts are spread over the rest of the hour. */
|
||||
slowBelow?: number;
|
||||
/** How long a key pauses once it is at the reserve. */
|
||||
pauseMs?: number;
|
||||
backoffBaseMs?: number;
|
||||
backoffMaxMs?: number;
|
||||
maxAttempts?: number;
|
||||
clock?: Clock;
|
||||
/** Reads a result (or the error) for throttling and quota. */
|
||||
classify?: (result: unknown, error: unknown, task: ReadTask<unknown>) => Outcome;
|
||||
}
|
||||
|
||||
export interface SchedulerStats {
|
||||
queued: number;
|
||||
running: number;
|
||||
/** Finished in this round (since the queue was last empty). */
|
||||
done: number;
|
||||
/** done + running + queued of this round. */
|
||||
total: number;
|
||||
failed: number;
|
||||
/** The lowest App Store Connect quota seen per credential this hour. */
|
||||
quota: Record<string, { limit: number; remaining: number; at: number }>;
|
||||
/** Credentials waiting on a backoff or quota pause, with the time they may start again. */
|
||||
paused: Record<string, number>;
|
||||
}
|
||||
|
||||
interface Item<R> {
|
||||
task: ReadTask<R>;
|
||||
seq: number;
|
||||
attempt: number;
|
||||
notBefore: number;
|
||||
resolve: (r: R) => void;
|
||||
reject: (e: unknown) => void;
|
||||
promise: Promise<R>;
|
||||
}
|
||||
|
||||
const HOUR = 3_600_000;
|
||||
|
||||
export class ReadScheduler {
|
||||
private o: Required<Omit<SchedulerOptions, 'limits' | 'clock' | 'classify'>> & { limits: Record<Lane, number> };
|
||||
private clock: Clock;
|
||||
private classify: (result: unknown, error: unknown, task: ReadTask<unknown>) => Outcome;
|
||||
private queue: Item<unknown>[] = [];
|
||||
private running = new Map<string, Item<unknown>>();
|
||||
private busy: Record<Lane, number> = { apple: 0, play: 0, local: 0 };
|
||||
private nextAt = new Map<string, number>();
|
||||
private quotas = new Map<string, { limit: number; remaining: number; at: number }>();
|
||||
private seq = 0;
|
||||
private done = 0;
|
||||
private failed = 0;
|
||||
private timer: unknown = null;
|
||||
private timerAt = 0;
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
constructor(o: SchedulerOptions = {}) {
|
||||
this.o = {
|
||||
limits: { apple: 4, play: 4, local: 2, ...o.limits },
|
||||
spacingMs: o.spacingMs ?? 250,
|
||||
reserve: o.reserve ?? 100,
|
||||
slowBelow: o.slowBelow ?? 0.2,
|
||||
pauseMs: o.pauseMs ?? 10 * 60_000,
|
||||
backoffBaseMs: o.backoffBaseMs ?? 5_000,
|
||||
backoffMaxMs: o.backoffMaxMs ?? 5 * 60_000,
|
||||
maxAttempts: o.maxAttempts ?? 4,
|
||||
};
|
||||
this.clock = o.clock ?? realClock;
|
||||
this.classify = o.classify ?? (() => ({}));
|
||||
}
|
||||
|
||||
setLimits(limits: Partial<Record<Lane, number>>): void { this.o.limits = { ...this.o.limits, ...limits }; this.pump(); }
|
||||
|
||||
subscribe(fn: () => void): () => void { this.listeners.add(fn); return () => { this.listeners.delete(fn); }; }
|
||||
|
||||
has(key: string): boolean { return this.running.has(key) || this.queue.some(i => i.task.key === key); }
|
||||
|
||||
enqueue<R>(task: ReadTask<R>): Promise<R> {
|
||||
const run = this.running.get(task.key);
|
||||
if (run) return run.promise as Promise<R>;
|
||||
const queued = this.queue.find(i => i.task.key === task.key);
|
||||
if (queued) {
|
||||
if (task.priority < queued.task.priority) { queued.task = { ...queued.task, priority: task.priority }; this.pump(); }
|
||||
return queued.promise as Promise<R>;
|
||||
}
|
||||
let resolve!: (r: R) => void, reject!: (e: unknown) => void;
|
||||
const promise = new Promise<R>((a, b) => { resolve = a; reject = b; });
|
||||
this.queue.push({ task, seq: ++this.seq, attempt: 0, notBefore: 0, resolve, reject, promise } as unknown as Item<unknown>);
|
||||
this.pump();
|
||||
this.emit();
|
||||
return promise;
|
||||
}
|
||||
|
||||
/** Drops queued tasks (not the running ones); their promises reject with "cancelled". */
|
||||
clear(filter: (key: string) => boolean = () => true): void {
|
||||
const drop = this.queue.filter(i => filter(i.task.key));
|
||||
this.queue = this.queue.filter(i => !filter(i.task.key));
|
||||
for (const i of drop) i.reject(new Error('cancelled'));
|
||||
this.pump();
|
||||
this.emit();
|
||||
}
|
||||
|
||||
stats(): SchedulerStats {
|
||||
const now = this.clock.now();
|
||||
const paused: Record<string, number> = {};
|
||||
for (const [c, at] of this.nextAt) if (at - now > this.o.spacingMs) paused[c] = at;
|
||||
const quota: SchedulerStats['quota'] = {};
|
||||
for (const [c, q] of this.quotas) if (now - q.at < HOUR) quota[c] = q;
|
||||
return { queued: this.queue.length, running: this.running.size, done: this.done, total: this.done + this.running.size + this.queue.length, failed: this.failed, quota, paused };
|
||||
}
|
||||
|
||||
/** Earliest time a credential may start again. */
|
||||
private gap(cred: string, now: number): number {
|
||||
const q = this.quotas.get(cred);
|
||||
if (!q || now - q.at >= HOUR) return this.o.spacingMs;
|
||||
if (q.remaining <= this.o.reserve) return this.o.pauseMs;
|
||||
if (q.remaining < q.limit * this.o.slowBelow) return Math.max(this.o.spacingMs, Math.ceil(HOUR / Math.max(1, q.remaining - this.o.reserve)));
|
||||
return this.o.spacingMs;
|
||||
}
|
||||
|
||||
private ready(i: Item<unknown>, now: number): number {
|
||||
// 0 = can start now; otherwise the time it can (Infinity: waits on a lane slot)
|
||||
let at = i.notBefore;
|
||||
for (const c of i.task.credentials) at = Math.max(at, this.nextAt.get(c) ?? 0);
|
||||
if (i.task.lanes.some(l => this.busy[l] >= Math.max(1, this.o.limits[l]))) return Infinity;
|
||||
return at <= now ? 0 : at;
|
||||
}
|
||||
|
||||
private pump(): void {
|
||||
const now = this.clock.now();
|
||||
let wake = Infinity;
|
||||
for (;;) {
|
||||
const order = [...this.queue].sort((a, b) => a.task.priority - b.task.priority || a.seq - b.seq);
|
||||
let started = false;
|
||||
for (const i of order) {
|
||||
const r = this.ready(i, now);
|
||||
if (r === 0) { this.start(i, now); started = true; break; }
|
||||
if (r !== Infinity) wake = Math.min(wake, r);
|
||||
}
|
||||
if (!started) break;
|
||||
}
|
||||
if (this.timer !== null && (wake === Infinity || wake !== this.timerAt)) { this.clock.clearTimeout(this.timer); this.timer = null; }
|
||||
if (wake !== Infinity && this.timer === null) {
|
||||
this.timerAt = wake;
|
||||
this.timer = this.clock.setTimeout(() => { this.timer = null; this.pump(); }, Math.max(0, wake - now));
|
||||
}
|
||||
}
|
||||
|
||||
private start(i: Item<unknown>, now: number): void {
|
||||
this.queue = this.queue.filter(x => x !== i);
|
||||
this.running.set(i.task.key, i);
|
||||
for (const l of i.task.lanes) this.busy[l]++;
|
||||
for (const c of i.task.credentials) this.nextAt.set(c, now + this.gap(c, now));
|
||||
i.attempt++;
|
||||
this.emit();
|
||||
i.task.run().then(r => this.finish(i, r, undefined, true), e => this.finish(i, undefined, e, false));
|
||||
}
|
||||
|
||||
private finish(i: Item<unknown>, result: unknown, error: unknown, ok: boolean): void {
|
||||
const now = this.clock.now();
|
||||
this.running.delete(i.task.key);
|
||||
for (const l of i.task.lanes) this.busy[l]--;
|
||||
let out: Outcome = {};
|
||||
try { out = this.classify(result, error, i.task); } catch { /* a classifier bug never stalls the queue */ }
|
||||
if (out.quota) {
|
||||
const prev = this.quotas.get(out.quota.credential);
|
||||
// the hour is the key's: keep the lowest count seen in it
|
||||
const q = prev && now - prev.at < HOUR && prev.remaining < out.quota.remaining ? prev : { limit: out.quota.limit, remaining: out.quota.remaining, at: now };
|
||||
this.quotas.set(out.quota.credential, q);
|
||||
this.nextAt.set(out.quota.credential, Math.max(this.nextAt.get(out.quota.credential) ?? 0, now + this.gap(out.quota.credential, now)));
|
||||
}
|
||||
if (out.throttled && i.attempt < this.o.maxAttempts) {
|
||||
const exp = Math.min(this.o.backoffMaxMs, this.o.backoffBaseMs * 2 ** (i.attempt - 1));
|
||||
const delay = Math.round(exp / 2 + this.clock.random() * exp / 2); // "equal jitter"
|
||||
i.notBefore = now + delay;
|
||||
for (const c of i.task.credentials) this.nextAt.set(c, Math.max(this.nextAt.get(c) ?? 0, now + delay));
|
||||
this.queue.push(i);
|
||||
} else {
|
||||
this.done++;
|
||||
if (!ok || out.throttled) this.failed++;
|
||||
if (ok) i.resolve(result); else i.reject(error);
|
||||
}
|
||||
if (!this.queue.length && !this.running.size) { this.done = 0; this.failed = 0; }
|
||||
this.pump();
|
||||
this.emit();
|
||||
}
|
||||
|
||||
private emit(): void { for (const f of this.listeners) { try { f(); } catch { /* listeners never break the queue */ } } }
|
||||
}
|
||||
|
|
@ -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(', ') })}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import { crashCrossed, newLowReviews, releaseChanges } from './lib/notify';
|
|||
import { setCrashContext } from './lib/crash';
|
||||
import { normalizeAccounts, normalizeProjects, normalizeWorkspace } from './lib/normalize';
|
||||
import { keepLines, progressOf } from './lib/progress';
|
||||
import { isStale, lanesOf, looksThrottled, oneLine, readKey, readsFor } from './lib/freshness';
|
||||
import { type Outcome, type Priority, ReadScheduler, type ReadTask, type SchedulerStats } from './lib/scheduler';
|
||||
import { fixHint } from './lib/views';
|
||||
import { failureText, usable } from './lib/results';
|
||||
|
||||
|
|
@ -32,6 +34,31 @@ export interface Toast {
|
|||
|
||||
export interface AccountHealth { check?: AccountCheck; error?: string; at: string }
|
||||
|
||||
/** What the read bus tells the cards showing one read (key: lib/freshness.ts readKey). */
|
||||
export type ReadEvent = { kind: 'start' } | { kind: 'done'; result: ToolResult } | { kind: 'error'; error: string } | { kind: 'invalidate' };
|
||||
|
||||
/** The background reader's state for the status line. */
|
||||
export interface ReaderState extends SchedulerStats { autoRead: boolean }
|
||||
|
||||
/** Throttling and App Store Connect quota of one finished read (the ToolResult's `quota`, else its text). */
|
||||
export function classifyRead(result: unknown, error: unknown, task: ReadTask<unknown>): Outcome {
|
||||
const r = result as ToolResult | undefined;
|
||||
const q = r?.quota;
|
||||
const out: Outcome = {};
|
||||
if (q?.store === 'apple' && typeof q.limit === 'number' && typeof q.remaining === 'number')
|
||||
out.quota = { credential: task.credentials.find(c => c.startsWith('apple:')) ?? 'apple:default', limit: q.limit, remaining: q.remaining };
|
||||
if (r && !r.ok && (q?.throttled || looksThrottled(r.output))) out.throttled = true;
|
||||
if (error !== undefined && looksThrottled(errorText(error))) out.throttled = true;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Which stores an app has (for the reads it gets and the lanes they take). */
|
||||
export function platformsOf(a: UnifiedApp | undefined, status?: AppStatus): { ios: boolean; android: boolean } {
|
||||
if (!a) return { ios: false, android: false };
|
||||
const pl = status?.app?.platforms as { ios?: unknown; android?: unknown } | undefined;
|
||||
return { ios: !!a.ios || !!pl?.ios || (!!a.project?.appleAccount && !pl), android: !!a.android || !!pl?.android || (!!a.project?.playAccount && !pl) };
|
||||
}
|
||||
|
||||
export interface Data {
|
||||
ready: boolean;
|
||||
info: AppInfo | null;
|
||||
|
|
@ -56,7 +83,20 @@ export interface Data {
|
|||
portfolioError: string | null;
|
||||
importBusy: boolean;
|
||||
icons: Record<string, string | null>;
|
||||
refreshPortfolio: (live?: boolean) => Promise<void>;
|
||||
/** Background reader: progress and the App Store Connect quota seen this hour. */
|
||||
reader: ReaderState;
|
||||
/** Runs one read through the scheduler (deduped, rate limited); the result also goes to the read bus. */
|
||||
schedule: (target: string, method: string, args: Record<string, unknown>, priority: Priority) => Promise<ToolResult>;
|
||||
/** Listens to one read's bus key. */
|
||||
onRead: (key: string, fn: (e: ReadEvent) => void) => () => void;
|
||||
isReading: (key: string) => boolean;
|
||||
/** Reads everything of every app that is past its TTL (force: everything). */
|
||||
readAll: (force?: boolean) => Promise<void>;
|
||||
/** After a write: the app's reads are read again. */
|
||||
invalidate: (target: string) => void;
|
||||
/** The app on screen (its reads go first). */
|
||||
setFocus: (key: string | null) => void;
|
||||
refreshPortfolio: (live?: boolean, quiet?: boolean) => Promise<void>;
|
||||
/** Reads the apps of one account (or all) from the stores. */
|
||||
importApps: (account?: string) => Promise<void>;
|
||||
checkAccount: (id: string) => Promise<void>;
|
||||
|
|
@ -155,10 +195,10 @@ export function DataProvider({ children }: { children: ReactNode }) {
|
|||
const appsRef = useRef(apps);
|
||||
appsRef.current = apps;
|
||||
|
||||
const refreshPortfolio = useCallback(async (liveRead = false) => {
|
||||
const refreshPortfolio = useCallback(async (liveRead = false, quiet = false) => {
|
||||
const job = newJob(liveRead ? 'portfolio-live' : 'portfolio');
|
||||
setBusy(liveRead ? 'live' : 'offline'); setPortfolioError(null);
|
||||
const tid = liveRead ? toast({ id: job, kind: 'job', title: t('toast.readingStores'), detail: t('toast.readingStoresDetail'), progress: null, job }) : null;
|
||||
const tid = liveRead && !quiet ? toast({ id: job, kind: 'job', title: t('toast.readingStores'), detail: t('toast.readingStoresDetail'), progress: null, job }) : null;
|
||||
try {
|
||||
const stores = liveRead ? appsRef.current.filter(a => !a.project && !a.ignored).map(a => a.target) : [];
|
||||
const r = await api.portfolio(liveRead, job, stores);
|
||||
|
|
@ -179,6 +219,78 @@ export function DataProvider({ children }: { children: ReactNode }) {
|
|||
} finally { setBusy(null); }
|
||||
}, [toast, updateToast]);
|
||||
|
||||
// ---- background reader: one scheduler for every store read of the window (lib/scheduler.ts) ----
|
||||
const schedRef = useRef<ReadScheduler | null>(null);
|
||||
if (!schedRef.current) schedRef.current = new ReadScheduler({ classify: classifyRead, limits: { apple: settings.refresh.parallel, play: settings.refresh.parallel } });
|
||||
const sched = schedRef.current;
|
||||
const [reader, setReader] = useState<SchedulerStats>(() => sched.stats());
|
||||
useEffect(() => {
|
||||
let tm: ReturnType<typeof setTimeout> | null = null;
|
||||
const un = sched.subscribe(() => { if (!tm) tm = setTimeout(() => { tm = null; setReader(sched.stats()); }, 150); });
|
||||
return () => { un(); if (tm) clearTimeout(tm); };
|
||||
}, [sched]);
|
||||
useEffect(() => { sched.setLimits({ apple: settings.refresh.parallel, play: settings.refresh.parallel }); }, [sched, settings.refresh.parallel]);
|
||||
const bus = useRef(new Map<string, Set<(e: ReadEvent) => void>>());
|
||||
const emitRead = useCallback((key: string, e: ReadEvent) => { for (const f of bus.current.get(key) ?? []) { try { f(e); } catch { /* a card's listener never breaks the bus */ } } }, []);
|
||||
const onRead = useCallback((key: string, fn: (e: ReadEvent) => void) => {
|
||||
const set = bus.current.get(key) ?? new Set();
|
||||
set.add(fn); bus.current.set(key, set);
|
||||
return () => { set.delete(fn); if (!set.size) bus.current.delete(key); };
|
||||
}, []);
|
||||
const isReading = useCallback((key: string) => sched.has(key), [sched]);
|
||||
const focusRef = useRef<string | null>(null);
|
||||
const setFocus = useCallback((key: string | null) => { focusRef.current = key; }, []);
|
||||
|
||||
const credentialsOf = (a: UnifiedApp | undefined, lanes: string[]): string[] => {
|
||||
const c: string[] = [];
|
||||
if (lanes.includes('apple')) c.push(`apple:${a?.project?.appleAccount ?? a?.ios?.account ?? 'default'}`);
|
||||
if (lanes.includes('play')) c.push(`play:${a?.project?.playAccount ?? a?.android?.account ?? 'default'}`);
|
||||
return c;
|
||||
};
|
||||
const statusRef = useRef<(a: UnifiedApp) => AppStatus | undefined>(() => undefined);
|
||||
|
||||
const schedule = useCallback((target: string, method: string, args: Record<string, unknown>, priority: Priority): Promise<ToolResult> => {
|
||||
const key = readKey(target, method, args);
|
||||
const app = appsRef.current.find(a => a.target === target);
|
||||
const pf = platformsOf(app, app ? statusRef.current(app) : undefined);
|
||||
const lanes = lanesOf(method, pf);
|
||||
const p = sched.enqueue<ToolResult>({ key, lanes, credentials: credentialsOf(app, lanes), priority, run: () => api.call<ToolResult>(method, target, { args }, newJob(method)) });
|
||||
emitRead(key, { kind: 'start' });
|
||||
p.then(result => emitRead(key, { kind: 'done', result }), e => emitRead(key, { kind: 'error', error: errorText(e) }));
|
||||
return p;
|
||||
}, [sched, emitRead]);
|
||||
|
||||
const readAll = useCallback(async (force = false) => {
|
||||
if (!force && !settingsRef.current.refresh.autoRead) return;
|
||||
if (force || isStale('portfolio.status', liveRef.current?.at)) {
|
||||
sched.enqueue({ key: '*\u001fportfolio.status', lanes: ['apple', 'play'], credentials: [], priority: 1, run: () => refreshPortfolio(true, true) }).catch(() => {});
|
||||
}
|
||||
const list = [...appsRef.current].filter(a => !a.ignored && !a.project?.missing)
|
||||
.sort((a, b) => Number(b.key === focusRef.current) - Number(a.key === focusRef.current));
|
||||
for (const a of list) {
|
||||
const pf = platformsOf(a, statusRef.current(a));
|
||||
for (const r of readsFor({ folder: !!a.project, ...pf })) {
|
||||
const key = readKey(a.target, r.method, r.args);
|
||||
if (sched.has(key)) continue;
|
||||
if (!force) {
|
||||
const c = await api.cached<ToolResult>(r.method, a.target, r.args).catch(() => null);
|
||||
if (c && !isStale(r.method, c.at)) continue;
|
||||
}
|
||||
schedule(a.target, r.method, r.args, a.key === focusRef.current ? 0 : 2).catch(() => {});
|
||||
}
|
||||
}
|
||||
}, [sched, schedule, refreshPortfolio]);
|
||||
|
||||
const invalidate = useCallback((target: string) => {
|
||||
const pre = `${target}\u001f`;
|
||||
for (const k of [...bus.current.keys()]) if (k.startsWith(pre)) emitRead(k, { kind: 'invalidate' });
|
||||
const a = appsRef.current.find(x => x.target === target);
|
||||
if (a) for (const r of readsFor({ folder: !!a.project, ...platformsOf(a, statusRef.current(a)) })) {
|
||||
const key = readKey(target, r.method, r.args);
|
||||
if (!bus.current.has(key)) schedule(target, r.method, r.args, 1).catch(() => {});
|
||||
}
|
||||
}, [emitRead, schedule]);
|
||||
|
||||
const importApps = useCallback(async (account?: string) => {
|
||||
const ids = (account ? [account] : accounts.map(a => a.id)).filter(id => account || !overrides.ignored.includes(`account:${id}`));
|
||||
if (!ids.length) return;
|
||||
|
|
@ -236,21 +348,27 @@ export function DataProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
useEffect(() => { if (projects.length) refreshPortfolio(false); }, [projects.length, refreshPortfolio]);
|
||||
|
||||
// Settings > Refresh: on launch, then every N minutes (store reads use the accounts' API quota: off by default)
|
||||
// Settings > Reading: everything past its TTL is read in the background on start, when the window shows again after
|
||||
// a while, and every N minutes while it is visible (the scheduler keeps it inside the stores' limits).
|
||||
const launched = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!ready || launched.current) return;
|
||||
if (!ready || launched.current || !apps.length) return;
|
||||
launched.current = true;
|
||||
if (settings.refresh.onLaunch && (projects.length || accounts.length)) importApps().finally(() => refreshPortfolio(true));
|
||||
}, [ready, settings.refresh.onLaunch, projects.length, accounts.length, importApps, refreshPortfolio]);
|
||||
const busyRef = useRef(portfolioBusy);
|
||||
busyRef.current = portfolioBusy;
|
||||
if (settings.refresh.autoRead) readAll(false).catch(() => {});
|
||||
}, [ready, apps.length, settings.refresh.autoRead, readAll]);
|
||||
useEffect(() => {
|
||||
const m = settings.refresh.autoMinutes;
|
||||
if (!m) return;
|
||||
const tm = setInterval(() => { if (!busyRef.current) refreshPortfolio(true); }, m * 60_000);
|
||||
return () => clearInterval(tm);
|
||||
}, [settings.refresh.autoMinutes, refreshPortfolio]);
|
||||
if (!settings.refresh.autoRead) return;
|
||||
let hiddenAt = 0;
|
||||
const vis = () => {
|
||||
if (document.hidden) { hiddenAt = Date.now(); return; }
|
||||
if (hiddenAt && Date.now() - hiddenAt > 60_000) readAll(false).catch(() => {});
|
||||
hiddenAt = 0;
|
||||
};
|
||||
document.addEventListener('visibilitychange', vis);
|
||||
const m = settings.refresh.everyMinutes;
|
||||
const tm = m ? setInterval(() => { if (!document.hidden) readAll(false).catch(() => {}); }, m * 60_000) : null;
|
||||
return () => { document.removeEventListener('visibilitychange', vis); if (tm) clearInterval(tm); };
|
||||
}, [settings.refresh.autoRead, settings.refresh.everyMinutes, readAll]);
|
||||
|
||||
// icons: cached first, then read (offline, cheap)
|
||||
useEffect(() => {
|
||||
|
|
@ -296,12 +414,15 @@ export function DataProvider({ children }: { children: ReactNode }) {
|
|||
const liveFor = useCallback((a: UnifiedApp) => (a.project ? liveOf(a.project) : portfolio?.apps.find(x => x.project === a.target && x.release.source === 'live') ?? live?.value.apps.find(x => x.project === a.target)), [liveOf, portfolio, live]);
|
||||
const iconFor = useCallback((a: UnifiedApp) => (a.project ? icons[a.project.id] ?? a.storeIcon : a.storeIcon), [icons]);
|
||||
const appByKey = useCallback((key: string) => apps.find(a => a.key === key), [apps]);
|
||||
statusRef.current = statusFor;
|
||||
const readerState = useMemo<ReaderState>(() => ({ ...reader, autoRead: settings.refresh.autoRead }), [reader, settings.refresh.autoRead]);
|
||||
|
||||
const value = useMemo<Data>(() => ({
|
||||
ready, info, projects, accounts, studios, overrides, settings, storeApps, accountHealth, apps, sidecar, sidecarError, portfolio, live, portfolioBusy, portfolioError,
|
||||
importBusy, icons, refreshPortfolio, importApps, checkAccount, reload, updateOverrides, updateSettings, statusOf, liveOf, statusFor, liveFor, iconFor, appByKey,
|
||||
importBusy, icons, reader: readerState, schedule, onRead, isReading, readAll, invalidate, setFocus,
|
||||
refreshPortfolio, importApps, checkAccount, reload, updateOverrides, updateSettings, statusOf, liveOf, statusFor, liveFor, iconFor, appByKey,
|
||||
toasts, toast, updateToast, dismiss, cancelJob,
|
||||
}), [ready, info, projects, accounts, studios, overrides, settings, storeApps, accountHealth, apps, sidecar, sidecarError, portfolio, live, portfolioBusy, portfolioError,
|
||||
}), [readerState, schedule, onRead, isReading, readAll, invalidate, setFocus, ready, info, projects, accounts, studios, overrides, settings, storeApps, accountHealth, apps, sidecar, sidecarError, portfolio, live, portfolioBusy, portfolioError,
|
||||
importBusy, icons, refreshPortfolio, importApps, checkAccount, reload, updateOverrides, updateSettings, statusOf, liveOf, statusFor, liveFor, iconFor, appByKey,
|
||||
toasts, toast, updateToast, dismiss, cancelJob]);
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
|
|
@ -309,6 +430,8 @@ export function DataProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
export interface ReadState<T> {
|
||||
data: T | null;
|
||||
/** Past its TTL (lib/freshness.ts): a refresh is due or running. */
|
||||
stale?: boolean;
|
||||
/** When `data` was read (from the store or the cache). */
|
||||
at: string | null;
|
||||
fromCache: boolean;
|
||||
|
|
@ -339,7 +462,7 @@ export function useWrite(projectId: string | null) {
|
|||
const o = await api.writeWithConfirm(w.method, projectId, w.args, job);
|
||||
if (o.stage === 'dry-run') d.updateToast(tid, { kind: 'bad', title: t('write.dryFailed'), detail: withHint(failureText(o.dryRun)), progress: undefined, job: undefined });
|
||||
else if (o.stage === 'dialog') d.updateToast(tid, { kind: 'info', title: t('write.notApplied'), detail: t('write.notAppliedDetail'), progress: undefined, job: undefined });
|
||||
else if (o.result?.ok) { d.updateToast(tid, { kind: 'ok', title: w.done, detail: lastLine(o.result.output) || undefined, progress: undefined, job: undefined }); w.after?.(); }
|
||||
else if (o.result?.ok) { d.updateToast(tid, { kind: 'ok', title: w.done, detail: lastLine(o.result.output) || undefined, progress: undefined, job: undefined }); w.after?.(); d.invalidate(projectId); }
|
||||
// exit 3: the command stopped for an owner decision it did not get (a price change of a live product: the
|
||||
// Catalog tab's owner action applies it); everything else it could do was done
|
||||
else if (o.result?.exitCode === 3) { d.updateToast(tid, { kind: 'info', title: t('write.declined'), detail: lastLine(o.result.output) || undefined, progress: undefined, job: undefined }); w.after?.(); }
|
||||
|
|
@ -369,52 +492,63 @@ function notifyAfterRead(method: string, prev: unknown, next: unknown, settings:
|
|||
|
||||
/**
|
||||
* One read method of one app (a project id, or a store-only app's store target). The cached value shows at once with
|
||||
* its time; offline reads (`network: false`) then refresh by themselves, store reads only on demand (they use the
|
||||
* account's API quota). A failed read keeps the last good value and says so: never an empty "all fine".
|
||||
* its time; then, past its TTL (lib/freshness.ts), it is read again through the background scheduler (offline reads
|
||||
* always; store reads while Settings > Reading > auto-read is on). A read the background reader runs for this key
|
||||
* lands here too (the read bus). A failed read keeps the last good value and says so: never an empty "all fine".
|
||||
*/
|
||||
export function useRead<T>(target: string, method: string, o: { network: boolean; args?: Record<string, unknown>; title?: string; appName?: string; skip?: boolean }): ReadState<T> {
|
||||
const d = useData();
|
||||
const argsKey = JSON.stringify(o.args ?? {});
|
||||
const key = readKey(target, method, JSON.parse(argsKey) as Record<string, unknown>);
|
||||
const [s, set] = useState<Omit<ReadState<T>, 'refresh'>>({ data: null, at: null, fromCache: false, loading: true, error: null, command: null });
|
||||
const seq = useRef(0);
|
||||
const dataRef = useRef<T | null>(null);
|
||||
dataRef.current = s.data;
|
||||
const dRef = useRef(d);
|
||||
dRef.current = d;
|
||||
const skipRef = useRef(o.skip);
|
||||
skipRef.current = o.skip;
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const my = ++seq.current;
|
||||
const job = newJob(method);
|
||||
set(x => ({ ...x, loading: true, error: null }));
|
||||
const tid = o.network ? d.toast({ id: job, kind: 'job', title: o.title ?? t('toast.reading'), detail: t('toast.readingDetail'), progress: null, job }) : null;
|
||||
try {
|
||||
const r = await api.call<ToolResult>(method, target, { args: JSON.parse(argsKey) as Record<string, unknown> }, job);
|
||||
if (my !== seq.current) return;
|
||||
if (usable(method, r)) {
|
||||
notifyAfterRead(method, dataRef.current, r.data, d.settings, o.appName);
|
||||
set({ data: r.data as T, at: new Date().toISOString(), fromCache: false, loading: false, error: null, command: r.command });
|
||||
if (tid) d.updateToast(tid, { kind: 'ok', title: t('toast.readOk', { what: o.title ?? method }), detail: undefined, progress: undefined, job: undefined });
|
||||
} else {
|
||||
const msg = failureText(r);
|
||||
set(x => ({ ...x, loading: false, error: msg, command: r.command }));
|
||||
if (tid) d.updateToast(tid, { kind: 'bad', title: t('toast.readFailedWhat', { what: o.title ?? method }), detail: withHint(msg), progress: undefined, job: undefined });
|
||||
if (skipRef.current) return;
|
||||
set(x => ({ ...x, loading: true }));
|
||||
await dRef.current.schedule(target, method, JSON.parse(argsKey) as Record<string, unknown>, 0).catch(() => {});
|
||||
}, [target, method, argsKey]);
|
||||
|
||||
// results of this key, whoever asked for them (this card, the background reader, a write's invalidation)
|
||||
useEffect(() => {
|
||||
if (o.skip) return;
|
||||
return d.onRead(key, e => {
|
||||
if (e.kind === 'start') set(x => ({ ...x, loading: true }));
|
||||
else if (e.kind === 'invalidate') { set(x => ({ ...x, stale: true })); refresh(); }
|
||||
else if (e.kind === 'error') { if (/cancel/i.test(e.error)) set(x => ({ ...x, loading: false })); else set(x => ({ ...x, loading: false, error: oneLine(e.error) })); }
|
||||
else {
|
||||
const r = e.result;
|
||||
if (usable(method, r)) {
|
||||
notifyAfterRead(method, dataRef.current, r.data, dRef.current.settings, o.appName);
|
||||
set({ data: r.data as T, at: new Date().toISOString(), fromCache: false, loading: false, stale: false, error: null, command: r.command });
|
||||
} else set(x => ({ ...x, loading: false, error: oneLine(failureText(r)), command: r.command }));
|
||||
}
|
||||
} catch (e) {
|
||||
if (my !== seq.current) return;
|
||||
const msg = errorText(e);
|
||||
set(x => ({ ...x, loading: false, error: msg }));
|
||||
if (tid) d.updateToast(tid, { kind: /cancel/i.test(msg) ? 'info' : 'bad', title: /cancel/i.test(msg) ? t('toast.cancelled') : t('toast.readFailedWhat', { what: o.title ?? method }), detail: msg, progress: undefined, job: undefined });
|
||||
}
|
||||
}, [target, method, argsKey, o.network, o.title, o.appName, d.settings]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
});
|
||||
}, [key, o.skip]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
if (o.skip) { set({ data: null, at: null, fromCache: false, loading: false, error: null, command: null }); return; }
|
||||
const auto = !o.network || dRef.current.settings.refresh.autoRead;
|
||||
const running = dRef.current.isReading(key);
|
||||
set({ data: null, at: null, fromCache: false, loading: true, error: null, command: null });
|
||||
const go = (at: string | null) => {
|
||||
if (!alive) return;
|
||||
const due = !o.network || isStale(method, at);
|
||||
if (running) set(x => ({ ...x, loading: true }));
|
||||
else if (auto && due) refresh();
|
||||
else set(x => ({ ...x, loading: false }));
|
||||
};
|
||||
api.cached<ToolResult>(method, target, JSON.parse(argsKey) as Record<string, unknown>).then(c => {
|
||||
if (!alive) return;
|
||||
if (c && c.value.data !== undefined) set({ data: c.value.data as T, at: c.at, fromCache: true, loading: !o.network, error: null, command: c.value.command });
|
||||
else set(x => ({ ...x, loading: !o.network }));
|
||||
if (!o.network) refresh();
|
||||
}, () => { if (alive) { set(x => ({ ...x, loading: !o.network })); if (!o.network) refresh(); } });
|
||||
if (c && c.value.data !== undefined) set({ data: c.value.data as T, at: c.at, fromCache: true, loading: true, stale: isStale(method, c.at), error: null, command: c.value.command });
|
||||
go(c && c.value.data !== undefined ? c.at : null);
|
||||
}, () => go(null));
|
||||
return () => { alive = false; };
|
||||
}, [target, method, argsKey, o.skip]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
|
|
|
|||
|
|
@ -169,6 +169,11 @@ svg { flex: none; }
|
|||
.topbar .spacer { flex: 1; }
|
||||
.stamp { display: inline-flex; align-items: center; gap: 6px; font-size: var(--t-meta); color: var(--muted); white-space: nowrap; }
|
||||
.stamp svg { width: 13px; height: 13px; }
|
||||
.read-ctl { display: inline-flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.read-stamp { font-size: var(--t-meta); color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 360px; }
|
||||
.read-stamp.read-err { color: var(--warn-ink); }
|
||||
.reader-line { display: flex; align-items: center; gap: 6px; font-size: var(--t-meta); color: var(--muted); min-width: 0; }
|
||||
.reader-line > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dev-pill { font: 700 10px/1 var(--mono); letter-spacing: .06em; color: var(--warn-ink); background: var(--warn-bg); border: 1px solid color-mix(in srgb, var(--warn-solid) 45%, transparent); padding: 5px 7px; border-radius: 5px; }
|
||||
.content { flex: 1; overflow: auto; min-height: 0; scroll-behavior: smooth; }
|
||||
.page { padding: 24px 28px 48px; max-width: 1360px; margin: 0 auto; display: grid; gap: 20px; }
|
||||
|
|
@ -794,6 +799,14 @@ h2[tabindex="-1"]:focus, h2[tabindex="-1"]:focus-visible { outline: none; }
|
|||
.roll-ctl > .small:first-child { min-width: 150px; }
|
||||
.table tbody tr.edit-row td { background: var(--surface-2); }
|
||||
.table tbody tr.edit-row:hover td { background: var(--surface-2); }
|
||||
.issue-row { cursor: pointer; }
|
||||
.issue-row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
.issue-row .chev { flex: none; color: var(--muted); transition: transform var(--speed) var(--ease); }
|
||||
.issue-row .chev.rot { transform: rotate(90deg); }
|
||||
.ellipsis { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||
.issue-detail { display: grid; gap: 12px; padding: 4px 0; min-width: 0; }
|
||||
.issue-title { overflow-wrap: anywhere; }
|
||||
.stack-trace { margin: 0; max-height: 280px; overflow: auto; padding: 10px 12px; border-radius: 8px; background: var(--surface); border: 1px solid var(--line, var(--border)); font: 12px/1.5 var(--mono, ui-monospace, monospace); white-space: pre; }
|
||||
.build-edit { display: grid; gap: 10px; padding: 4px 0; max-width: 760px; }
|
||||
textarea.input { height: auto; padding: 8px 10px; line-height: 1.45; resize: vertical; font-family: var(--font); }
|
||||
.reply-edit { min-height: 110px; }
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { StarIcon } from '@phosphor-icons/react/dist/csr/Star';
|
|||
import type { ReactNode } from 'react';
|
||||
import type { ReleasePhase, StoreRelease } from '../api';
|
||||
import { t } from '../i18n';
|
||||
import { share } from '../lib/format';
|
||||
import { ago, share } from '../lib/format';
|
||||
import { hueOf, monogram, type Tone } from '../lib/model';
|
||||
import { fixHint } from '../lib/views';
|
||||
import { Icon, type IconName, StoreGlyph } from './icons';
|
||||
|
|
@ -173,3 +173,17 @@ export function StudioMark({ studio, size = 'md' }: { studio: { name: string; co
|
|||
export function StoreOnlyTag() {
|
||||
return <span className="chip outline so-tag" title={t('apps.storeOnlyHint')}><Icon name="cloud" />{t('apps.storeOnly')}</span>;
|
||||
}
|
||||
|
||||
/** The state of one read beside its card's title: "read 3 min ago" with a small spinner while it refreshes, or
|
||||
* "couldn't refresh: <reason>" over the last good value, and the per-card refresh icon (no "Read" button first). */
|
||||
export function ReadControl({ r, label }: { r: { data: unknown; at: string | null; loading: boolean; error: string | null; refresh: () => Promise<void> }; label?: string }) {
|
||||
const text = r.error ? t('read.couldNotRefresh', { reason: r.error }) : r.at ? t('read.ago', { when: ago(r.at) }) : r.loading ? t('read.waiting') : '';
|
||||
return (
|
||||
<span className="read-ctl" data-read-state={r.loading ? 'loading' : r.error ? 'error' : r.data !== null ? 'ok' : 'none'}>
|
||||
{text && <span className={`read-stamp ${r.error ? 'read-err' : ''}`} title={r.error ?? undefined}>{text}</span>}
|
||||
<button className="btn btn-sm btn-icon" onClick={() => { r.refresh(); }} disabled={r.loading} aria-label={label ?? t('read.refresh')} title={label ?? t('read.refresh')}>
|
||||
<Icon name="refresh" className={r.loading ? 'spin' : undefined} />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@
|
|||
},
|
||||
"refresh": {
|
||||
"autoMinutes": 0,
|
||||
"autoRead": true,
|
||||
"everyMinutes": 15,
|
||||
"icons": true,
|
||||
"onLaunch": false,
|
||||
"parallel": 4
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@
|
|||
},
|
||||
"refresh": {
|
||||
"autoMinutes": 0,
|
||||
"autoRead": true,
|
||||
"everyMinutes": 15,
|
||||
"icons": true,
|
||||
"onLaunch": false,
|
||||
"parallel": 4
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@
|
|||
},
|
||||
"refresh": {
|
||||
"autoMinutes": 0,
|
||||
"autoRead": true,
|
||||
"everyMinutes": 15,
|
||||
"icons": true,
|
||||
"onLaunch": false,
|
||||
"parallel": 4
|
||||
|
|
|
|||
|
|
@ -81,6 +81,8 @@
|
|||
},
|
||||
"refresh": {
|
||||
"autoMinutes": 0,
|
||||
"autoRead": true,
|
||||
"everyMinutes": 15,
|
||||
"icons": true,
|
||||
"onLaunch": false,
|
||||
"parallel": 4
|
||||
|
|
|
|||
257
apps/desktop/tests/scheduler.test.ts
Normal file
257
apps/desktop/tests/scheduler.test.ts
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
// The background read scheduler (src/lib/scheduler.ts) with a fake clock: concurrency per lane, priority, dedupe,
|
||||
// spacing per credential, backoff with jitter on 429, slowing down and pausing on App Store Connect's quota header;
|
||||
// and the TTLs / plan of src/lib/freshness.ts, the "Copy for AI" markdown of src/lib/issues.ts.
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { isStale, lanesOf, looksThrottled, oneLine, readKey, readsFor, ttlOf } from '../src/lib/freshness';
|
||||
import { appleSignatureView, issueMarkdown, playIssueView } from '../src/lib/issues';
|
||||
import { type Clock, type Outcome, ReadScheduler, type ReadTask } from '../src/lib/scheduler';
|
||||
|
||||
class FakeClock implements Clock {
|
||||
t = 0;
|
||||
private timers: { at: number; fn: () => void; id: number }[] = [];
|
||||
private n = 0;
|
||||
rand = 0.5;
|
||||
now() { return this.t; }
|
||||
setTimeout(fn: () => void, ms: number) { const id = ++this.n; this.timers.push({ at: this.t + ms, fn, id }); return id; }
|
||||
clearTimeout(h: unknown) { this.timers = this.timers.filter(x => x.id !== h); }
|
||||
random() { return this.rand; }
|
||||
/** Moves time forward, firing due timers in order (and letting promise callbacks run in between). */
|
||||
async advance(ms: number) {
|
||||
const end = this.t + ms;
|
||||
for (;;) {
|
||||
await flush();
|
||||
const next = this.timers.filter(x => x.at <= end).sort((a, b) => a.at - b.at)[0];
|
||||
if (!next) break;
|
||||
this.t = next.at;
|
||||
this.timers = this.timers.filter(x => x !== next);
|
||||
next.fn();
|
||||
}
|
||||
this.t = end;
|
||||
await flush();
|
||||
}
|
||||
}
|
||||
const flush = async () => { for (let i = 0; i < 10; i++) await Promise.resolve(); };
|
||||
|
||||
/** A task whose run resolves when the test says so. */
|
||||
function controlled() {
|
||||
const runs: { key: string; done: (v: unknown) => void; fail: (e: unknown) => void }[] = [];
|
||||
const task = (key: string, o: Partial<ReadTask<unknown>> = {}): ReadTask<unknown> => ({
|
||||
key, lanes: ['apple'], credentials: ['apple:k1'], priority: 2, ...o,
|
||||
run: () => new Promise((done, fail) => { runs.push({ key, done, fail }); }),
|
||||
});
|
||||
return { runs, task };
|
||||
}
|
||||
|
||||
describe('ReadScheduler', () => {
|
||||
test('at most N at once per lane, the rest wait for a slot', async () => {
|
||||
const clock = new FakeClock();
|
||||
const s = new ReadScheduler({ clock, spacingMs: 0, limits: { apple: 2, play: 1 } });
|
||||
const { runs, task } = controlled();
|
||||
for (const k of ['a', 'b', 'c', 'd']) s.enqueue(task(k)).catch(() => {});
|
||||
s.enqueue(task('p1', { lanes: ['play'], credentials: ['play:k'] })).catch(() => {});
|
||||
s.enqueue(task('p2', { lanes: ['play'], credentials: ['play:k'] })).catch(() => {});
|
||||
await clock.advance(0);
|
||||
expect(runs.map(r => r.key)).toEqual(['a', 'b', 'p1']);
|
||||
expect(s.stats()).toMatchObject({ running: 3, queued: 3, total: 6 });
|
||||
runs[0]!.done(1);
|
||||
await clock.advance(0);
|
||||
expect(runs.map(r => r.key)).toEqual(['a', 'b', 'p1', 'c']);
|
||||
// a task in both lanes needs a slot in each
|
||||
const both = new ReadScheduler({ clock, spacingMs: 0, limits: { apple: 1, play: 1 } });
|
||||
const c2 = controlled();
|
||||
both.enqueue(c2.task('apple-only')).catch(() => {});
|
||||
both.enqueue(c2.task('both', { lanes: ['apple', 'play'], credentials: [] })).catch(() => {});
|
||||
both.enqueue(c2.task('play-only', { lanes: ['play'], credentials: [] })).catch(() => {});
|
||||
await clock.advance(0);
|
||||
expect(c2.runs.map(r => r.key)).toEqual(['apple-only', 'play-only']);
|
||||
});
|
||||
|
||||
test('priority first (visible app, portfolio, the rest), FIFO within one; re-enqueueing raises it', async () => {
|
||||
const clock = new FakeClock();
|
||||
const s = new ReadScheduler({ clock, spacingMs: 0, limits: { apple: 1 } });
|
||||
const { runs, task } = controlled();
|
||||
s.enqueue(task('first')).catch(() => {});
|
||||
s.enqueue(task('rest-1', { priority: 2 })).catch(() => {});
|
||||
s.enqueue(task('rest-2', { priority: 2 })).catch(() => {});
|
||||
s.enqueue(task('portfolio', { priority: 1 })).catch(() => {});
|
||||
s.enqueue(task('visible', { priority: 0 })).catch(() => {});
|
||||
s.enqueue(task('rest-2', { priority: 0 })).catch(() => {}); // the owner opened that tab
|
||||
await clock.advance(0);
|
||||
for (let i = 0; i < 5; i++) { runs.at(-1)!.done(i); await clock.advance(0); }
|
||||
expect(runs.map(r => r.key)).toEqual(['first', 'rest-2', 'visible', 'portfolio', 'rest-1']);
|
||||
});
|
||||
|
||||
test('one in-flight read per key: the same promise, one run', async () => {
|
||||
const clock = new FakeClock();
|
||||
const s = new ReadScheduler({ clock, spacingMs: 0 });
|
||||
const { runs, task } = controlled();
|
||||
const p1 = s.enqueue(task('k'));
|
||||
const p2 = s.enqueue(task('k'));
|
||||
await clock.advance(0);
|
||||
const p3 = s.enqueue(task('k'));
|
||||
expect(s.has('k')).toBe(true);
|
||||
expect(runs).toHaveLength(1);
|
||||
runs[0]!.done('v');
|
||||
expect(await Promise.all([p1, p2, p3])).toEqual(['v', 'v', 'v']);
|
||||
expect(s.has('k')).toBe(false);
|
||||
// finished: a new enqueue runs again
|
||||
s.enqueue(task('k')).catch(() => {});
|
||||
await clock.advance(0);
|
||||
expect(runs).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('starts on one credential are spaced; other credentials are not held up', async () => {
|
||||
const clock = new FakeClock();
|
||||
const s = new ReadScheduler({ clock, spacingMs: 400, limits: { apple: 8 } });
|
||||
const { runs, task } = controlled();
|
||||
for (const k of ['a', 'b', 'c']) s.enqueue(task(k)).catch(() => {});
|
||||
s.enqueue(task('other', { credentials: ['apple:k2'] })).catch(() => {});
|
||||
await clock.advance(0);
|
||||
expect(runs.map(r => r.key)).toEqual(['a', 'other']);
|
||||
await clock.advance(399);
|
||||
expect(runs).toHaveLength(2);
|
||||
await clock.advance(1);
|
||||
expect(runs.map(r => r.key)).toEqual(['a', 'other', 'b']);
|
||||
await clock.advance(400);
|
||||
expect(runs.map(r => r.key)).toEqual(['a', 'other', 'b', 'c']);
|
||||
});
|
||||
|
||||
test('429: runs again later with exponential backoff and jitter, the key waits too; gives up after maxAttempts', async () => {
|
||||
const clock = new FakeClock();
|
||||
let attempts = 0;
|
||||
const classify = (r: unknown): Outcome => ({ throttled: (r as { status: number }).status === 429 });
|
||||
const s = new ReadScheduler({ clock, spacingMs: 0, backoffBaseMs: 1000, backoffMaxMs: 60_000, maxAttempts: 4, classify });
|
||||
const starts: number[] = [];
|
||||
const p = s.enqueue({ key: 'x', lanes: ['play'], credentials: ['play:k'], priority: 0, run: async () => { starts.push(clock.now()); attempts++; return { status: attempts < 3 ? 429 : 200 }; } });
|
||||
let other = false;
|
||||
await clock.advance(0);
|
||||
s.enqueue({ key: 'y', lanes: ['play'], credentials: ['play:k'], priority: 2, run: async () => { other = true; return { status: 200 }; } }).catch(() => {});
|
||||
await clock.advance(0);
|
||||
// first try at 0; jitter (rand 0.5) -> 0.75 x the exponential step: 750 ms, then 1500 ms
|
||||
expect(starts).toEqual([0]);
|
||||
expect(s.stats().paused['play:k']).toBe(750);
|
||||
await clock.advance(749);
|
||||
expect(starts).toEqual([0]);
|
||||
expect(other).toBe(false); // the throttled key holds back its other reads as well
|
||||
await clock.advance(1);
|
||||
expect(starts).toEqual([0, 750]);
|
||||
await clock.advance(1500);
|
||||
expect(starts).toEqual([0, 750, 2250]);
|
||||
expect(await p).toEqual({ status: 200 });
|
||||
await clock.advance(0);
|
||||
expect(other).toBe(true);
|
||||
// always 429: resolved with the last answer after maxAttempts, counted as failed
|
||||
const s2 = new ReadScheduler({ clock, spacingMs: 0, backoffBaseMs: 10, maxAttempts: 3, classify });
|
||||
let n = 0;
|
||||
const q = s2.enqueue({ key: 'z', lanes: ['play'], credentials: ['play:k'], priority: 0, run: async () => { n++; return { status: 429 }; } });
|
||||
await clock.advance(1000);
|
||||
expect(await q).toEqual({ status: 429 });
|
||||
expect(n).toBe(3);
|
||||
});
|
||||
|
||||
test('App Store Connect quota: spreads the rest of the hour when it runs low, pauses at the reserve', async () => {
|
||||
const clock = new FakeClock();
|
||||
let remaining = 3412;
|
||||
const classify = (_r: unknown, _e: unknown, t: ReadTask<unknown>): Outcome => ({ quota: { credential: t.credentials[0]!, limit: 3600, remaining } });
|
||||
const s = new ReadScheduler({ clock, spacingMs: 100, reserve: 100, slowBelow: 0.2, pauseMs: 600_000, limits: { apple: 4 }, classify });
|
||||
const starts: number[] = [];
|
||||
const run = async () => { starts.push(clock.now()); return {}; };
|
||||
const add = (k: string) => s.enqueue({ key: k, lanes: ['apple'], credentials: ['apple:k1'], priority: 2, run }).catch(() => {});
|
||||
add('a'); add('b');
|
||||
await clock.advance(0);
|
||||
await clock.advance(100);
|
||||
expect(starts).toEqual([0, 100]); // plenty left: the base spacing
|
||||
expect(s.stats().quota['apple:k1']).toMatchObject({ limit: 3600, remaining: 3412 });
|
||||
remaining = 500; // under 20 %: (500 - 100 reserve) left for the hour -> one start every 9 s
|
||||
add('c'); add('d');
|
||||
await clock.advance(100);
|
||||
const c = starts.at(-1)!;
|
||||
await clock.advance(8_000);
|
||||
expect(starts.at(-1)).toBe(c);
|
||||
await clock.advance(1_100);
|
||||
expect(starts.at(-1)! - c).toBe(9_000);
|
||||
remaining = 80; // at the reserve: the key pauses for 10 minutes
|
||||
add('e'); add('f');
|
||||
await clock.advance(9_000);
|
||||
const e = starts.at(-1)!;
|
||||
await clock.advance(599_000);
|
||||
expect(starts.at(-1)).toBe(e);
|
||||
expect(Object.keys(s.stats().paused)).toEqual(['apple:k1']);
|
||||
await clock.advance(2_000);
|
||||
expect(starts.at(-1)! - e).toBe(600_000);
|
||||
// the quota a run reported stays the lowest of the hour, and is forgotten after it
|
||||
remaining = 3000;
|
||||
add('g');
|
||||
await clock.advance(600_000);
|
||||
expect(s.stats().quota['apple:k1']!.remaining).toBe(80);
|
||||
await clock.advance(3_600_000);
|
||||
expect(s.stats().quota['apple:k1']).toBeUndefined();
|
||||
});
|
||||
|
||||
test('progress for the status line: done of total in one round, failures, clear()', async () => {
|
||||
const clock = new FakeClock();
|
||||
const s = new ReadScheduler({ clock, spacingMs: 0, limits: { apple: 1 } });
|
||||
const { runs, task } = controlled();
|
||||
const ps = ['a', 'b', 'c'].map(k => s.enqueue(task(k)).catch(e => String(e)));
|
||||
await clock.advance(0);
|
||||
runs[0]!.done(1);
|
||||
await clock.advance(0);
|
||||
expect(s.stats()).toMatchObject({ done: 1, running: 1, queued: 1, total: 3 });
|
||||
runs[1]!.fail(new Error('boom'));
|
||||
await clock.advance(0);
|
||||
expect(s.stats()).toMatchObject({ done: 2, failed: 1, total: 3 });
|
||||
s.clear();
|
||||
runs[2]?.done(1);
|
||||
await clock.advance(0);
|
||||
expect(await ps[1]).toBe('Error: boom');
|
||||
expect(s.stats()).toMatchObject({ done: 0, total: 0, running: 0, queued: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('freshness', () => {
|
||||
test('TTLs: status 5 min, reviews 15 min, catalog / prices / listings 30 min, health / analytics 6 h', () => {
|
||||
expect([ttlOf('apple.status'), ttlOf('play.status'), ttlOf('reviews.list'), ttlOf('catalog.diff'), ttlOf('prices.current'), ttlOf('project.listings'), ttlOf('health'), ttlOf('analytics.compare')].map(x => x / 60_000))
|
||||
.toEqual([5, 5, 15, 30, 30, 30, 360, 360]);
|
||||
const now = Date.parse('2026-10-13T12:00:00Z');
|
||||
expect(isStale('apple.status', '2026-10-13T11:56:00Z', now)).toBe(false);
|
||||
expect(isStale('apple.status', '2026-10-13T11:55:00Z', now)).toBe(true);
|
||||
expect(isStale('health', '2026-10-13T07:00:00Z', now)).toBe(false);
|
||||
expect(isStale('health', null, now)).toBe(true);
|
||||
expect(isStale('health', 'garbage', now)).toBe(true);
|
||||
});
|
||||
test('lanes, the reads of an app, keys, throttling text, one-line reasons', () => {
|
||||
expect(lanesOf('apple.status', { ios: true, android: true })).toEqual(['apple']);
|
||||
expect(lanesOf('health', { ios: true, android: true })).toEqual(['apple', 'play']);
|
||||
expect(lanesOf('health', { ios: false, android: true })).toEqual(['play']);
|
||||
expect(lanesOf('scan', { ios: true, android: true })).toEqual(['local']);
|
||||
const all = readsFor({ folder: true, ios: true, android: true }).map(r => r.method);
|
||||
expect(all).toEqual(expect.arrayContaining(['apple.status', 'play.status', 'apple.testflight_status', 'reviews.list', 'health', 'catalog.diff', 'prices.current', 'project.listings', 'validate', 'scan', 'analytics.compare', 'aso.audit', 'apple.ppo_status']));
|
||||
expect(readsFor({ folder: false, ios: false, android: true }).map(r => r.method)).toEqual(['play.status', 'reviews.list', 'health']);
|
||||
expect(readKey('p1', 'reviews.list', { days: 30 })).toBe(readKey('p1', 'reviews.list', { days: 30 }));
|
||||
expect(readKey('p1', 'reviews.list', { days: 30 })).not.toBe(readKey('p1', 'reviews.list', {}));
|
||||
expect(looksThrottled('GET /v1/apps -> 429 Too Many Requests')).toBe(true);
|
||||
expect(looksThrottled('{"error":{"status":"RESOURCE_EXHAUSTED"}}')).toBe(true);
|
||||
expect(looksThrottled('-> 403 forbidden')).toBe(false);
|
||||
expect(oneLine('12:00:01 [health] reading\n12:00:02 [health] error: key not found\n')).toBe('error: key not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('issue detail', () => {
|
||||
test('Copy for AI: title, type, counts, versions, top frames, console link', () => {
|
||||
const v = playIssueView({ type: 'CRASH', cause: 'java.lang.IllegalStateException', location: 'com.example.Combo.update', reports: 412, users: 268, firstVersion: '224', lastVersion: '231', id: 'a1', uri: 'https://play.google.com/console/x' });
|
||||
expect(v.sampleArgs).toEqual({ issue: 'a1' });
|
||||
const stack = Array.from({ length: 20 }, (_, i) => `\tat com.example.F${i}(F.kt:${i})`);
|
||||
const md = issueMarkdown('Prism Puddle', v, { store: 'play', version: '231', device: 'Galaxy A12', os: 'API 31', stack });
|
||||
expect(md).toContain('## Google Play CRASH: java.lang.IllegalStateException');
|
||||
expect(md).toContain('- Reports: 412, users: 268');
|
||||
expect(md).toContain('- versionCode: 224 to 231');
|
||||
expect(md).toContain('F14(F.kt:14)');
|
||||
expect(md).not.toContain('F15(F.kt:15)');
|
||||
expect(md).toContain('... 5 more frames');
|
||||
expect(md).toContain('Console: https://play.google.com/console/x');
|
||||
const a = appleSignatureView({ build: '48', type: 'HANGS', signature: 'ComboMeter.update + 212', weight: 0.31, id: 's1' });
|
||||
expect(a.sampleArgs).toEqual({ signature: 's1' });
|
||||
expect(issueMarkdown('X', a, null)).toContain('- Share of reports: 31 %');
|
||||
expect(playIssueView({ type: 'CRASH', cause: '', location: '', reports: 1, users: 1 }).sampleArgs).toBeNull();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue