@@ -23,10 +23,13 @@ import {
2323 SelectValue ,
2424} from '@/components/ui/select' ;
2525import { Alert , AlertDescription , AlertTitle } from '@/components/ui/alert' ;
26+ import { Tabs , TabsContent , TabsList , TabsTrigger } from '@/components/ui/tabs' ;
2627import { AlertTriangle , ChevronDown , ChevronRight , Copy } from 'lucide-react' ;
28+ import { toast } from 'sonner' ;
2729import type { inferRouterOutputs } from '@trpc/server' ;
2830import type { RootRouter } from '@/routers/root-router' ;
2931import type { AdminKiloclawInstance } from '@/routers/admin-kiloclaw-instances-router' ;
32+ import { defaultScheduledAt } from '@/lib/kiloclaw/scheduled-action-form' ;
3033
3134type RouterOutputs = inferRouterOutputs < RootRouter > ;
3235type ListVersionsItem = RouterOutputs [ 'admin' ] [ 'kiloclawVersions' ] [ 'listVersions' ] [ 'items' ] [ number ] ;
@@ -69,6 +72,8 @@ export function BulkChangeVersionDialog({
6972 const [ appliedSectionOpen , setAppliedSectionOpen ] = useState ( true ) ;
7073 const [ skippedSectionOpen , setSkippedSectionOpen ] = useState ( true ) ;
7174 const [ failedSectionOpen , setFailedSectionOpen ] = useState ( true ) ;
75+ const [ mode , setMode ] = useState < 'now' | 'scheduled' > ( 'now' ) ;
76+ const [ scheduledAt , setScheduledAt ] = useState < string > ( defaultScheduledAt ) ;
7277
7378 // Reset form whenever the dialog reopens. Keeps state from leaking
7479 // between independent admin actions.
@@ -78,6 +83,7 @@ export function BulkChangeVersionDialog({
7883 setOverridePins ( false ) ;
7984 setConfirmInput ( '' ) ;
8085 setResult ( null ) ;
86+ setMode ( 'now' ) ;
8187 }
8288 } , [ open ] ) ;
8389
@@ -163,21 +169,62 @@ export function BulkChangeVersionDialog({
163169 } )
164170 ) ;
165171
172+ // Scheduled bulk path. One scheduleAction call covers all selected
173+ // instances (parent + N targets). The schedule shows up in the
174+ // Scheduler tab; no per-instance result partition (that happens at
175+ // apply time inside each DO).
176+ const bulkSchedule = useMutation (
177+ trpc . admin . kiloclawInstances . scheduleAction . mutationOptions ( {
178+ onSuccess : ( ) => {
179+ toast . success (
180+ `Scheduled version change on ${ selectedIds . length } ${ selectedIds . length === 1 ? 'instance' : 'instances' } `
181+ ) ;
182+ void queryClient . invalidateQueries ( {
183+ queryKey : trpc . admin . kiloclawInstances . listScheduledActions . queryKey ( ) ,
184+ } ) ;
185+ onApplied ( ) ;
186+ onOpenChange ( false ) ;
187+ } ,
188+ onError : err => {
189+ toast . error ( `Failed to schedule: ${ err . message } ` ) ;
190+ } ,
191+ } )
192+ ) ;
193+
166194 const overrideRequiresConfirm = overridePins ;
167195 const confirmMatches = ! overrideRequiresConfirm || confirmInput === CONFIRM_TOKEN ;
168- const canApply = targetTag !== '' && confirmMatches && ! bulkChange . isPending && result === null ;
196+ const isPending = bulkChange . isPending || bulkSchedule . isPending ;
197+ // In schedule mode also require a non-empty datetime — the input has
198+ // `required` for browser validation but we still guard here so a
199+ // programmatic submit can't fall into `new Date("")` → RangeError.
200+ const scheduleDateValid = mode !== 'scheduled' || scheduledAt !== '' ;
201+ const canApply =
202+ targetTag !== '' && confirmMatches && scheduleDateValid && ! isPending && result === null ;
169203
170204 const onApply = ( ) => {
171205 if ( ! targetTag ) return ;
172- bulkChange . mutate ( {
206+ if ( mode === 'now' ) {
207+ bulkChange . mutate ( {
208+ instanceIds : selectedIds ,
209+ imageTag : targetTag ,
210+ overridePins,
211+ } ) ;
212+ return ;
213+ }
214+ // Scheduled path — convert local datetime-local to UTC ISO.
215+ const local = new Date ( scheduledAt ) ;
216+ if ( Number . isNaN ( local . getTime ( ) ) ) return ;
217+ bulkSchedule . mutate ( {
218+ actionType : 'version_change' ,
173219 instanceIds : selectedIds ,
174220 imageTag : targetTag ,
175221 overridePins,
222+ scheduledAt : local . toISOString ( ) ,
176223 } ) ;
177224 } ;
178225
179226 const handleClose = ( next : boolean ) => {
180- if ( bulkChange . isPending ) return ; // don't close mid-flight
227+ if ( isPending ) return ; // don't close mid-flight
181228 onOpenChange ( next ) ;
182229 } ;
183230
@@ -217,15 +264,52 @@ export function BulkChangeVersionDialog({
217264 </ DialogDescription >
218265 </ DialogHeader >
219266
220- < Alert variant = "destructive" >
221- < AlertTriangle className = "h-4 w-4" />
222- < AlertTitle > This runs immediately. No undo, no user notice.</ AlertTitle >
223- < AlertDescription >
224- Every selected instance restarts now. End users get no notification, and any active
225- session is interrupted. Confirm the selection and target version are correct before
226- applying.
227- </ AlertDescription >
228- </ Alert >
267+ < Tabs value = { mode } onValueChange = { v => setMode ( v as 'now' | 'scheduled' ) } >
268+ < TabsList className = "grid w-full grid-cols-2" >
269+ < TabsTrigger value = "now" > Apply now</ TabsTrigger >
270+ < TabsTrigger value = "scheduled" > Schedule for later</ TabsTrigger >
271+ </ TabsList >
272+ < TabsContent value = "now" className = "mt-3" >
273+ < Alert variant = "destructive" >
274+ < AlertTriangle className = "h-4 w-4" />
275+ < AlertTitle > This runs immediately. No undo, no user notice.</ AlertTitle >
276+ < AlertDescription >
277+ Every selected instance restarts now. End users get no notification, and any
278+ active session is interrupted. Confirm the selection and target version are
279+ correct before applying.
280+ </ AlertDescription >
281+ </ Alert >
282+ </ TabsContent >
283+ < TabsContent value = "scheduled" className = "mt-3 space-y-2" >
284+ < Alert >
285+ < AlertTriangle className = "h-4 w-4" />
286+ < AlertDescription >
287+ Notifications aren't implemented yet — end users get no warning before their
288+ session is interrupted at the scheduled time. Use cautiously on customer
289+ instances until the notifications work lands.
290+ </ AlertDescription >
291+ </ Alert >
292+ < div className = "space-y-2" >
293+ < Label htmlFor = "bulk-scheduled-at" > Scheduled at (local time)</ Label >
294+ < Input
295+ id = "bulk-scheduled-at"
296+ type = "datetime-local"
297+ value = { scheduledAt }
298+ onChange = { e => setScheduledAt ( e . target . value ) }
299+ disabled = { isPending }
300+ // Without `required`, an admin can clear the field
301+ // and submit; new Date("") throws RangeError below.
302+ required
303+ />
304+ </ div >
305+ < p className = "text-muted-foreground text-xs" >
306+ Each instance fires on its next reconcile alarm tick after the scheduled time
307+ (cadence ~5 minutes for running instances). Treat as a "no earlier than" bound.
308+ Per-instance outcome (applied / skipped / failed) shows up in the Scheduler tab as
309+ the action progresses.
310+ </ p >
311+ </ TabsContent >
312+ </ Tabs >
229313
230314 < div className = "space-y-4 py-2" >
231315 < div className = "bg-muted/30 rounded-md border p-3 text-sm" >
@@ -344,19 +428,23 @@ export function BulkChangeVersionDialog({
344428 </ div >
345429
346430 < DialogFooter >
347- < Button
348- variant = "outline"
349- onClick = { ( ) => handleClose ( false ) }
350- disabled = { bulkChange . isPending }
351- >
431+ < Button variant = "outline" onClick = { ( ) => handleClose ( false ) } disabled = { isPending } >
352432 Cancel
353433 </ Button >
354434 < Button
355435 onClick = { onApply }
356436 disabled = { ! canApply }
357- className = { overridePins ? 'bg-destructive hover:bg-destructive/90' : undefined }
437+ className = {
438+ mode === 'now' && overridePins
439+ ? 'bg-destructive hover:bg-destructive/90'
440+ : undefined
441+ }
358442 >
359- { overridePins ? 'Override and change version' : 'Apply' }
443+ { mode === 'scheduled'
444+ ? 'Schedule'
445+ : overridePins
446+ ? 'Override and change version'
447+ : 'Apply' }
360448 </ Button >
361449 </ DialogFooter >
362450 </ >
0 commit comments