feat(web): deploy wizard polls async lifecycle instead of holding HTTP
- New useLifecyclePolling(ids, intervalMs) hook: polls GET /deckies/lifecycle?ids=... every 2s until every row is terminal, surfaces transient HTTP failures without giving up. - DeployWizard: drops the 180s axios timeout and the fake-log-driven deployOk flag. After POST 202, sets lifecycle_ids -> the hook drives the per-decky pill grid (PENDING / RUNNING / SUCCEEDED / FAILED). Real terminal lines stream into the log as rows resolve. Auto-close on all-success after 700ms. - DeckyFleet.css: .lifecycle-grid + .lifecycle-pill in the existing fleet vocabulary; running pill pulses, failed pill borders alert. - Existing 4 wizard render tests still pass; 4 new hook tests cover empty ids / single-success / polling-until-terminal / HTTP error.
This commit is contained in:
@@ -462,6 +462,51 @@
|
|||||||
}
|
}
|
||||||
.fleet-empty .dim { opacity: 0.5; }
|
.fleet-empty .dim { opacity: 0.5; }
|
||||||
|
|
||||||
|
/* Per-decky lifecycle pills (deploy wizard, /deckies/{name}/mutate) */
|
||||||
|
.lifecycle-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.lifecycle-pill {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
.lifecycle-pill .lifecycle-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.lifecycle-pill .lifecycle-status {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
}
|
||||||
|
.lifecycle-pill.lifecycle-pending {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.lifecycle-pill.lifecycle-running {
|
||||||
|
border-color: var(--matrix);
|
||||||
|
color: var(--matrix);
|
||||||
|
animation: dfleet-pulse 1.2s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
.lifecycle-pill.lifecycle-succeeded {
|
||||||
|
border-color: var(--matrix);
|
||||||
|
color: var(--matrix);
|
||||||
|
}
|
||||||
|
.lifecycle-pill.lifecycle-failed {
|
||||||
|
border-color: var(--alert);
|
||||||
|
color: var(--alert);
|
||||||
|
}
|
||||||
|
|
||||||
/* Animations */
|
/* Animations */
|
||||||
@keyframes dfleet-pulse { from { opacity: 0.5; } to { opacity: 1; } }
|
@keyframes dfleet-pulse { from { opacity: 0.5; } to { opacity: 1; } }
|
||||||
@keyframes dfleet-blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
|
@keyframes dfleet-blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { PlusCircle } from '../../icons';
|
import { PlusCircle } from '../../icons';
|
||||||
|
import { useLifecyclePolling } from '../../hooks/useLifecyclePolling';
|
||||||
import api from '../../utils/api';
|
import api from '../../utils/api';
|
||||||
import Modal from '../Modal/Modal';
|
import Modal from '../Modal/Modal';
|
||||||
import { DEFAULT_SERVICES } from '../MazeNET/data';
|
import { DEFAULT_SERVICES } from '../MazeNET/data';
|
||||||
@@ -129,6 +130,8 @@ export const DeployWizard: React.FC<Props> = ({
|
|||||||
setServiceConfigs({});
|
setServiceConfigs({});
|
||||||
setServiceSchemas({});
|
setServiceSchemas({});
|
||||||
setOpenSvcCfg(null);
|
setOpenSvcCfg(null);
|
||||||
|
setLifecycleIds([]);
|
||||||
|
setLoggedTerminals(new Set());
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const effectiveArchetypeName = archetype?.name
|
const effectiveArchetypeName = archetype?.name
|
||||||
@@ -163,28 +166,45 @@ export const DeployWizard: React.FC<Props> = ({
|
|||||||
return out;
|
return out;
|
||||||
}, [count, prefix, fleetSize, effectiveArchetypeName, effectiveServices]);
|
}, [count, prefix, fleetSize, effectiveArchetypeName, effectiveServices]);
|
||||||
|
|
||||||
const [deployOk, setDeployOk] = useState(false);
|
// 202 returns the per-decky lifecycle row ids; the polling hook flips
|
||||||
const [deployFailures, setDeployFailures] = useState<string[]>([]);
|
// them through pending -> running -> succeeded|failed. Empty array
|
||||||
|
// disables the hook (idle / not yet POSTed).
|
||||||
|
const [lifecycleIds, setLifecycleIds] = useState<string[]>([]);
|
||||||
|
const { rows: lifecycleRows, done: lifecycleDone, error: lifecycleErr } =
|
||||||
|
useLifecyclePolling(lifecycleIds);
|
||||||
|
|
||||||
// Fake log stream during "deploying" (runs as visual backdrop; real API
|
// Atmospheric backdrop (one-shot, decoupled from real progress now
|
||||||
// lines are spliced in by startDeploy once the HTTP call resolves).
|
// that the lifecycle rows carry truth). Runs once when DEPLOYING
|
||||||
|
// begins so the operator sees activity before the first poll lands.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (step !== 3 || !deploying) return;
|
if (step !== 3 || !deploying || lifecycleIds.length === 0) return;
|
||||||
const msgs = PLACEHOLDER_LINES(effectiveArchetypeName, effectiveServices, count, fleetSize);
|
const msgs = PLACEHOLDER_LINES(effectiveArchetypeName, effectiveServices, count, fleetSize);
|
||||||
let i = 0;
|
let i = 0;
|
||||||
const t = window.setInterval(() => {
|
const t = window.setInterval(() => {
|
||||||
setLog((prev) => [...prev, msgs[i]]);
|
setLog((prev) => [...prev, msgs[i]]);
|
||||||
i++;
|
i++;
|
||||||
if (i >= msgs.length) {
|
if (i >= msgs.length) window.clearInterval(t);
|
||||||
window.clearInterval(t);
|
|
||||||
// Only auto-close if the server accepted.
|
|
||||||
if (deployOk) {
|
|
||||||
window.setTimeout(() => onComplete(count), 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 420);
|
}, 420);
|
||||||
return () => window.clearInterval(t);
|
return () => window.clearInterval(t);
|
||||||
}, [step, deploying, effectiveArchetypeName, effectiveServices, count, fleetSize, onComplete, deployOk]);
|
}, [step, deploying, lifecycleIds.length, effectiveArchetypeName, effectiveServices, count, fleetSize]);
|
||||||
|
|
||||||
|
const deployFailures = useMemo(() =>
|
||||||
|
lifecycleRows
|
||||||
|
.filter((r) => r.status === 'failed')
|
||||||
|
.map((r) => `[FAIL] ${r.decky_name}: ${r.error ?? 'unknown error'}`),
|
||||||
|
[lifecycleRows],
|
||||||
|
);
|
||||||
|
const deployOk = lifecycleDone && deployFailures.length === 0;
|
||||||
|
|
||||||
|
// When every row reaches terminal status, auto-close on full success
|
||||||
|
// (or stay open so the operator can read failures).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lifecycleDone) return;
|
||||||
|
if (deployFailures.length === 0) {
|
||||||
|
const t = window.setTimeout(() => onComplete(count), 700);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}
|
||||||
|
}, [lifecycleDone, deployFailures.length, count, onComplete]);
|
||||||
|
|
||||||
const canNext = step === 0
|
const canNext = step === 0
|
||||||
? (pickMode === 'archetype' ? !!archetype : selectedServices.length > 0)
|
? (pickMode === 'archetype' ? !!archetype : selectedServices.length > 0)
|
||||||
@@ -193,8 +213,7 @@ export const DeployWizard: React.FC<Props> = ({
|
|||||||
const startDeploy = async () => {
|
const startDeploy = async () => {
|
||||||
setDeployErr(null);
|
setDeployErr(null);
|
||||||
setLog([]);
|
setLog([]);
|
||||||
setDeployOk(false);
|
setLifecycleIds([]);
|
||||||
setDeployFailures([]);
|
|
||||||
setDeploying(true);
|
setDeploying(true);
|
||||||
// Roll the per-service forms into the compact payload the server
|
// Roll the per-service forms into the compact payload the server
|
||||||
// expects — empty values dropped, types coerced where the schema
|
// expects — empty values dropped, types coerced where the schema
|
||||||
@@ -215,20 +234,13 @@ export const DeployWizard: React.FC<Props> = ({
|
|||||||
mutate, mutateEvery, rolled, serviceSchemas,
|
mutate, mutateEvery, rolled, serviceSchemas,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
const res = await api.post<{ failures?: { name: string; reason: string }[] }>(
|
const res = await api.post<{ lifecycle_ids?: string[]; message?: string; mode?: string }>(
|
||||||
'/deckies/deploy',
|
'/deckies/deploy',
|
||||||
{ ini_content: ini },
|
{ ini_content: ini },
|
||||||
{ timeout: 180000 },
|
|
||||||
);
|
);
|
||||||
const failures = res.data?.failures ?? [];
|
const ids = res.data?.lifecycle_ids ?? [];
|
||||||
setDeployFailures(failures.map(f => `[FAIL] ${f.name}: ${f.reason}`));
|
setLifecycleIds(ids);
|
||||||
if (failures.length > 0) {
|
setLog((prev) => [...prev, `[ACK] server accepted ${ids.length} decky/ies — tracking...`]);
|
||||||
setLog(prev => [...prev, `[OK] server accepted ${count - failures.length}/${count}`,
|
|
||||||
...failures.map(f => `[FAIL] ${f.name}: ${f.reason}`)]);
|
|
||||||
} else {
|
|
||||||
setLog(prev => [...prev, `[OK] server accepted ${count} deckies`]);
|
|
||||||
}
|
|
||||||
setDeployOk(true);
|
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const err = e as { response?: { data?: { detail?: string } }; message?: string };
|
const err = e as { response?: { data?: { detail?: string } }; message?: string };
|
||||||
setDeployErr(err?.response?.data?.detail || err?.message || 'Deploy failed');
|
setDeployErr(err?.response?.data?.detail || err?.message || 'Deploy failed');
|
||||||
@@ -236,6 +248,33 @@ export const DeployWizard: React.FC<Props> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Append lifecycle terminal lines to the log as rows resolve, so the
|
||||||
|
// operator gets a running transcript instead of a flicker-replaced
|
||||||
|
// table. De-dupe by id so re-polls don't double-log.
|
||||||
|
const [, setLoggedTerminals] = useState<Set<string>>(new Set());
|
||||||
|
useEffect(() => {
|
||||||
|
setLoggedTerminals((prev) => {
|
||||||
|
let next = prev;
|
||||||
|
const additions: string[] = [];
|
||||||
|
for (const r of lifecycleRows) {
|
||||||
|
if (prev.has(r.id)) continue;
|
||||||
|
if (r.status === 'succeeded') {
|
||||||
|
additions.push(`[OK] ${r.decky_name} deployed`);
|
||||||
|
} else if (r.status === 'failed') {
|
||||||
|
additions.push(`[FAIL] ${r.decky_name}: ${r.error ?? 'unknown error'}`);
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (next === prev) next = new Set(prev);
|
||||||
|
next.add(r.id);
|
||||||
|
}
|
||||||
|
if (additions.length > 0) {
|
||||||
|
setLog((l) => [...l, ...additions]);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [lifecycleRows]);
|
||||||
|
|
||||||
const toggleService = (slug: string) => {
|
const toggleService = (slug: string) => {
|
||||||
setSelectedServices((prev) =>
|
setSelectedServices((prev) =>
|
||||||
prev.includes(slug) ? prev.filter((s) => s !== slug) : [...prev, slug]);
|
prev.includes(slug) ? prev.filter((s) => s !== slug) : [...prev, slug]);
|
||||||
@@ -270,10 +309,10 @@ export const DeployWizard: React.FC<Props> = ({
|
|||||||
{step === 3 && !deploying && (
|
{step === 3 && !deploying && (
|
||||||
<button className="btn violet" onClick={startDeploy}>ESTABLISH FLEET</button>
|
<button className="btn violet" onClick={startDeploy}>ESTABLISH FLEET</button>
|
||||||
)}
|
)}
|
||||||
{step === 3 && deploying && !deployOk && (
|
{step === 3 && deploying && !lifecycleDone && (
|
||||||
<button className="btn" disabled>DEPLOYING...</button>
|
<button className="btn" disabled>DEPLOYING...</button>
|
||||||
)}
|
)}
|
||||||
{step === 3 && deployOk && deployFailures.length > 0 && (
|
{step === 3 && lifecycleDone && deployFailures.length > 0 && (
|
||||||
<button className="btn alert" disabled>{deployFailures.length} FAILED</button>
|
<button className="btn alert" disabled>{deployFailures.length} FAILED</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -485,8 +524,29 @@ export const DeployWizard: React.FC<Props> = ({
|
|||||||
<div className="type-label">
|
<div className="type-label">
|
||||||
{!deploying
|
{!deploying
|
||||||
? 'Ready to deploy. This will write to the fleet and start the listener.'
|
? 'Ready to deploy. This will write to the fleet and start the listener.'
|
||||||
: 'Deploying...'}
|
: lifecycleDone
|
||||||
|
? (deployFailures.length === 0 ? 'Deployed.' : 'Deploy finished with errors.')
|
||||||
|
: 'Deploying — polling lifecycle...'}
|
||||||
</div>
|
</div>
|
||||||
|
{lifecycleIds.length > 0 && (
|
||||||
|
<div className="lifecycle-grid">
|
||||||
|
{lifecycleRows.map((r) => (
|
||||||
|
<div
|
||||||
|
key={r.id}
|
||||||
|
className={`lifecycle-pill lifecycle-${r.status}`}
|
||||||
|
title={r.error ?? ''}
|
||||||
|
>
|
||||||
|
<span className="lifecycle-name">{r.decky_name}</span>
|
||||||
|
<span className="lifecycle-status">{r.status.toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{lifecycleErr && (
|
||||||
|
<div className="info-banner warn" style={{ marginBottom: 8 }}>
|
||||||
|
Polling: {lifecycleErr} — retrying...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="code-block" style={{ minHeight: 180 }}>
|
<div className="code-block" style={{ minHeight: 180 }}>
|
||||||
{log.length === 0 && !deploying && (
|
{log.length === 0 && !deploying && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
71
decnet_web/src/hooks/useLifecyclePolling.test.ts
Normal file
71
decnet_web/src/hooks/useLifecyclePolling.test.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import { http, HttpResponse, server, apiUrl } from '../test/server';
|
||||||
|
import { useLifecyclePolling, type LifecycleRow } from './useLifecyclePolling';
|
||||||
|
|
||||||
|
const row = (over: Partial<LifecycleRow>): LifecycleRow => ({
|
||||||
|
id: 'lid-1',
|
||||||
|
decky_name: 'decky-01',
|
||||||
|
host_uuid: null,
|
||||||
|
operation: 'deploy',
|
||||||
|
status: 'pending',
|
||||||
|
error: null,
|
||||||
|
started_at: '2026-05-22T00:00:00Z',
|
||||||
|
updated_at: '2026-05-22T00:00:00Z',
|
||||||
|
completed_at: null,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useLifecyclePolling', () => {
|
||||||
|
it('returns no rows and done=false for empty ids', () => {
|
||||||
|
const { result } = renderHook(() => useLifecyclePolling([]));
|
||||||
|
expect(result.current.rows).toEqual([]);
|
||||||
|
expect(result.current.done).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches once and marks done when all rows are terminal', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(apiUrl('/deckies/lifecycle'), () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
rows: [row({ id: 'lid-1', status: 'succeeded', completed_at: 'ts' })],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const { result } = renderHook(() => useLifecyclePolling(['lid-1'], 20));
|
||||||
|
await waitFor(() => expect(result.current.done).toBe(true));
|
||||||
|
expect(result.current.rows).toHaveLength(1);
|
||||||
|
expect(result.current.rows[0].status).toBe('succeeded');
|
||||||
|
expect(result.current.error).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps polling while at least one row is non-terminal', async () => {
|
||||||
|
let hits = 0;
|
||||||
|
server.use(
|
||||||
|
http.get(apiUrl('/deckies/lifecycle'), () => {
|
||||||
|
hits++;
|
||||||
|
return HttpResponse.json({
|
||||||
|
rows: hits < 2
|
||||||
|
? [row({ id: 'lid-1', status: 'running' })]
|
||||||
|
: [row({ id: 'lid-1', status: 'succeeded', completed_at: 'ts' })],
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { result } = renderHook(() => useLifecyclePolling(['lid-1'], 20));
|
||||||
|
await waitFor(() => expect(result.current.done).toBe(true));
|
||||||
|
expect(hits).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces error and keeps retrying on HTTP failure', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(apiUrl('/deckies/lifecycle'), () =>
|
||||||
|
HttpResponse.json({ detail: 'server error' }, { status: 500 }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const { result } = renderHook(() => useLifecyclePolling(['lid-1'], 20));
|
||||||
|
await waitFor(() => expect(result.current.error).not.toBeNull());
|
||||||
|
expect(result.current.done).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
84
decnet_web/src/hooks/useLifecyclePolling.ts
Normal file
84
decnet_web/src/hooks/useLifecyclePolling.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import api from '../utils/api';
|
||||||
|
|
||||||
|
export type LifecycleStatus = 'pending' | 'running' | 'succeeded' | 'failed';
|
||||||
|
|
||||||
|
export interface LifecycleRow {
|
||||||
|
id: string;
|
||||||
|
decky_name: string;
|
||||||
|
host_uuid: string | null;
|
||||||
|
operation: 'deploy' | 'mutate';
|
||||||
|
status: LifecycleStatus;
|
||||||
|
error: string | null;
|
||||||
|
started_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
completed_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TERMINAL = new Set<LifecycleStatus>(['succeeded', 'failed']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll ``GET /deckies/lifecycle?ids=…`` every ``intervalMs`` until every
|
||||||
|
* row reaches a terminal status. Returns the latest rows, a derived
|
||||||
|
* ``done`` flag, and any HTTP failure that surfaced on the last tick.
|
||||||
|
*
|
||||||
|
* Polling stops automatically once all rows are terminal (or when
|
||||||
|
* ``ids`` becomes empty / unmounts). Pass an empty array to disable.
|
||||||
|
*/
|
||||||
|
export function useLifecyclePolling(
|
||||||
|
ids: string[],
|
||||||
|
intervalMs: number = 2000,
|
||||||
|
): { rows: LifecycleRow[]; done: boolean; error: string | null } {
|
||||||
|
const [rows, setRows] = useState<LifecycleRow[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const cancelled = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
cancelled.current = false;
|
||||||
|
if (ids.length === 0) {
|
||||||
|
setRows([]);
|
||||||
|
return () => { cancelled.current = true; };
|
||||||
|
}
|
||||||
|
|
||||||
|
let timer: number | undefined;
|
||||||
|
|
||||||
|
const tick = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<{ rows: LifecycleRow[] }>(
|
||||||
|
'/deckies/lifecycle',
|
||||||
|
// axios encodes array params as repeated ?ids=… when
|
||||||
|
// paramsSerializer isn't overridden — matches FastAPI's expected
|
||||||
|
// shape for List[str] Query params.
|
||||||
|
{ params: { ids }, paramsSerializer: { indexes: null } },
|
||||||
|
);
|
||||||
|
if (cancelled.current) return;
|
||||||
|
const next = data?.rows ?? [];
|
||||||
|
setRows(next);
|
||||||
|
setError(null);
|
||||||
|
const allDone = next.length === ids.length
|
||||||
|
&& next.every((r) => TERMINAL.has(r.status));
|
||||||
|
if (!allDone) {
|
||||||
|
timer = window.setTimeout(tick, intervalMs);
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (cancelled.current) return;
|
||||||
|
const err = e as { message?: string };
|
||||||
|
setError(err?.message || 'Lifecycle poll failed');
|
||||||
|
timer = window.setTimeout(tick, intervalMs);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tick();
|
||||||
|
return () => {
|
||||||
|
cancelled.current = true;
|
||||||
|
if (timer !== undefined) window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [ids.join('|'), intervalMs]);
|
||||||
|
|
||||||
|
const done = ids.length > 0
|
||||||
|
&& rows.length === ids.length
|
||||||
|
&& rows.every((r) => TERMINAL.has(r.status));
|
||||||
|
|
||||||
|
return { rows, done, error };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user