Skip to content

Commit b1a845d

Browse files
committed
Add task runs panel
This adds a button to the application bar that opens a task runs menu, showing currently running tasks and their properties.
1 parent c17bfab commit b1a845d

21 files changed

+1181
-4
lines changed
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
<template>
2+
<v-menu :close-on-content-click="false">
3+
<template #activator="{ props }">
4+
<v-chip
5+
variant="tonal"
6+
:color="isAllSelected ? undefined : 'primary'"
7+
v-bind="props"
8+
>
9+
<template #prepend>
10+
<v-btn
11+
class="mr-2"
12+
size="20"
13+
variant="plain"
14+
:icon="isAllSelected ? 'mdi-filter-plus' : 'mdi-filter-remove'"
15+
@click="removeFilterIfEnabled"
16+
/>
17+
</template>
18+
{{ label }}
19+
</v-chip>
20+
</template>
21+
<v-card min-height="300" height="50dvh">
22+
<v-card-text class="h-100 d-flex flex-column gr-2">
23+
<div class="flex-0-0 d-flex flex-row justify-space-between">
24+
<v-btn @click="selectAll">Select all</v-btn>
25+
<v-btn @click="selectNone">Select none</v-btn>
26+
</div>
27+
<div class="flex-0-0">
28+
<slot name="actions"></slot>
29+
</div>
30+
<v-list
31+
class="flex-1-1 overflow-auto"
32+
v-model:selected="selectedValues"
33+
select-strategy="leaf"
34+
>
35+
<v-list-item
36+
v-for="item in sortedItems"
37+
:key="item.id"
38+
:title="item.title"
39+
:value="item.value"
40+
>
41+
<template #prepend="{ isSelected }">
42+
<v-list-item-action start>
43+
<v-checkbox-btn :model-value="isSelected" />
44+
</v-list-item-action>
45+
</template>
46+
<template v-if="item.isPreferred" #append>
47+
<v-icon icon="mdi-star" />
48+
</template>
49+
</v-list-item>
50+
</v-list>
51+
</v-card-text>
52+
</v-card>
53+
</v-menu>
54+
</template>
55+
<script setup lang="ts" generic="T">
56+
import { computed } from 'vue'
57+
58+
interface SelectItem {
59+
id: string
60+
title: string
61+
value: T
62+
isPreferred?: boolean
63+
}
64+
65+
interface Props {
66+
label: string
67+
items: SelectItem[]
68+
doSortItems?: boolean
69+
}
70+
const props = withDefaults(defineProps<Props>(), {
71+
doSortItems: false,
72+
})
73+
const selectedValues = defineModel<T[]>({ required: true })
74+
75+
const sortedItems = computed<SelectItem[]>(() => {
76+
// Sort items only if selected.
77+
if (!props.doSortItems) return props.items
78+
79+
return props.items.toSorted((a, b) => {
80+
if (a.isPreferred && !b.isPreferred) {
81+
// Preferred items appear at the top.
82+
return -1
83+
} else if (!a.isPreferred && b.isPreferred) {
84+
// Preferred items appear at the top.
85+
return 1
86+
} else {
87+
// Preferred items are sorted by title.
88+
return a.title.localeCompare(b.title)
89+
}
90+
})
91+
})
92+
93+
const allValues = computed<T[]>(() => props.items.map((item) => item.value))
94+
const isAllSelected = computed<boolean>(
95+
() => selectedValues.value.length === props.items.length,
96+
)
97+
98+
function removeFilterIfEnabled(event: MouseEvent): void {
99+
// We don't need to do anything if we have no filter defined.
100+
if (isAllSelected.value) return
101+
// Otherwise, remove the filter and prevent propagation of the click event so
102+
// we do not open the menu.
103+
event.stopPropagation()
104+
selectAll()
105+
}
106+
107+
function selectAll(): void {
108+
selectedValues.value = allValues.value
109+
}
110+
111+
function selectNone(): void {
112+
selectedValues.value = []
113+
}
114+
</script>
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<template>
2+
<v-select
3+
v-model="numSecondsBack"
4+
:items="options"
5+
item-value="numSecondsBack"
6+
density="compact"
7+
hide-details
8+
/>
9+
</template>
10+
<script setup lang="ts">
11+
import { RelativePeriod } from '@/lib/period'
12+
import { computed } from 'vue'
13+
14+
const period = defineModel<RelativePeriod | null>({ required: true })
15+
16+
interface RelativePeriodOption {
17+
id: string
18+
title: string
19+
numSecondsBack: number | null
20+
}
21+
22+
const secondsPerHour = 60 * 60
23+
const secondsPerDay = 24 * secondsPerHour
24+
const options: RelativePeriodOption[] = [
25+
{
26+
id: '-2h',
27+
title: 'Last 2 hours',
28+
numSecondsBack: 2 * secondsPerHour,
29+
},
30+
{
31+
id: '-8h',
32+
title: 'Last 8 hours',
33+
numSecondsBack: 8 * secondsPerHour,
34+
},
35+
{
36+
id: '-1d',
37+
title: 'Last day',
38+
numSecondsBack: 1 * secondsPerDay,
39+
},
40+
{
41+
id: '-1w',
42+
title: 'Last week',
43+
numSecondsBack: 7 * secondsPerDay,
44+
},
45+
{
46+
id: 'all',
47+
title: 'All',
48+
numSecondsBack: null,
49+
},
50+
] as const
51+
52+
const numSecondsBack = computed<number | null>({
53+
get: () => {
54+
if (!period.value) return null
55+
return -period.value?.startOffsetSeconds
56+
},
57+
set: (newNumSecondsBack) => {
58+
if (newNumSecondsBack === null) {
59+
period.value = null
60+
} else {
61+
period.value = {
62+
startOffsetSeconds: -newNumSecondsBack,
63+
endOffsetSeconds: 0,
64+
}
65+
}
66+
},
67+
})
68+
</script>
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
<template>
2+
<v-tooltip location="bottom">
3+
<template #activator="{ props: activatorProps }">
4+
<v-progress-linear
5+
v-bind="activatorProps"
6+
v-model="progress"
7+
:color="color"
8+
:indeterminate="isUnknownProgress"
9+
/>
10+
</template>
11+
{{ details }}
12+
</v-tooltip>
13+
</template>
14+
<script setup lang="ts">
15+
import { createTimer, Timer } from '@/lib/timer'
16+
import { Duration, DurationUnit } from 'luxon'
17+
import { onMounted, onUnmounted, ref } from 'vue'
18+
19+
interface Props {
20+
dispatchTimestamp: number | null
21+
expectedRuntimeSeconds: number | null
22+
color?: string
23+
updateIntervalSeconds?: number
24+
}
25+
const props = withDefaults(defineProps<Props>(), {
26+
updateIntervalSeconds: 0.5,
27+
})
28+
29+
const progress = ref(0)
30+
const isUnknownProgress = ref(false)
31+
const details = ref('')
32+
33+
// Initialise progress, then update every few seconds; remove timer when the
34+
// component is unmounted.
35+
let timer: Timer | null = null
36+
onMounted(() => {
37+
timer = createTimer(updateProgress, props.updateIntervalSeconds, true)
38+
})
39+
onUnmounted(() => timer?.deactivate())
40+
41+
function updateProgress(): void {
42+
if (
43+
props.dispatchTimestamp === null ||
44+
props.expectedRuntimeSeconds === null
45+
) {
46+
// If we do not know how long to take, set progress to unknown to show
47+
// progress bar as indeterminate.
48+
setProgress(null)
49+
return
50+
}
51+
52+
const currentTimestamp = Date.now()
53+
const currentDurationSeconds =
54+
(currentTimestamp - props.dispatchTimestamp) / 1000
55+
// Compute expected fraction done.
56+
const fractionDone = currentDurationSeconds / props.expectedRuntimeSeconds
57+
58+
// If we are over 100%, set progress to null since we cannot predict our
59+
// progress anymore.
60+
setProgress(fractionDone <= 1 ? fractionDone * 100 : null)
61+
}
62+
63+
function setProgress(newProgress: number | null): void {
64+
isUnknownProgress.value = newProgress === null
65+
progress.value = newProgress ?? 0
66+
details.value = getProgressDetails()
67+
}
68+
69+
function getProgressDetails(): string {
70+
if (isUnknownProgress.value) {
71+
return getRemainingTimeString()
72+
}
73+
const percentage = progress.value.toFixed(0)
74+
const remaining = getRemainingTimeString()
75+
return `${percentage}%; ${remaining}`
76+
}
77+
78+
function getRemainingTimeString(): string {
79+
const currentTimestamp = Date.now()
80+
const currentDurationMilliseconds =
81+
currentTimestamp - props.dispatchTimestamp!
82+
const remainingMilliseconds =
83+
props.expectedRuntimeSeconds! * 1000 - currentDurationMilliseconds
84+
85+
const hasOverrunExpectedTime = remainingMilliseconds < 0
86+
if (hasOverrunExpectedTime) {
87+
// We have overrun our expected task duration, negate the remaining time and
88+
// format this as the amount of time we've overrun.
89+
const remaining = formatDuration(-remainingMilliseconds)
90+
return `Overran expected time by: ${remaining}`
91+
} else {
92+
const remaining = formatDuration(remainingMilliseconds)
93+
return `expected time remaining: ${remaining}`
94+
}
95+
}
96+
97+
function formatDuration(durationMilliseconds: number): string {
98+
// Convert to human-readable duration in hours, minutes and seconds; drop
99+
// the milliseconds.
100+
// FIXME: workaround for Luxon's weird behaviour of toHuman(), which leaves
101+
// units that are 0 in the final string.
102+
const units: DurationUnit[] = ['seconds']
103+
if (durationMilliseconds > 1000 * 60) {
104+
units.push('minutes')
105+
}
106+
if (durationMilliseconds > 1000 * 60 * 60) {
107+
units.push('hours')
108+
}
109+
if (durationMilliseconds > 1000 * 60 * 60 * 24) {
110+
units.push('days')
111+
}
112+
113+
const duration = Duration.fromMillis(durationMilliseconds).shiftTo(...units)
114+
// Remove milliseconds.
115+
const durationWithoutMilliseconds = duration.set({
116+
seconds: Math.round(duration.seconds),
117+
})
118+
return durationWithoutMilliseconds.toHuman()
119+
}
120+
</script>

0 commit comments

Comments
 (0)