mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-25 01:37:05 +00:00
feat(stacks): server-side POST /api/stacks/bulk endpoint (#1185)
* feat(stacks): server-side POST /api/stacks/bulk
The frontend's bulk action UI fanned out N parallel POSTs to
/api/stacks/:name/{start,stop,restart,update}. For 30 stacks on a
remote node that was 30 round-trips through the proxy + auth + audit
chain, with no shared mutex and partial-failure UX bolted on the
client.
The new endpoint accepts {action, stackNames} (max 100 names), runs
ops under bounded parallelism (4 concurrent), reuses the per-(nodeId,
stackName) lock from the lifecycle-mutex change so collisions report
stack_op_in_progress as a per-row outcome, and returns
{action, results: [{stackName, ok, error?, code?}, ...]} with a 200
envelope. Per-stack errors are rows, not response codes.
Update action keeps the policy-enforcement check (per-stack, returns
policy_blocked rows) and the post-deploy scan trigger so the
single-stack security contract is preserved. State-invalidate and
image-update notifications fire per successful row so the activity
timeline and image-updates UI reflect bulk operations the same way
as single-stack ones.
Frontend useBulkStackActions swaps the Promise.allSettled fan-out for
a single call; the per-stack toast aggregation moves to reading the
results array. isPaid pre-flight stays in place to avoid a round trip
for Community-tier users on update.
* chore(stacks): dedupe bulk inputs; document tier asymmetry; regression test
Three follow-ups from independent review:
- Dedupe stackNames before scheduling so a payload like ['web','web']
produces one row, not one ok-row plus one stack_op_in_progress row
whose presence depended on worker scheduling.
- Add a route-ordering regression test verifying that a stack literally
named 'bulk' is still reachable via /api/stacks/bulk/restart. Express
matches the literal /bulk before /:stackName paths only at the
no-suffix level; the :stackName/restart route still catches it.
- Comment the deliberate tier asymmetry: bulk update is requirePaid;
single-stack /:stackName/update is open to all tiers. The fan-out
blast radius is the reason, and it matches the prior frontend gate.
Existing 'policy_blocked per-row' test now uses the real ScanPolicy /
PolicyViolation / PolicyEnforcementResult shapes (the first cut elided
fields tsc strict-checked).
This commit is contained in:
@@ -17,6 +17,18 @@ interface BulkCallbacks {
|
||||
onAfter?: (files: string[]) => void;
|
||||
}
|
||||
|
||||
interface BulkResultItem {
|
||||
stackName: string;
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
interface BulkResponse {
|
||||
action: BulkAction;
|
||||
results: BulkResultItem[];
|
||||
}
|
||||
|
||||
export function useBulkStackActions() {
|
||||
const { isPaid } = useLicense();
|
||||
|
||||
@@ -33,32 +45,43 @@ export function useBulkStackActions() {
|
||||
|
||||
cbs?.onBefore?.(files);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
files.map(file => {
|
||||
const stackName = file.replace(/\.(yml|yaml)$/, '');
|
||||
const headers: Record<string, string> = action === 'update' ? { 'x-bulk-mode': '1' } : {};
|
||||
return apiFetch(`/stacks/${encodeURIComponent(stackName)}/${action}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
}).then(res => {
|
||||
if (!res.ok) return Promise.reject(new Error(file));
|
||||
return file;
|
||||
});
|
||||
})
|
||||
);
|
||||
const stackNames = files.map(file => file.replace(/\.(yml|yaml)$/, ''));
|
||||
|
||||
cbs?.onAfter?.(files);
|
||||
try {
|
||||
const response = await apiFetch('/stacks/bulk', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action, stackNames }),
|
||||
});
|
||||
|
||||
const failed = results
|
||||
.filter((r): r is PromiseRejectedResult => r.status === 'rejected')
|
||||
.map(r => (r.reason as Error).message);
|
||||
const okCount = results.length - failed.length;
|
||||
cbs?.onAfter?.(files);
|
||||
|
||||
if (failed.length === 0) {
|
||||
const noun = okCount === 1 ? 'stack' : 'stacks';
|
||||
toast.success(`${okCount} ${noun} ${pastTense[action]}`);
|
||||
} else {
|
||||
toast.error(`${okCount} of ${files.length} ${pastTense[action]}; ${failed.length} failed: ${failed.join(', ')}`);
|
||||
if (!response.ok) {
|
||||
const errBody = await response.json().catch(() => ({}));
|
||||
const errMsg = (errBody as { error?: string })?.error
|
||||
?? `Bulk ${action} failed (HTTP ${response.status})`;
|
||||
toast.error(errMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as BulkResponse;
|
||||
const results = Array.isArray(payload.results) ? payload.results : [];
|
||||
const okCount = results.filter(r => r.ok).length;
|
||||
const failed = results.filter(r => !r.ok);
|
||||
|
||||
if (failed.length === 0) {
|
||||
const noun = okCount === 1 ? 'stack' : 'stacks';
|
||||
toast.success(`${okCount} ${noun} ${pastTense[action]}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const failedNames = failed.map(r => r.stackName).join(', ');
|
||||
toast.error(
|
||||
`${okCount} of ${results.length} ${pastTense[action]}; ${failed.length} failed: ${failedNames}`,
|
||||
);
|
||||
} catch (err) {
|
||||
cbs?.onAfter?.(files);
|
||||
console.error('Bulk action failed:', err);
|
||||
toast.error(`Bulk ${action} failed: ${(err as Error).message}`);
|
||||
}
|
||||
}, [isPaid]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user