@@ -23,8 +23,10 @@ import {
2323 RotateCw ,
2424 ChevronRight ,
2525 Play ,
26+ Pause ,
2627} from " lucide-vue-next" ;
2728import { useNuxtApp } from " #app" ;
29+ import gql from " graphql-tag" ;
2830import getGraphqlClient from " ~/graphql/getGraphqlClient" ;
2931import { generateMutation , generateSubscription } from " ~/graphql/graphqlGen" ;
3032import { clipRenderJobFields } from " ~/graphql/clipRenderJob" ;
@@ -37,6 +39,7 @@ import {
3739 TooltipTrigger ,
3840} from " ~/components/ui/tooltip" ;
3941import { useAuthStore } from " ~/stores/AuthStore" ;
42+ import { useGpuPoolStatusStore } from " ~/stores/GpuPoolStatusStore" ;
4043import { useClipModal , type ClipQueueItem } from " ~/composables/useClipModal" ;
4144
4245const authStore = useAuthStore ();
@@ -70,6 +73,7 @@ type Job = {
7073 user_steam_id: string ;
7174 match_map_id: string ;
7275 status: string ;
76+ paused? : boolean | null ;
7377 progress: number | string | null ;
7478 error_message: string | null ;
7579 clip_id: string | null ;
@@ -304,15 +308,14 @@ type BatchGroup = {
304308 // 0..1 across the batch — sum of per-job render fractions / total.
305309 overallProgress: number ;
306310 isFinished: boolean ;
307- // Pod boot state — null once any job has left "queued" or no
308- // recent booting tick exists. Same tick fans out to every job.
309311 bootInfo: {
310312 stage: string ;
311313 stageSub: string | null ;
312314 progress: number | null ;
313315 at: string ;
314316 firedStages: Set <string >;
315317 } | null ;
318+ isPaused: boolean ;
316319};
317320
318321function buildBatchGroup(matchMapId : string , list : Job []): BatchGroup {
@@ -362,9 +365,11 @@ function buildBatchGroup(matchMapId: string, list: Job[]): BatchGroup {
362365 const inFlightCount = sorted .length - terminalCount ;
363366 const overallProgress = sorted .length === 0 ? 0 : progressSum / sorted .length ;
364367
368+ const isPaused = sorted .some ((j ) => j .paused === true );
369+
365370 let bootInfo: BatchGroup [" bootInfo" ] = null ;
366371 const noneStarted = sorted .every ((j ) => j .status === " queued" );
367- if (noneStarted ) {
372+ if (noneStarted && ! isPaused ) {
368373 const firedStages = new Set <string >();
369374 let latest: { entry: StatusHistoryEntry ; at: number } | null = null ;
370375 for (const j of sorted ) {
@@ -412,6 +417,7 @@ function buildBatchGroup(matchMapId: string, list: Job[]): BatchGroup {
412417 overallProgress ,
413418 isFinished: inFlightCount === 0 ,
414419 bootInfo ,
420+ isPaused ,
415421 };
416422}
417423
@@ -734,6 +740,29 @@ async function cancelBatch(matchMapId: string) {
734740 }
735741}
736742
743+ const resumingBatch = ref <Record <string , boolean >>({});
744+ const RESUME_CLIP_RENDER_BATCH = gql `
745+ mutation ResumeClipRenderBatch($match_map_id: uuid!) {
746+ resumeClipRenderBatch(match_map_id: $match_map_id) {
747+ success
748+ }
749+ }
750+ ` ;
751+ async function resumeBatch(matchMapId : string ) {
752+ if (resumingBatch .value [matchMapId ]) return ;
753+ resumingBatch .value = { ... resumingBatch .value , [matchMapId ]: true };
754+ try {
755+ await nuxtApp .$apollo .defaultClient .mutate ({
756+ mutation: RESUME_CLIP_RENDER_BATCH ,
757+ variables: { match_map_id: matchMapId },
758+ });
759+ } catch (e ) {
760+ console .error (" [render-queue] batch resume failed:" , e );
761+ } finally {
762+ resumingBatch .value = { ... resumingBatch .value , [matchMapId ]: false };
763+ }
764+ }
765+
737766function formatTimeAgo(iso : string | null ): string {
738767 if (! iso ) return " " ;
739768 const t = new Date (iso ).getTime ();
@@ -749,15 +778,56 @@ function formatTimeAgo(iso: string | null): string {
749778}
750779
751780const totalInFlight = computed (() => inFlight .value .length );
752- const totalActive = computed (
781+ const totalRendering = computed (
753782 () =>
754783 inFlight .value .filter (
755784 (j ) => j .status === " rendering" || j .status === " uploading" ,
756785 ).length ,
757786);
758- const totalQueued = computed (
759- () => inFlight .value .filter ((j ) => j .status === " queued " ).length ,
787+ const totalPaused = computed (
788+ () => inFlight .value .filter ((j ) => j .paused === true ).length ,
760789);
790+ const allPaused = computed (
791+ () =>
792+ inFlight .value .length > 0 && totalPaused .value === inFlight .value .length ,
793+ );
794+
795+ const gpuPool = useGpuPoolStatusStore ();
796+ gpuPool .subscribeToPool ();
797+
798+ const resumeBlockedReason = computed <string | null >(() => {
799+ const s = gpuPool .status ;
800+ if (! gpuPool .hasLoaded || ! s ) return null ;
801+ if (s .live_in_progress ) {
802+ return t (" clips.render_queue.resume_blocked_live" );
803+ }
804+ return null ;
805+ });
806+
807+ const queueStatus = computed <{
808+ key: string ;
809+ tone: " amber" | " muted" | " danger" ;
810+ }>(() => {
811+ if (totalRendering .value > 0 ) {
812+ return { key: " rendering" , tone: " amber" };
813+ }
814+ if (allPaused .value ) {
815+ return { key: " paused" , tone: " amber" };
816+ }
817+ const s = gpuPool .status ;
818+ if (gpuPool .hasLoaded && s ) {
819+ if (s .total_gpu_nodes === 0 ) {
820+ return { key: " no_gpu_registered" , tone: " danger" };
821+ }
822+ if (s .free_gpu_nodes === 0 ) {
823+ if (s .live_in_progress ) return { key: " blocked_live" , tone: " amber" };
824+ if (s .demo_in_progress ) return { key: " blocked_demo" , tone: " amber" };
825+ return { key: " no_gpu_free" , tone: " amber" };
826+ }
827+ }
828+ if (inFlight .value .length === 0 ) return { key: " idle" , tone: " muted" };
829+ return { key: " waiting" , tone: " muted" };
830+ });
761831 </script >
762832
763833<template >
@@ -775,28 +845,46 @@ const totalQueued = computed(
775845 >
776846 <div class =" flex items-center gap-2" >
777847 <ListVideo class="h-4 w-4 text-[hsl(var(--tac-amber))]" />
848+ <span class =" font-mono text-sm font-semibold tabular-nums" >
849+ {{ totalInFlight }}
850+ </span >
778851 <span
779852 class =" font-mono text-[0.65rem] uppercase tracking-[0.18em] text-muted-foreground"
780853 >
781- {{ $t("clips.render_queue.in_flight") }}
782- </span >
783- <span class =" font-mono text-sm font-semibold tabular-nums" >
784- {{ totalInFlight }}
854+ {{ $t("clips.render_queue.queued") }}
785855 </span >
786856 </div >
787857 <div
788- class =" ml-auto flex items-center gap-3 font-mono text-[0.65rem] uppercase tracking-[0.16em] text-muted-foreground"
858+ class =" ml-auto flex items-center gap-2 font-mono text-[0.65rem] uppercase tracking-[0.16em]"
859+ :class ="
860+ queueStatus.tone === 'danger'
861+ ? 'text-destructive'
862+ : queueStatus.tone === 'amber'
863+ ? 'text-[hsl(var(--tac-amber))]'
864+ : 'text-muted-foreground'
865+ "
789866 >
790- <span class =" inline-flex items-center gap-1.5" >
791- <Loader2
792- class="h-3 w-3 animate-spin text-[hsl(var(--tac-amber))]"
793- />
794- {{ totalActive }} active
795- </span >
796- <span class =" text-border" >·</span >
797- <span class =" inline-flex items-center gap-1.5" >
798- <Clock class="h-3 w-3" />
799- {{ totalQueued }} queued
867+ <Loader2
868+ v-if =" queueStatus .key === ' rendering' "
869+ class="h-3 w-3 animate-spin"
870+ />
871+ <Pause v-else-if =" queueStatus .key === ' paused' " class="h-3 w-3" />
872+ <AlertCircle
873+ v-else-if =" queueStatus .tone === ' danger' "
874+ class="h-3 w-3"
875+ />
876+ <Clock v-else class="h-3 w-3" />
877+ <span >
878+ <template v-if =" queueStatus .key === ' rendering' " >
879+ {{
880+ $t("clips.render_queue.status_rendering", {
881+ count: totalRendering,
882+ })
883+ }}
884+ </template >
885+ <template v-else >
886+ {{ $t(`clips.render_queue.status_${queueStatus.key}`) }}
887+ </template >
800888 </span >
801889 </div >
802890 </div >
@@ -889,18 +977,33 @@ const totalQueued = computed(
889977 <div
890978 class =" flex items-center justify-between font-mono text-[0.6rem] uppercase tracking-[0.16em] text-muted-foreground"
891979 >
892- <span >{{
893- g.bootInfo
894- ? $t("ui_extras.pod_boot")
895- : $t("ui_extras.batch_progress")
896- }}</span >
980+ <span
981+ :class =" g.isPaused ? 'text-[hsl(var(--tac-amber))]' : ''"
982+ >
983+ <template v-if =" g .isPaused " >
984+ <Pause class="inline h-3 w-3 mr-1 -mt-0.5 " />
985+ {{ $t("ui_extras.batch_paused") }}
986+ </template >
987+ <template v-else-if =" g .bootInfo " >
988+ {{ $t("ui_extras.pod_boot") }}
989+ </template >
990+ <template v-else >
991+ {{ $t("ui_extras.batch_progress") }}
992+ </template >
993+ </span >
897994 <span class =" tabular-nums" >
898995 <template
899- v-if =" g .bootInfo && g .bootInfo .progress !== null "
996+ v-if ="
997+ ! g .isPaused &&
998+ g .bootInfo &&
999+ g .bootInfo .progress !== null
1000+ "
9001001 >
9011002 {{ Math.round(g.bootInfo.progress * 100) }}%
9021003 </template >
903- <template v-else-if =" g .bootInfo " >…</template >
1004+ <template v-else-if =" ! g .isPaused && g .bootInfo "
1005+ >…</template
1006+ >
9041007 <template v-else >
9051008 {{ Math.round(g.overallProgress * 100) }}%
9061009 </template >
@@ -910,7 +1013,7 @@ const totalQueued = computed(
9101013 class =" relative h-1.5 overflow-hidden rounded-full bg-muted/40"
9111014 >
9121015 <div
913- v-if =" g.bootInfo"
1016+ v-if =" !g.isPaused && g.bootInfo"
9141017 class =" h-full rounded-full bg-primary transition-[width] duration-300"
9151018 :style =" {
9161019 width:
@@ -919,7 +1022,12 @@ const totalQueued = computed(
9191022 ></div >
9201023 <div
9211024 v-else
922- class =" h-full rounded-full bg-[hsl(var(--tac-amber))] transition-[width] duration-300"
1025+ class =" h-full rounded-full transition-[width] duration-300"
1026+ :class ="
1027+ g.isPaused
1028+ ? 'bg-[hsl(var(--tac-amber)/0.5)]'
1029+ : 'bg-[hsl(var(--tac-amber))]'
1030+ "
9231031 :style =" {
9241032 width: Math.round(g.overallProgress * 100) + '%',
9251033 }"
@@ -928,6 +1036,39 @@ const totalQueued = computed(
9281036 </div >
9291037 </div >
9301038 <div class =" flex shrink-0 items-center gap-1.5" >
1039+ <Tooltip v-if =" g .isPaused && resumeBlockedReason " >
1040+ <TooltipTrigger as-child>
1041+ <span tabindex =" 0" class =" inline-flex" >
1042+ <Button
1043+ size="sm"
1044+ variant="outline"
1045+ class="h-7 px-2 text-[0.7rem ] opacity-60 cursor-not-allowed"
1046+ :disabled =" true "
1047+ >
1048+ <Play class="h-3 w-3 mr-1" />
1049+ {{ $t("ui_extras.resume") }}
1050+ </Button >
1051+ </span >
1052+ </TooltipTrigger >
1053+ <TooltipContent side="left">
1054+ {{ resumeBlockedReason }}
1055+ </TooltipContent >
1056+ </Tooltip >
1057+ <Button
1058+ v-else-if =" g .isPaused "
1059+ size="sm"
1060+ variant="outline"
1061+ class="h-7 px-2 text-[0.7rem ] hover:border- [hsl(var(--tac-amber))] hover:text- [hsl(var(--tac-amber))]"
1062+ :disabled =" resumingBatch [g .matchMapId ]"
1063+ @click =" resumeBatch (g .matchMapId )"
1064+ >
1065+ <Loader2
1066+ v-if =" resumingBatch [g .matchMapId ]"
1067+ class="h-3 w-3 mr-1 animate-spin"
1068+ />
1069+ <Play v-else class="h-3 w-3 mr-1" />
1070+ {{ $t("ui_extras.resume") }}
1071+ </Button >
9311072 <Button
9321073 v-if =" isAdmin && (g .errorCount > 0 || g .cancelledCount > 0 )"
9331074 size="sm"
0 commit comments