Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions apps/cli/src/commands/fleet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,9 @@ type FleetInvocation = {
timeoutSeconds: number
commandId: string | null
targetFallback: readonly string[]
/** From a saved command when it came from one; a flag always wins. */
concurrency?: number
onFailure?: 'continue' | 'stop'
/**
* The caller already asked about this script's hazards in terms specific to
* what it does. `fleet upgrade --reboot` names the servers it will restart,
Expand Down Expand Up @@ -339,8 +342,8 @@ async function execute(
output: Output,
): Promise<number> {
const targets = await resolveTargets(parsed, store, invocation.targetFallback)
const concurrency = numberFlag(parsed, '--concurrency') ?? FLEET_DEFAULT_CONCURRENCY
const onFailure = hasFlag(parsed, '--stop-on-error') ? 'stop' : 'continue'
const concurrency = numberFlag(parsed, '--concurrency') ?? invocation.concurrency ?? FLEET_DEFAULT_CONCURRENCY
const onFailure = hasFlag(parsed, '--stop-on-error') ? 'stop' : (invocation.onFailure ?? 'continue')
const env = envFromFlags(parsed)
const assumeYes = hasFlag(parsed, '--yes')

Expand Down Expand Up @@ -550,6 +553,8 @@ async function fleetRun(parsed: ParsedArgv, store: DiskPushStore, output: Output
sudo: command.sudo || hasFlag(parsed, '--sudo'),
workingDirectory: flagValue(parsed, '--cwd') ?? command.workingDirectory,
timeoutSeconds: numberFlag(parsed, '--timeout') ?? command.timeoutSeconds,
concurrency: numberFlag(parsed, '--concurrency') ?? command.concurrency,
onFailure: hasFlag(parsed, '--stop-on-error') ? 'stop' : command.onFailure,
commandId: command.builtin ? null : command.id,
targetFallback: command.targets,
},
Expand Down Expand Up @@ -841,6 +846,11 @@ async function fleetCommands(parsed: ParsedArgv, store: DiskPushStore, output: O
sudo: hasFlag(parsed, '--sudo'),
workingDirectory: flags.workingDirectory,
timeoutSeconds: flags.timeoutSeconds,
// Pacing is part of the command, not of the invocation: a saved command
// that forgets it was meant to run two at a time is a saved command that
// still gets run wrong.
concurrency: numberFlag(parsed, '--concurrency') ?? FLEET_DEFAULT_CONCURRENCY,
onFailure: hasFlag(parsed, '--stop-on-error') ? 'stop' : 'continue',
targets: flagValues(parsed, '--on'),
tags: flagValues(parsed, '--tag'),
})
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/electron/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
DeleteEntryRequestSchema,
ExternalUrlSchema,
FleetCheckRequestSchema,
FleetCommandSaveSchema,
FleetListRenameSchema,
FleetListSaveSchema,
FleetRequestSchema,
Expand All @@ -29,6 +30,8 @@ import {
fleetCommands,
fleetLists,
fleetRunDetail,
removeFleetCommand,
saveFleetCommand,
fleetServers,
removeFleetList,
renameFleetList,
Expand Down Expand Up @@ -331,6 +334,12 @@ export function registerIpc(): void {

handle(IPC.fleetRunDetail, z.object({ runId: FleetRunIdSchema }), async ({ runId }) => fleetRunDetail(runId))

handle(IPC.fleetCommandSave, FleetCommandSaveSchema, async (input) => saveFleetCommand(input))

handle(IPC.fleetCommandRemove, z.object({ name: z.string().min(1).max(128) }), async ({ name }) =>
removeFleetCommand(name),
)

handle(IPC.fleetLists, z.undefined(), async () => fleetLists())

handle(IPC.fleetListSave, FleetListSaveSchema, async (input) => saveFleetList(input))
Expand Down
30 changes: 30 additions & 0 deletions apps/desktop/electron/main/services/fleet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,36 @@ export async function startFleet(request: FleetRequest, sender: WebContents): Pr
}
}

// --- saved commands ---------------------------------------------------------

/**
* Saves a command from the Fleet view.
*
* `builtin` is not settable here: the store forces it false, so a saved
* command can shadow a shipped recipe by name but can never claim to be one.
*/
export async function saveFleetCommand(input: {
name: string
description: string
script: string
interpreter: 'sh' | 'bash' | 'raw'
sudo: boolean
workingDirectory: string | null
timeoutSeconds: number
concurrency: number
onFailure: 'continue' | 'stop'
targets: string[]
}): Promise<FleetCommand> {
return (await store()).saveFleetCommand({ ...input, tags: [] })
}

export async function removeFleetCommand(name: string): Promise<boolean> {
if (BUILTIN_RECIPES.some((recipe) => recipe.name === name)) {
throw new Error(`${name} is a recipe DiskPush ships and cannot be deleted. Save a copy under another name instead.`)
}
return (await store()).deleteFleetCommand(name)
}

// --- saved lists ------------------------------------------------------------

export async function fleetLists(): Promise<FleetList[]> {
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/electron/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ const api = {
check: (connectionIds: string[], concurrency = 4, timeoutSeconds = 180) =>
call(IPC.fleetCheck, { connectionIds, concurrency, timeoutSeconds }),
runs: (limit = 25) => call(IPC.fleetRuns, { limit }),
saveCommand: (input: unknown) => call(IPC.fleetCommandSave, input),
removeCommand: (name: string) => call<boolean>(IPC.fleetCommandRemove, { name }),
lists: () => call(IPC.fleetLists),
saveList: (name: string, connectionIds: string[], description = '') =>
call(IPC.fleetListSave, { name, connectionIds, description }),
Expand Down
25 changes: 25 additions & 0 deletions apps/desktop/electron/shared/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ export const IPC = {
fleetCancel: 'fleet:cancel',
fleetCheck: 'fleet:check',
fleetRuns: 'fleet:runs',
fleetCommandSave: 'fleet:command-save',
fleetCommandRemove: 'fleet:command-remove',
fleetLists: 'fleet:lists',
fleetListSave: 'fleet:list-save',
fleetListRename: 'fleet:list-rename',
Expand Down Expand Up @@ -244,6 +246,29 @@ export const FleetListNameSchema = z.string().min(1).max(128).refine((name) => n
message: 'A name cannot begin or end with a space.',
})

/**
* Saving a command from the Fleet view.
*
* The same shape the run request takes, minus the servers and the password:
* a saved command is the *settings*, and which servers to point them at is a
* separate choice made at run time (or remembered as `targets`).
*/
export const FleetCommandSaveSchema = z.object({
name: z.string().min(1).max(128).refine((value) => value.trim() === value, {
message: 'A name cannot begin or end with a space.',
}),
description: z.string().max(500).default(''),
script: z.string().min(1).max(256 * 1024),
interpreter: z.enum(['sh', 'bash', 'raw']).default('sh'),
sudo: z.boolean().default(false),
workingDirectory: PathSchema.nullable().default(null),
timeoutSeconds: z.number().int().min(1).max(86400).default(900),
concurrency: z.number().int().min(1).max(64).default(4),
onFailure: z.enum(['continue', 'stop']).default('continue'),
/** Remembered so a saved command can carry the servers it is usually for. */
targets: z.array(z.string().min(1).max(128)).max(500).default([]),
})

export const FleetListSaveSchema = z.object({
name: FleetListNameSchema,
description: z.string().max(500).default(''),
Expand Down
116 changes: 109 additions & 7 deletions apps/desktop/src/components/fleet-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ export function FleetView({ onAddServer }: { onAddServer: () => void }) {
const [lists, setLists] = useState<FleetList[]>([])
const [savingList, setSavingList] = useState(false)
const [newListName, setNewListName] = useState('')
const [savingCommand, setSavingCommand] = useState(false)
const [newCommandName, setNewCommandName] = useState('')
const [selected, setSelected] = useState<Set<string>>(new Set())
const [tagFilter, setTagFilter] = useState<string | null>(null)

Expand Down Expand Up @@ -221,9 +223,51 @@ export function FleetView({ onAddServer }: { onAddServer: () => void }) {
setInterpreter(command.interpreter)
setSudo(command.sudo)
setTimeoutSeconds(command.timeoutSeconds)
// The pacing is part of the command: "reload nginx" and "upgrade the
// database tier" want very different answers, and leaving whatever was
// last on screen is how a saved command still gets run wrong.
setConcurrency(command.concurrency)
setStopOnError(command.onFailure === 'stop')
setHazards([])
}

/** Saves everything on screen except the servers, which are chosen per run. */
const saveCommand = useCallback(async () => {
const name = newCommandName.trim()
if (!name || !script.trim()) return
setError(null)
try {
await unwrap(
api()?.fleet.saveCommand({
name,
script,
interpreter,
sudo,
workingDirectory: null,
timeoutSeconds,
concurrency,
onFailure: stopOnError ? 'stop' : 'continue',
}),
)
setNewCommandName('')
setSavingCommand(false)
setLabel(name)
setCommands(await unwrap(api()?.fleet.commands()))
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught))
}
}, [newCommandName, script, interpreter, sudo, timeoutSeconds, concurrency, stopOnError])

const removeCommand = useCallback(async (name: string) => {
setError(null)
try {
await unwrap(api()?.fleet.removeCommand(name))
setCommands(await unwrap(api()?.fleet.commands()))
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught))
}
}, [])

const requestBody = useCallback(
(hazardsConfirmed: boolean) => ({
connectionIds: [...selected],
Expand Down Expand Up @@ -457,19 +501,77 @@ export function FleetView({ onAddServer }: { onAddServer: () => void }) {
<section className="flex min-w-0 flex-1 flex-col">
<div className="shrink-0 border-b border-line px-3 pb-2.5 pt-2">
<div className="mb-1.5 flex flex-wrap items-center gap-1.5">
<span className="mr-1 text-[11px] text-faint">Recipes</span>
<span className="mr-1 text-[11px] text-faint">Commands</span>
{commands.map((command) => (
<button
<span
key={command.id}
className="group/cmd inline-flex items-center overflow-hidden rounded-md border border-line-strong"
>
<button
type="button"
title={
`${command.description}\n${command.concurrency} at a time · ${command.timeoutSeconds}s` +
`${command.sudo ? ' · sudo' : ''}${command.onFailure === 'stop' ? ' · stops on failure' : ''}`
}
onClick={() => pickCommand(command)}
disabled={running}
className="focus-ring px-2 py-0.5 text-[11px] text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground disabled:opacity-50"
>
{command.name}
</button>
{/*
Only what someone saved can be deleted. A shipped recipe is
copied, not edited, so upgrading DiskPush never silently
changes a command anyone relies on.
*/}
{command.builtin ? null : (
<button
type="button"
aria-label={`Delete the command ${command.name}`}
title={`Delete the saved command ${command.name}`}
onClick={() => void removeCommand(command.name)}
disabled={running}
className="focus-ring pr-1 text-faint opacity-0 transition-opacity hover:text-destructive group-hover/cmd:opacity-100"
>
<X className="size-2.5" />
</button>
)}
</span>
))}

{script.trim() && !savingCommand ? (
<button
type="button"
title={command.description}
onClick={() => pickCommand(command)}
onClick={() => setSavingCommand(true)}
disabled={running}
className="focus-ring rounded-md border border-line-strong px-2 py-0.5 text-[11px] text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground disabled:opacity-50"
className="focus-ring inline-flex items-center gap-1 rounded-md border border-dashed border-line-strong px-2 py-0.5 text-[11px] text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
>
{command.name}
<BookmarkPlus className="size-2.5" />
Save these settings
</button>
))}
) : null}

{savingCommand ? (
<span className="inline-flex items-center gap-1">
<Input
autoFocus
value={newCommandName}
onChange={(event) => setNewCommandName(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') void saveCommand()
if (event.key === 'Escape') {
setSavingCommand(false)
setNewCommandName('')
}
}}
placeholder="command name"
className="h-6 w-[150px] text-[11.5px]"
/>
<Button size="xs" onClick={() => void saveCommand()} disabled={!newCommandName.trim()} className="text-[11px]">
Save
</Button>
</span>
) : null}
</div>

{/*
Expand Down
18 changes: 18 additions & 0 deletions apps/desktop/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,11 +104,27 @@ export type FleetCommand = {
sudo: boolean
workingDirectory: string | null
timeoutSeconds: number
concurrency: number
onFailure: 'continue' | 'stop'
targets: string[]
tags: string[]
builtin: boolean
}

/** Everything a saved command remembers. The servers are chosen at run time. */
export type FleetCommandSave = {
name: string
description?: string
script: string
interpreter: 'sh' | 'bash' | 'raw'
sudo: boolean
workingDirectory: string | null
timeoutSeconds: number
concurrency: number
onFailure: 'continue' | 'stop'
targets?: string[]
}

export type Hazard = { kind: string; explanation: string; line: string; lineNumber: number }

export type FleetListMember = { connectionId: string; connectionName: string }
Expand Down Expand Up @@ -209,6 +225,8 @@ type Api = {
check(connectionIds: string[], concurrency?: number, timeoutSeconds?: number): Promise<IpcResult<HostUpdateReport[]>>
runs(limit?: number): Promise<IpcResult<unknown[]>>
runDetail(runId: string): Promise<IpcResult<{ run: unknown; hosts: FleetHostResult[] } | null>>
saveCommand(input: FleetCommandSave): Promise<IpcResult<FleetCommand>>
removeCommand(name: string): Promise<IpcResult<boolean>>
lists(): Promise<IpcResult<FleetList[]>>
saveList(name: string, connectionIds: string[], description?: string): Promise<IpcResult<FleetList>>
renameList(from: string, to: string): Promise<IpcResult<FleetList>>
Expand Down
7 changes: 7 additions & 0 deletions docs/desktop.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,13 @@ script editor, and the results — and **the action bar is pinned outside all
of them**. Run is on screen at the 960×600 minimum window size just as it is
maximised.

The command strip above the editor holds the shipped recipes and anything you
have saved. Editing the script or the settings offers **Save these settings**,
which stores the script, the interpreter, sudo, the timeout, how many servers
at a time, and whether a failure stops the rest — picking it again restores all
of it. A saved command carries an × to delete it; a shipped recipe does not,
because it is copied rather than edited.

Saved **lists** sit at the top of the sidebar, above the tag chips. Clicking
one ticks exactly its members; ticking servers by hand offers **Save these 3**;
the × on a chip deletes the list and leaves the servers alone. A member whose
Expand Down
24 changes: 21 additions & 3 deletions docs/fleet.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,9 +173,27 @@ diskpush fleet commands copy upgrade my-upgrade # built-ins are copied, not e
diskpush fleet run --command reload-nginx # --on comes from the saved default
```

A built-in cannot be edited or deleted, only copied, so upgrading DiskPush
never silently changes a command you rely on. A saved command with the same
name as a built-in shadows it.
A saved command remembers **the settings, not just the script**: the
interpreter, whether it runs through sudo, the timeout, how many servers at a
time, and whether a failure stops the rest. Picking one restores all of it.
Those last two matter — "reload nginx" and "upgrade the database tier" want
very different answers, and re-choosing them on every run is how a saved
command still gets run wrong.

```bash
diskpush fleet commands save careful "systemctl reload nginx" \
--sudo --concurrency 2 --stop-on-error --timeout 45 --on tag:web

diskpush fleet run --command careful # 2 at a time, stops on failure
diskpush fleet run --command careful --concurrency 8 # a flag still wins
```

In the desktop app the same commands are the strip above the editor. Editing
anything offers **Save these settings**; a saved command carries an × to
delete it. A shipped recipe has no × — it is copied, not edited, so upgrading
DiskPush never silently changes a command you rely on.

A saved command with the same name as a built-in shadows it.

### `fleet runs` and `fleet show`

Expand Down
Loading
Loading