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