desktop: one read registry for every read button: single-flight per canonical key, broad reads cover narrow keys, overlapping buttons all busy and disabled (useReadState), 20 s 'read just now' memo, quota pause on the buttons, every read through the scheduler (Check again, Import apps, account Test, Read the stores, Dependencies), writes invalidate only the reads they change; tests/read-registry.test.ts; fixtures ?blocked and window.__fakeCalls
This commit is contained in:
parent
7345de2f7c
commit
c03938f9dc
14 changed files with 861 additions and 164 deletions
|
|
@ -10,7 +10,9 @@ import { type Account, type AccountCheck, api, errorText, STUDIO_COLORS, type St
|
|||
import { type Key, t } from '../i18n';
|
||||
import { ago } from '../lib/format';
|
||||
import { withIgnored } from '../lib/identity';
|
||||
import { type AccountHealth, useData } from '../store';
|
||||
import { type AccountHealth, useData, useReadState } from '../store';
|
||||
import { accountCheckKey, importKey } from '../lib/read-registry';
|
||||
import { ImportAppsButton, TestAccountButton } from '../ui/ReadButtons';
|
||||
import { Icon, StoreGlyph } from '../ui/icons';
|
||||
import { Chip, Notice, StudioMark } from '../ui/kit';
|
||||
import { EmptyState, PageHeader, Section, StatTile } from '../ui/ds';
|
||||
|
|
@ -58,11 +60,14 @@ function AccountRow({ a, busy, setBusy, onError }: { a: Account; busy: string |
|
|||
};
|
||||
const save = (e: FormEvent) => { e.preventDefault(); run(`ed-${a.id}`, async () => { await api.updateAccount(a.id, label, studio || null); if (a.kind === 'play' && a.profile && dev.trim() !== (a.developerId ?? '')) await api.setPlayDeveloper(a.id, dev.trim() || null); await d.reload(); setEdit(false); }); };
|
||||
const check = h?.check;
|
||||
// Test and Import of this account: busy from the read registry (Test all / Import apps make them busy too)
|
||||
const ck = useReadState(accountCheckKey(a.id));
|
||||
const im = useReadState(importKey(a.id));
|
||||
const cm = useContextMenu();
|
||||
const more = useMenuButton();
|
||||
const menu = (): MenuInput => [
|
||||
{ label: t('acct.test'), icon: 'shield', disabled: busy ? t('cm.busy') : false, onSelect: () => run(`ck-${a.id}`, () => d.checkAccount(a.id)) },
|
||||
{ label: t('acct.import'), icon: 'download', disabled: busy || d.importBusy ? t('cm.busy') : false, onSelect: () => run(`im-${a.id}`, () => d.importApps(a.id)) },
|
||||
{ label: t('acct.test'), icon: 'shield', disabled: ck.busy ? t('cm.busy') : false, onSelect: () => { d.checkAccount(a.id).catch(() => {}); } },
|
||||
{ label: t('acct.import'), icon: 'download', disabled: im.busy ? t('cm.busy') : false, onSelect: () => { d.importApps(a.id).catch(() => {}); } },
|
||||
{ label: t('acct.edit'), icon: 'edit', onSelect: () => setEdit(true) },
|
||||
'sep',
|
||||
{ label: t('cm.copy'), icon: 'copy', submenu: [
|
||||
|
|
@ -90,8 +95,8 @@ function AccountRow({ a, busy, setBusy, onError }: { a: Account; busy: string |
|
|||
</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn btn-sm" onClick={() => run(`ck-${a.id}`, () => d.checkAccount(a.id))} disabled={!!busy}><Icon name="shield" className={busy === `ck-${a.id}` ? 'spin' : undefined} />{t('acct.test')}</button>
|
||||
<button className="btn btn-sm" onClick={() => run(`im-${a.id}`, () => d.importApps(a.id))} disabled={!!busy || d.importBusy}><Icon name="download" />{t('acct.import')}</button>
|
||||
<TestAccountButton account={a.id} label={t('acct.test')} />
|
||||
<ImportAppsButton account={a.id} label={t('acct.import')} />
|
||||
<button className="btn btn-sm btn-ghost btn-icon" onClick={() => setEdit(x => !x)} aria-expanded={edit} aria-label={t('acct.editAria', { label: a.label })} title={t('acct.edit')}><Icon name="edit" /></button>
|
||||
<button className="btn btn-sm btn-ghost btn-icon" {...more(menu)} aria-label={t('cm.more')} title={t('cm.more')}><Icon name="more" /></button>
|
||||
</div>
|
||||
|
|
@ -196,7 +201,6 @@ export function Accounts({ focus }: { focus?: string }) {
|
|||
const addStudio = () => act('studio', async () => { const s = await api.saveStudio(null, studioName, studioColor); setStudioName(''); return s; }, r => t('studio.added', { name: (r as Studio).name }));
|
||||
const addPlay = () => act('play', async () => { const a = await api.addPlayAccount(playLabel, playStudio || null); if (a) setPlayLabel(''); return a; }, r => t('accounts.saved', { label: (r as Account).label }));
|
||||
const addApple = () => act('apple', async () => { const a = await api.addAppleAccount(appleLabel, keyId, issuerId, appleStudio || null); if (a) { setAppleLabel(''); setKeyId(''); setIssuerId(''); } return a; }, r => t('accounts.saved', { label: (r as Account).label }));
|
||||
const testAll = async () => { setBusy('all'); await Promise.all(d.accounts.map(a => d.checkAccount(a.id))); setBusy(null); };
|
||||
const unassigned = d.accounts.filter(a => !a.studio || !d.studios.some(s => s.id === a.studio));
|
||||
const studioSelect = (id: string, value: string, set: (v: string) => void) => (
|
||||
<div className="field"><label htmlFor={id}>{t('studio.studio')}</label>
|
||||
|
|
@ -209,8 +213,8 @@ export function Accounts({ focus }: { focus?: string }) {
|
|||
return (
|
||||
<div className="page scr-accounts">
|
||||
<PageHeader icon="key" eyebrow={t('v2cfg.workspace')} title={t('nav.accounts')} subtitle={t('accounts.leadStudios')} actions={<>
|
||||
<button className="btn" onClick={testAll} disabled={!!busy || !d.accounts.length}><Icon name="shield" className={busy === 'all' ? 'spin' : undefined} />{t('acct.testAll')}</button>
|
||||
<button className="btn btn-primary" onClick={() => d.importApps()} disabled={d.importBusy || !d.accounts.length}><Icon name="download" className={d.importBusy ? 'spin' : undefined} />{t('action.importApps')}</button>
|
||||
<TestAccountButton className="btn" label={t('acct.testAll')} />
|
||||
<ImportAppsButton className="btn btn-primary" />
|
||||
</>} />
|
||||
{d.accounts.length > 0 && <div className="ds-grid acct-kpis">
|
||||
<StatTile icon="building" label={t('v2cfg.studios')} value={d.studios.length} />
|
||||
|
|
|
|||
|
|
@ -6,12 +6,15 @@ import { type AndroidSdkLookup, api, type DownloadInfo, errorText, type LocalToo
|
|||
import { t } from '../i18n';
|
||||
import { bytes } from '../lib/format';
|
||||
import { shortPath } from '../paths';
|
||||
import { useData } from '../store';
|
||||
import { useData, useReadState } from '../store';
|
||||
import { Icon, type IconName } from '../ui/icons';
|
||||
import { Chip, Notice, Skeleton } from '../ui/kit';
|
||||
import { PageHeader, Section, StatTile, StatusPill } from '../ui/ds';
|
||||
import '../styles/deps.css';
|
||||
|
||||
/** The local tools check (system.tools): one registry key. */
|
||||
const TOOLS_KEY = '*\u001fsystem.tools';
|
||||
|
||||
const TOOL_ICON: Record<string, IconName> = { ffmpeg: 'image', ffprobe: 'search', chrome: 'globe', xcode: 'terminal', simctl: 'phone', adb: 'phone', git: 'layers' };
|
||||
|
||||
/** Status chip of one tool. */
|
||||
|
|
@ -29,16 +32,18 @@ export function Dependencies() {
|
|||
const [sdk, setSdk] = useState<AndroidSdkLookup | null>(d.sidecar?.tools?.androidSdk ?? null);
|
||||
const [info, setInfo] = useState<DownloadInfo[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// local tools check: one registry key, so a second click (or a remount) joins the check in flight
|
||||
const busy = useReadState(TOOLS_KEY).busy;
|
||||
const [dl, setDl] = useState<Record<string, Dl>>({});
|
||||
|
||||
const reg = d.registry;
|
||||
const refresh = useCallback(async () => {
|
||||
setBusy(true); setError(null);
|
||||
setError(null);
|
||||
try {
|
||||
const [s, i] = await Promise.all([api.call<LocalTools>('system.tools'), api.toolsInfo()]);
|
||||
const [s, i] = await reg.read({ key: TOOLS_KEY, lanes: ['local'], accounts: [], priority: 0, run: () => Promise.all([api.call<LocalTools>('system.tools'), api.toolsInfo()]) }, { force: true });
|
||||
setTools(s.tools); setInfo(i); setSdk(s.androidSdk ?? null);
|
||||
} catch (e) { setError(errorText(e)); } finally { setBusy(false); }
|
||||
}, []);
|
||||
} catch (e) { setError(errorText(e)); }
|
||||
}, [reg]);
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
useEffect(() => {
|
||||
const un = onDownload(e => setDl(m => {
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@
|
|||
// version, crash rate, catalog parity, config check), grouped by studio; then what changed and what to fix first.
|
||||
import { plainError } from '../../../../cli/lib/google-errors';
|
||||
import { BlockedProjectsNotice, blockedProjects } from '../ui/StoreError';
|
||||
import { type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
import { api, type AppStatus, newJob, type StoreRelease, type ToolResult } from '../api';
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { api, type AppStatus, type StoreRelease, type ToolResult } from '../api';
|
||||
import { type Key, t } from '../i18n';
|
||||
import { ago, pct, share } from '../lib/format';
|
||||
import { useShowIgnored } from '../lib/show-ignored';
|
||||
|
|
@ -15,7 +15,10 @@ import { type AppTab, useNav } from '../nav';
|
|||
import { useContextMenu } from '../ui/ContextMenu';
|
||||
import { appMenu } from '../ui/app-menu';
|
||||
import type { IconName } from '../ui/icons';
|
||||
import { useData } from '../store';
|
||||
import { useData, useReadState } from '../store';
|
||||
import { ImportAppsButton } from '../ui/ReadButtons';
|
||||
import { readKey } from '../lib/freshness';
|
||||
import { PORTFOLIO_KEY } from '../lib/read-registry';
|
||||
import { exportName, portfolioCsv } from '../lib/exports';
|
||||
import { useExports } from '../ui/Exports';
|
||||
import { Icon, StoreGlyph } from '../ui/icons';
|
||||
|
|
@ -302,20 +305,25 @@ export function Portfolio() {
|
|||
const [showIgnored, setShowIgnored] = useShowIgnored();
|
||||
const menu = useContextMenu();
|
||||
const [round, setRound] = useState(0);
|
||||
const [rechecking, setRechecking] = useState(false);
|
||||
const health = useCachedHealth(visible.map(a => a.target), round);
|
||||
const blocked = useMemo(() => blockedProjects(visible.map(a => {
|
||||
const h = health[a.target]?.h, l = d.liveFor(a);
|
||||
return { app: a.name, errors: [h?.play && isError(h.play) ? h.play : null, l?.storeErrors.android] };
|
||||
})), [visible, health, d]);
|
||||
// "Check again": read health of the blocked apps again (one at a time: the same Cloud project's quota), then the cache
|
||||
const recheck = async () => {
|
||||
setRechecking(true);
|
||||
const names = new Set(blocked.flatMap(b => b.apps));
|
||||
for (const a of visible.filter(x => names.has(x.name))) await api.call<ToolResult>('health', a.target, { args: {} }, newJob('health')).catch(() => null);
|
||||
setRechecking(false);
|
||||
setRound(n => n + 1);
|
||||
// "Check again": the health reads of the blocked apps (the same keys as their Health tab's refresh and Refresh all),
|
||||
// plus the portfolio read when a blocked store came from it. Busy (and disabled) whenever any of these keys is in
|
||||
// flight, whoever started it; a click joins what runs and the scheduler spaces the rest (one Cloud project's quota).
|
||||
const blockedApps = useMemo(() => { const names = new Set(blocked.flatMap(b => b.apps)); return visible.filter(x => names.has(x.name)); }, [blocked, visible]);
|
||||
const fromLive = blockedApps.some(a => !!d.liveFor(a)?.storeErrors.android);
|
||||
const checkKeys = useMemo(() => [...blockedApps.map(a => readKey(a.target, 'health', {})), ...(fromLive ? [PORTFOLIO_KEY] : [])], [blockedApps, fromLive]);
|
||||
const checkReads = useReadState({ keys: checkKeys });
|
||||
const recheck = () => {
|
||||
Promise.allSettled([...blockedApps.map(a => d.schedule(a.target, 'health', {}, 0)), ...(fromLive ? [d.refreshPortfolio(true)] : [])]).catch(() => {});
|
||||
};
|
||||
// whoever read them (this button, Refresh all, a Health tab), the notice looks at the saved health again after
|
||||
const wasBusy = useRef(false);
|
||||
useEffect(() => { if (wasBusy.current && !checkReads.busy) setRound(n => n + 1); wasBusy.current = checkReads.busy; }, [checkReads.busy]);
|
||||
const allReads = useReadState({ tags: ['all'] });
|
||||
const open = (a: UnifiedApp) => nav.go({ screen: 'app', appId: a.key, tab: 'releases' });
|
||||
|
||||
const all = useMemo(() => visible.map(app => {
|
||||
|
|
@ -411,14 +419,14 @@ export function Portfolio() {
|
|||
<p>{t('portfolio.subStudios', { count: visible.length, stores, studios: d.studios.length })}{d.live ? ` · ${t('pf.readAgo', { when: ago(d.live.at) })}` : ''}</p>
|
||||
</div>
|
||||
<div className="actions">
|
||||
{neverImported === 0 && <button className="btn" onClick={() => d.importApps()} disabled={d.importBusy || !d.accounts.length} title={t('action.importAppsHint')}><Icon name="download" className={d.importBusy ? 'spin' : undefined} />{t('action.importApps')}</button>}
|
||||
{neverImported === 0 && <ImportAppsButton className="btn" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{d.portfolioError && <Notice tone="bad">{d.portfolioError}</Notice>}
|
||||
<BlockedProjectsNotice blocked={blocked} onRetry={recheck} busy={rechecking} />
|
||||
{neverImported > 0 && <Notice tone="info" icon="download" action={<button className="btn btn-sm" onClick={() => d.importApps()} disabled={d.importBusy}>{t('action.importApps')}</button>}>{t('portfolio.notImported', { count: neverImported })}</Notice>}
|
||||
{!known && d.ready && d.portfolioBusy !== 'live' && <Notice tone="info" action={<button className="btn btn-sm btn-primary" onClick={() => { d.readAll(true).catch(() => {}); }}>{t('action.readStores')}</button>}>{t('portfolio.notReadNotice')}</Notice>}
|
||||
<BlockedProjectsNotice blocked={blocked} onRetry={recheck} busy={checkReads.busy} />
|
||||
{neverImported > 0 && <Notice tone="info" icon="download" action={<ImportAppsButton />}>{t('portfolio.notImported', { count: neverImported })}</Notice>}
|
||||
{!known && d.ready && d.portfolioBusy !== 'live' && <Notice tone="info" action={<button className="btn btn-sm btn-primary" onClick={() => { d.readAll(true).catch(() => {}); }} disabled={allReads.busy} aria-busy={allReads.busy}><Icon name="refresh" className={allReads.busy ? 'spin' : undefined} />{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 })} />
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { pairCandidates, type UnifiedApp, withPair, withUnpair } from '../lib/id
|
|||
import { useNav } from '../nav';
|
||||
import { usePrefs } from '../prefs-ctx';
|
||||
import { useData } from '../store';
|
||||
import { ImportAppsButton, RefreshAllButton } from '../ui/ReadButtons';
|
||||
import { Icon, type IconName, StoreGlyph } from '../ui/icons';
|
||||
import { AppIcon, Chip, Kbd, MOD, Notice, Seg, StudioMark } from '../ui/kit';
|
||||
|
||||
|
|
@ -153,8 +154,8 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;
|
|||
{[1, 2, 3, 4, 6, 8].map(n => <option key={n} value={n}>{n}</option>)}
|
||||
</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.readAll(true).catch(() => {}); }}><Icon name="refresh" />{t('action.refreshAll')}</button>
|
||||
<ImportAppsButton />
|
||||
<RefreshAllButton />
|
||||
<span className="small muted">{d.live ? t('foot.lastRead', { when: ago(d.live.at) }) : t('foot.neverRead')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue