desktop: Releases > Send for review (App Store submission state, builds by number, Submit / Resubmit / in-app purchases; Play versionCodes by number, Send to production, managed publishing, console plans for countries and Send changes for review), plan panel before every owner dialog with streamed progress; App info console steps for every console plan form with verify; Catalog price-change owner actions and the declined state; Data safety deletion answer; TestFlight create missing groups; Android SDK in Dependencies; upload progress in job toasts; boot check send-for-review cases on D1 and D3 state
This commit is contained in:
parent
33eb24a3dc
commit
2b101f0fd8
22 changed files with 1527 additions and 57 deletions
174
apps/desktop/src/ui/ConsolePlan.tsx
Normal file
174
apps/desktop/src/ui/ConsolePlan.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
// Console steps without an API, agent-first (`storebound console plan <form> --json`, offline): the start page (a
|
||||
// deep link when the ids are known), the steps in order with what to click, the answer sheet from the config (certain,
|
||||
// ask the owner, owner check), the buttons never to press and the final one that stays the owner's, then a read-only
|
||||
// check of the result (`console verify <form>`: the page text pasted here, or the App Store Connect API for the app
|
||||
// record and the age rating). Nothing here clicks in a console: the owner's AI agent (or the owner) drives the page
|
||||
// with this plan, `Copy plan` hands it over as JSON; "Fill in console" (ui/ConsoleFill.tsx) is the scripted fast path
|
||||
// where it exists. Every form of `console plan`, including the Play pages added for the first production release.
|
||||
import { type KeyboardEvent, useEffect, useRef, useState } from 'react';
|
||||
import { api, errorText, newJob, type Project, type ToolResult } from '../api';
|
||||
import { type Key, t } from '../i18n';
|
||||
import { useData } from '../store';
|
||||
import { Icon, StoreGlyph } from './icons';
|
||||
import { Chip, Notice } from './kit';
|
||||
|
||||
/** The forms of `console plan` (cli/mcp-tools.ts CONSOLE_PLAN_FORMS). */
|
||||
export type PlanForm = 'apple-app-record' | 'apple-age-rating' | 'apple-app-privacy' | 'play-app-record' | 'play-content-rating' | 'play-target-audience'
|
||||
| 'play-store-settings' | 'play-app-content' | 'play-privacy-policy' | 'play-ads' | 'play-sign-in-details' | 'play-advertising-id' | 'play-government-apps'
|
||||
| 'play-financial-features' | 'play-health-apps' | 'play-production-countries' | 'play-production-release';
|
||||
/** `console plan <form> --json` (cli/lib/console-plan.ts ConsolePlan), the parts shown here. */
|
||||
export interface Plan {
|
||||
form: PlanForm; store: 'apple' | 'play'; title: string;
|
||||
start: { url: string; deepLink: boolean; reach: string[] };
|
||||
steps: { n: number; text: string; click?: string[] }[];
|
||||
answers: { id: string; question: string; text: string; status: string; label?: string; note?: string }[];
|
||||
askOwner: string[];
|
||||
buttons: { neverPress: string[]; final: string };
|
||||
}
|
||||
/** `console verify <form> --json` (cli/lib/console-verify.ts VerifyResult). */
|
||||
export interface VerifyResult { form: PlanForm; rows: { item: string; expected: string; seen: string; status: string }[]; mismatches: number; matched: number; ok: boolean; notes: string[] }
|
||||
/** Every form `console plan` knows, with its name and one line (cli/mcp-tools.ts CONSOLE_PLAN_FORMS). */
|
||||
export const PLAN_FORMS: { form: PlanForm; store: 'apple' | 'play'; name: Key; text: Key }[] = [
|
||||
{ form: 'apple-app-record', store: 'apple', name: 'cf.appleAppRecord', text: 'cp.appleAppRecordText' },
|
||||
{ form: 'apple-age-rating', store: 'apple', name: 'cf.appleAgeRating', text: 'cp.appleAgeRatingText' },
|
||||
{ form: 'apple-app-privacy', store: 'apple', name: 'cf.appleAppPrivacy', text: 'cp.appleAppPrivacyText' },
|
||||
{ form: 'play-app-record', store: 'play', name: 'cf.playAppRecord', text: 'cf.playAppRecordText' },
|
||||
{ form: 'play-store-settings', store: 'play', name: 'cp.playStoreSettings', text: 'cp.playStoreSettingsText' },
|
||||
{ form: 'play-app-content', store: 'play', name: 'cp.playAppContent', text: 'cp.playAppContentText' },
|
||||
{ form: 'play-privacy-policy', store: 'play', name: 'cp.playPrivacyPolicy', text: 'cp.cardText' },
|
||||
{ form: 'play-ads', store: 'play', name: 'cp.playAds', text: 'cp.cardText' },
|
||||
{ form: 'play-sign-in-details', store: 'play', name: 'cp.playSignIn', text: 'cp.playSignInText' },
|
||||
{ form: 'play-advertising-id', store: 'play', name: 'cp.playAdId', text: 'cp.cardText' },
|
||||
{ form: 'play-government-apps', store: 'play', name: 'cp.playGovernment', text: 'cp.cardText' },
|
||||
{ form: 'play-financial-features', store: 'play', name: 'cp.playFinancial', text: 'cp.cardText' },
|
||||
{ form: 'play-health-apps', store: 'play', name: 'cp.playHealth', text: 'cp.cardText' },
|
||||
{ form: 'play-content-rating', store: 'play', name: 'cf.playContentRating', text: 'cf.playContentRatingText' },
|
||||
{ form: 'play-target-audience', store: 'play', name: 'cf.playTargetAudience', text: 'cf.playTargetAudienceText' },
|
||||
{ form: 'play-production-countries', store: 'play', name: 'cp.playCountries', text: 'cp.playCountriesText' },
|
||||
{ form: 'play-production-release', store: 'play', name: 'cp.playProductionRelease', text: 'cp.playProductionReleaseText' },
|
||||
];
|
||||
const nameOf = (form: PlanForm): string => t(PLAN_FORMS.find(f => f.form === form)?.name ?? 'cp.title');
|
||||
/** Forms whose result `console verify` reads from the App Store Connect API (no page text needed). */
|
||||
const API_VERIFY = new Set<PlanForm>(['apple-app-record', 'apple-age-rating']);
|
||||
const STATUS: Record<string, ['ok' | 'warn' | 'info' | 'bad', Key]> = { certain: ['ok', 'cp.sCertain'], 'ask-owner': ['warn', 'cp.sAsk'], 'owner-check': ['info', 'cp.sCheck'], agreement: ['bad', 'cp.sAgreement'] };
|
||||
const ROW: Record<string, ['ok' | 'warn' | 'bad', Key]> = { match: ['ok', 'cp.vMatch'], mismatch: ['bad', 'cp.vMismatch'], 'not-found': ['warn', 'cp.vNotFound'], 'ask-owner': ['warn', 'cp.sAsk'] };
|
||||
|
||||
export function ConsolePlanButton({ project, form, primary, label }: { project: Project; form: PlanForm; primary?: boolean; label?: string }) {
|
||||
const d = useData();
|
||||
const [plan, setPlan] = useState<Plan | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const open = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await api.call<ToolResult & { data?: Plan }>('console.plan', project.id, { args: { form } }, newJob('console.plan'));
|
||||
if (!r.ok || !r.data) d.toast({ kind: 'bad', title: t('cp.failed', { form: nameOf(form) }), detail: r.output.trim().split('\n').at(-1) });
|
||||
else setPlan(r.data);
|
||||
} catch (e) { d.toast({ kind: 'bad', title: t('cp.failed', { form: nameOf(form) }), detail: errorText(e) }); } finally { setBusy(false); }
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<button className={`btn btn-sm${primary ? ' btn-primary' : ''}`} onClick={() => { open(); }} disabled={busy} aria-busy={busy || undefined} title={t('cp.hint', { form: nameOf(form) })}>
|
||||
<Icon name={busy ? 'refresh' : 'clipboard'} className={busy ? 'spin' : undefined} />{label ?? t('cp.button')}
|
||||
</button>
|
||||
{plan && <PlanPanel project={project} plan={plan} onClose={() => setPlan(null)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanPanel({ project, plan, onClose }: { project: Project; plan: Plan; onClose: () => void }) {
|
||||
const d = useData();
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const [text, setText] = useState('');
|
||||
const [verify, setVerify] = useState<{ busy: boolean; result?: VerifyResult; error?: string }>({ busy: false });
|
||||
useEffect(() => { root.current?.focus(); }, []);
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); onClose(); } };
|
||||
const apiVerify = API_VERIFY.has(plan.form);
|
||||
const check = async () => {
|
||||
setVerify({ busy: true });
|
||||
try {
|
||||
const r = await api.call<ToolResult & { data?: VerifyResult }>('console.verify', project.id, { args: { form: plan.form, ...(text.trim() ? { text: text.trim() } : {}) } }, newJob('console.verify'));
|
||||
if (r.data) setVerify({ busy: false, result: r.data });
|
||||
else setVerify({ busy: false, error: r.output.trim().split('\n').at(-1)?.replace(/^\S+ \[[^\]]+\] /, '') ?? t('common.exit', { code: r.exitCode }) });
|
||||
} catch (e) { setVerify({ busy: false, error: errorText(e) }); }
|
||||
};
|
||||
const copy = () => { navigator.clipboard?.writeText(JSON.stringify(plan, null, 2)).then(() => d.toast({ kind: 'ok', title: t('cp.copied') }), () => {}); };
|
||||
const asks = plan.answers.filter(a => a.status !== 'certain');
|
||||
return (
|
||||
<div className="scrim lightbox" onMouseDown={e => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<div className="lb cf-panel cp-panel" role="dialog" aria-modal="true" aria-labelledby="cp-title" tabIndex={-1} ref={root} onKeyDown={onKey}>
|
||||
<div className="cf-body stack">
|
||||
<div className="stack" style={{ gap: 4 }}>
|
||||
<h2 id="cp-title" className="row" style={{ gap: 8 }}><Icon name="clipboard" />{plan.title}</h2>
|
||||
<span className="small muted row" style={{ gap: 6 }}><StoreGlyph store={plan.store === 'apple' ? 'ios' : 'android'} className="ic13" />{t('cp.agentFirst')}</span>
|
||||
</div>
|
||||
<Notice tone="info" icon="globe" action={<button className="btn btn-sm" onClick={() => api.openExternal(plan.start.url).catch(e => d.toast({ kind: 'bad', title: t('cp.openFailed'), detail: errorText(e) }))}><Icon name="external" />{t('cp.openPage')}</button>}>
|
||||
<div className="stack" style={{ gap: 2 }}>
|
||||
<span className="small selectable mono-id">{plan.start.url}</span>
|
||||
{plan.start.reach.length > 0 && <span className="small">{plan.start.reach.join(' > ')}</span>}
|
||||
</div>
|
||||
</Notice>
|
||||
<section className="stack" style={{ gap: 6 }} aria-labelledby="cp-steps">
|
||||
<b id="cp-steps" className="small">{t('cp.steps', { count: plan.steps.length })}</b>
|
||||
<ol className="cp-steps">
|
||||
{plan.steps.map(s => (
|
||||
<li key={s.n}><span className="selectable">{s.text}</span>{s.click && s.click.length > 0 && <span className="chips">{s.click.map((c, i) => <Chip key={i} tone="outline" dot={false}>{c}</Chip>)}</span>}</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
{plan.answers.length > 0 && (
|
||||
<section className="stack" style={{ gap: 6 }}>
|
||||
<div className="row between small"><b>{t('cp.answers', { count: plan.answers.length })}</b>{asks.length > 0 && <Chip tone="warn" icon="question">{t('cf.uncertain', { count: asks.length })}</Chip>}</div>
|
||||
<table className="table cf-table">
|
||||
<tbody>
|
||||
{plan.answers.map(a => {
|
||||
const [tone, key] = STATUS[a.status] ?? STATUS['ask-owner']!;
|
||||
return (
|
||||
<tr key={a.id}>
|
||||
<td><div className="strong">{a.label ?? a.question}</div>{a.note && <div className="small muted">{a.note}</div>}</td>
|
||||
<td>{a.status === 'certain' ? <span className="selectable">{a.text}</span> : <span className="small">{a.text}</span>}</td>
|
||||
<td><Chip tone={tone}>{t(key)}</Chip></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
)}
|
||||
<Notice tone="warn" icon="shield">
|
||||
<div className="stack" style={{ gap: 4 }}>
|
||||
<span>{t('cp.final', { button: plan.buttons.final || '—' })}</span>
|
||||
<span className="chips">{plan.buttons.neverPress.slice(0, 14).map(b => <Chip key={b} tone="bad" dot={false}>{b}</Chip>)}</span>
|
||||
</div>
|
||||
</Notice>
|
||||
<section className="stack" style={{ gap: 6 }} aria-labelledby="cp-verify">
|
||||
<b id="cp-verify" className="small">{t('cp.verify')}</b>
|
||||
<span className="small muted">{apiVerify ? t('cp.verifyApi') : t('cp.verifyText')}</span>
|
||||
{!apiVerify && <textarea className="input" rows={4} value={text} onChange={e => setText(e.target.value)} aria-label={t('cp.pasteLabel')} placeholder={t('cp.pastePlaceholder')} />}
|
||||
{verify.error && <Notice tone="bad">{verify.error}</Notice>}
|
||||
{verify.result && (
|
||||
<>
|
||||
<Notice tone={verify.result.ok ? 'ok' : 'warn'}>{t('cp.verified', { matched: verify.result.matched, mismatches: verify.result.mismatches })}</Notice>
|
||||
<table className="table cf-table">
|
||||
<tbody>
|
||||
{verify.result.rows.map((r, i) => {
|
||||
const [tone, key] = ROW[r.status] ?? ROW['not-found']!;
|
||||
return <tr key={i}><td className="strong">{r.item}</td><td className="small">{r.expected}</td><td className="small muted">{r.seen}</td><td><Chip tone={tone}>{t(key)}</Chip></td></tr>;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
<div className="lb-foot">
|
||||
<div className="grow small muted">{t('cp.foot')}</div>
|
||||
<button className="btn btn-sm btn-ghost" onClick={copy}><Icon name="copy" />{t('cp.copy')}</button>
|
||||
<button className="btn btn-sm" onClick={() => { check(); }} disabled={verify.busy || (!apiVerify && !text.trim())} aria-busy={verify.busy || undefined}>
|
||||
<Icon name={verify.busy ? 'refresh' : 'check'} className={verify.busy ? 'spin' : undefined} />{t('cp.verifyRun')}
|
||||
</button>
|
||||
<button className="btn btn-sm btn-primary" onClick={onClose}>{t('common.close')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
143
apps/desktop/src/ui/OwnerAction.tsx
Normal file
143
apps/desktop/src/ui/OwnerAction.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
// An owner action with its plan in the window first: the button runs the dry run (the host forces dryRun for every
|
||||
// call from here), this panel shows the plan table and what blocks it, then "Continue" goes through
|
||||
// write_with_confirm: the host runs the dry run again, shows it in a native dialog and mints the confirm token only on
|
||||
// the dialog's own button. While the real run goes (a submit retries up to 10 minutes while Apple says "not ready
|
||||
// yet"), its progress lines stream into the panel; Cancel stops the command. Used for submit for review, resubmit,
|
||||
// in-app purchases to review, the Play production release and price changes of existing products.
|
||||
import { type KeyboardEvent, type ReactNode, useEffect, useRef, useState } from 'react';
|
||||
import { api, errorText, newJob, onProgress, type Project, type ToolResult } from '../api';
|
||||
import { t } from '../i18n';
|
||||
import { planBlockers } from '../lib/submit';
|
||||
import { useData } from '../store';
|
||||
import { Icon, type IconName } from './icons';
|
||||
import { Notice } from './kit';
|
||||
|
||||
type Stage =
|
||||
| { s: 'idle' }
|
||||
| { s: 'dry' }
|
||||
| { s: 'plan'; dry: ToolResult; note?: string }
|
||||
| { s: 'running'; dry: ToolResult; job: string; lines: string[] }
|
||||
| { s: 'done'; dry: ToolResult; ok: boolean; declined: boolean; lines: string[]; detail: string };
|
||||
|
||||
/** Exit code of a command that stopped because the owner's answer was missing (lib/ctx declined()). */
|
||||
export const DECLINED_EXIT = 3;
|
||||
const clean = (out: string): string => out.split('\n').filter(l => l.trim()).map(l => l.replace(/^\d\d:\d\d:\d\d \[[^\]]+\] /, '')).join('\n');
|
||||
const tail = (out: string): string => clean(out).split('\n').at(-1) ?? '';
|
||||
|
||||
export interface OwnerActionProps {
|
||||
project: Project;
|
||||
method: string;
|
||||
args: Record<string, unknown>;
|
||||
/** Button and panel title. */
|
||||
label: string;
|
||||
icon: IconName;
|
||||
/** Toast and panel line when it went through. */
|
||||
done: string;
|
||||
/** What happens, in plain words, above the plan. */
|
||||
lead?: ReactNode;
|
||||
primary?: boolean;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
hint?: string;
|
||||
after?: () => void;
|
||||
}
|
||||
|
||||
export function OwnerAction(p: OwnerActionProps) {
|
||||
const d = useData();
|
||||
const [st, setSt] = useState<Stage>({ s: 'idle' });
|
||||
const plan = async () => {
|
||||
setSt({ s: 'dry' });
|
||||
try {
|
||||
const r = await api.call<ToolResult>(p.method, p.project.id, { args: p.args }, newJob(`${p.method}-plan`));
|
||||
setSt({ s: 'plan', dry: r });
|
||||
} catch (e) {
|
||||
setSt({ s: 'idle' });
|
||||
d.toast({ kind: 'bad', title: t('oa.planFailed', { what: p.label }), detail: errorText(e) });
|
||||
}
|
||||
};
|
||||
const go = async (dry: ToolResult) => {
|
||||
const job = newJob(p.method);
|
||||
setSt({ s: 'running', dry, job, lines: [] });
|
||||
const lines: string[] = [];
|
||||
const un = onProgress(e => {
|
||||
if (e.job !== job || !e.line?.trim()) return;
|
||||
lines.push(e.line);
|
||||
setSt(x => (x.s === 'running' && x.job === job ? { ...x, lines: lines.slice(-200) } : x));
|
||||
});
|
||||
try {
|
||||
const o = await api.writeWithConfirm(p.method, p.project.id, p.args, job);
|
||||
if (o.stage === 'dialog') setSt({ s: 'plan', dry: o.dryRun, note: t('oa.notSent') });
|
||||
else if (o.stage === 'dry-run') setSt({ s: 'plan', dry: o.dryRun, note: t('oa.dryChanged') });
|
||||
else {
|
||||
const r = o.result!;
|
||||
const declined = r.exitCode === DECLINED_EXIT;
|
||||
setSt({ s: 'done', dry, ok: r.ok, declined, lines: lines.slice(-200), detail: tail(r.output) });
|
||||
d.toast({ kind: r.ok ? 'ok' : declined ? 'info' : 'bad', title: r.ok ? p.done : declined ? t('write.declined') : t('write.failed'), detail: tail(r.output) || undefined });
|
||||
if (r.ok) p.after?.();
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = errorText(e);
|
||||
setSt({ s: 'done', dry, ok: false, declined: false, lines: lines.slice(-200), detail: /cancel/i.test(msg) ? t('oa.cancelled') : msg });
|
||||
} finally { un.then(f => f()); }
|
||||
};
|
||||
const busy = st.s === 'dry';
|
||||
return (
|
||||
<>
|
||||
<button className={`btn btn-sm${p.primary ? ' btn-primary' : ''}${p.danger ? ' btn-danger' : ''}`} disabled={p.disabled || busy || st.s === 'running'} aria-busy={busy || undefined} title={p.hint}
|
||||
onClick={() => { plan(); }}>
|
||||
<Icon name={busy ? 'refresh' : p.icon} className={busy ? 'spin' : undefined} />{p.label}
|
||||
</button>
|
||||
{st.s !== 'idle' && st.s !== 'dry' && (
|
||||
<PlanPanel st={st} p={p} onGo={() => { go(st.dry); }} onCancel={() => { if (st.s === 'running') api.cancel(st.job).catch(() => {}); }} onClose={() => setSt({ s: 'idle' })} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanPanel({ st, p, onGo, onCancel, onClose }: {
|
||||
st: Extract<Stage, { s: 'plan' | 'running' | 'done' }>; p: OwnerActionProps; onGo: () => void; onCancel: () => void; onClose: () => void;
|
||||
}) {
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const logRef = useRef<HTMLPreElement>(null);
|
||||
const running = st.s === 'running';
|
||||
// focus the panel when it opens and whenever a stage ends (the button that had focus may be gone), so Escape and Tab
|
||||
// keep working inside it
|
||||
useEffect(() => { if (!running && !root.current?.contains(document.activeElement)) root.current?.focus(); }, [st.s, running]);
|
||||
const lines = st.s === 'running' || st.s === 'done' ? st.lines : [];
|
||||
useEffect(() => { if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight; }, [lines.length]);
|
||||
const blockers = planBlockers(st.dry.data);
|
||||
const dryOk = st.dry.ok && !blockers.length;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && !running) { e.preventDefault(); onClose(); } };
|
||||
return (
|
||||
<div className="scrim lightbox" onMouseDown={e => { if (e.target === e.currentTarget && !running) onClose(); }}>
|
||||
<div className="lb cf-panel oa-panel" role="dialog" aria-modal="true" aria-labelledby="oa-title" tabIndex={-1} ref={root} onKeyDown={onKey}>
|
||||
<div className="cf-body stack">
|
||||
<div className="stack" style={{ gap: 4 }}>
|
||||
<h2 id="oa-title" className="row" style={{ gap: 8 }}><Icon name={p.icon} />{p.label}</h2>
|
||||
<code className="small muted selectable">{st.dry.command}</code>
|
||||
</div>
|
||||
{p.lead && <Notice tone="info" icon="shield">{p.lead}</Notice>}
|
||||
{st.s === 'plan' && st.note && <Notice tone="info">{st.note}</Notice>}
|
||||
{!st.dry.ok && <Notice tone="bad" icon="alertCircle">{t('oa.dryBad')}{tail(st.dry.output) ? `: ${tail(st.dry.output)}` : ''}</Notice>}
|
||||
{blockers.map((b, i) => <Notice key={i} tone="bad" icon="alertCircle">{b}</Notice>)}
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<b className="small">{t('oa.plan')}</b>
|
||||
<pre className="log selectable plan-table" aria-label={t('oa.plan')}>{clean(st.dry.output) || t('oa.noOutput')}</pre>
|
||||
</div>
|
||||
{running && <Notice tone="info" icon="clock">{t('oa.running')}</Notice>}
|
||||
{st.s === 'done' && (st.ok ? <Notice tone="ok" icon="check">{p.done}{st.detail ? `: ${st.detail}` : ''}</Notice>
|
||||
: st.declined ? <Notice tone="warn" icon="alert">{t('write.declined')}{st.detail ? `: ${st.detail}` : ''}</Notice>
|
||||
: <Notice tone="bad" icon="alertCircle">{t('write.failed')}{st.detail ? `: ${st.detail}` : ''}</Notice>)}
|
||||
{lines.length > 0 && <pre className="log" ref={logRef} aria-live="polite" aria-label={t('oa.progress')}>{lines.map(l => l.replace(/^\d\d:\d\d:\d\d /, '')).join('\n')}</pre>}
|
||||
</div>
|
||||
<div className="lb-foot">
|
||||
<div className="grow small muted">{st.s === 'plan' ? (dryOk ? t('oa.foot') : t('oa.fixFirst')) : null}</div>
|
||||
{running ? <button className="btn btn-sm btn-danger" onClick={onCancel}><Icon name="x" />{t('oa.stop')}</button>
|
||||
: <button className="btn btn-sm btn-ghost" onClick={onClose}>{st.s === 'done' ? t('common.close') : t('common.cancel')}</button>}
|
||||
{st.s === 'plan' && <button className={`btn btn-sm ${p.danger ? 'btn-danger' : 'btn-primary'}`} disabled={!dryOk} onClick={onGo}><Icon name={p.icon} />{t('oa.continue', { what: p.label })}</button>}
|
||||
{running && <span className="small muted row" style={{ gap: 6 }}><Icon name="refresh" className="spin ic13" />{t('oa.working')}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -29,6 +29,12 @@ export function Toasts() {
|
|||
<div className="c"><span>{big ? `${bytes(p.done)} / ${bytes(p.total)}` : `${num(p.done)} / ${num(p.total)}`}</span><span>{Math.round((p.done / Math.max(1, p.total)) * 100)}%</span></div>
|
||||
</div>
|
||||
) : <div className="bar indet"><span /></div>}
|
||||
{x.lines && x.lines.length > 1 && (
|
||||
<details className="toast-log">
|
||||
<summary className="small muted">{t('toast.log', { count: x.lines.length })}</summary>
|
||||
<pre className="log">{x.lines.join('\n')}</pre>
|
||||
</details>
|
||||
)}
|
||||
{x.job && <div className="toast-actions"><button className="btn btn-sm" onClick={() => d.cancelJob(x.job!)}>{t('common.cancel')}</button></div>}
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue