Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add task runs menu #1131

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
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
129 changes: 129 additions & 0 deletions src/components/tasks/BaseTaskFilterControl.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<template>
<v-menu :close-on-content-click="false">
<template #activator="{ props }">
<v-chip
variant="tonal"
pilled
label
:color="isAllSelected ? undefined : 'primary'"
v-bind="props"
class="mx-1"
>
<template #prepend>
<v-btn
class="mr-2"
size="20"
variant="plain"
:icon="isAllSelected ? 'mdi-filter-plus' : 'mdi-filter-remove'"
@click="removeFilterIfEnabled"
/>
</template>
{{ label }}
<v-icon>mdi-chevron-down</v-icon>
</v-chip>
</template>
<v-sheet min-height="300" max-height="80dvh">
<v-banner sticky density="compact" class="pa-2">
<v-btn
@click="toggleSelectAll"
:icon="selectAllIcon"
variant="plain"
></v-btn>
<slot name="actions"></slot>
</v-banner>
<v-list
v-model:selected="selectedValues"
select-strategy="leaf"
density="compact"
>
<v-list-item
v-for="item in sortedItems"
:key="item.id"
:title="item.title"
:value="item.value"
>
<template #prepend="{ isSelected }">
<v-checkbox-btn density="compact" :model-value="isSelected" />
</template>
<template v-if="item.isPreferred" #append>
<v-icon icon="mdi-star" />
</template>
</v-list-item>
</v-list>
</v-sheet>
</v-menu>
</template>
<script setup lang="ts" generic="T">
import { computed } from 'vue'

interface SelectItem {
id: string
title: string
value: T
isPreferred?: boolean
}

interface Props {
label: string
items: SelectItem[]
doSortItems?: boolean
}
const props = withDefaults(defineProps<Props>(), {
doSortItems: false,
})
const selectedValues = defineModel<T[]>({ required: true })

const sortedItems = computed<SelectItem[]>(() => {
// Sort items only if selected.
if (!props.doSortItems) return props.items

return props.items.toSorted((a, b) => {
if (a.isPreferred && !b.isPreferred) {
// Preferred items appear at the top.
return -1
} else if (!a.isPreferred && b.isPreferred) {
// Preferred items appear at the top.
return 1
} else {
// Preferred items are sorted by title.
return a.title.localeCompare(b.title)
}
})
})

const allValues = computed<T[]>(() => props.items.map((item) => item.value))
const isAllSelected = computed<boolean>(
() => selectedValues.value.length === props.items.length,
)

function removeFilterIfEnabled(event: MouseEvent): void {
// We don't need to do anything if we have no filter defined.
if (isAllSelected.value) return
// Otherwise, remove the filter and prevent propagation of the click event so
// we do not open the menu.
event.stopPropagation()
selectAll()
}

function selectAll(): void {
selectedValues.value = allValues.value
}

function toggleSelectAll(): void {
if (selectedValues.value.length === allValues.value.length) {
selectedValues.value = []
} else {
selectedValues.value = allValues.value
}
}

const selectAllIcon = computed(() => {
if (selectedValues.value.length === 0) {
return 'mdi-checkbox-blank-outline'
}
if (selectedValues.value.length === allValues.value.length) {
return 'mdi-checkbox-marked'
}
return 'mdi-minus-box' // 'mdi-checkbox-marked' // 'mdi-minus-box'
})
</script>
70 changes: 70 additions & 0 deletions src/components/tasks/PeriodFilterControl.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<template>
<v-select
v-model="numSecondsBack"
:items="options"
item-value="numSecondsBack"
density="compact"
variant="plain"
flat
hide-details
/>
</template>
<script setup lang="ts">
import { RelativePeriod } from '@/lib/period'
import { computed } from 'vue'

const period = defineModel<RelativePeriod | null>({ required: true })

interface RelativePeriodOption {
id: string
title: string
numSecondsBack: number | null
}

const secondsPerHour = 60 * 60
const secondsPerDay = 24 * secondsPerHour
const options: RelativePeriodOption[] = [
{
id: '-2h',
title: 'Last 2 hours',
numSecondsBack: 2 * secondsPerHour,
},
{
id: '-8h',
title: 'Last 8 hours',
numSecondsBack: 8 * secondsPerHour,
},
{
id: '-1d',
title: 'Last day',
numSecondsBack: 1 * secondsPerDay,
},
{
id: '-1w',
title: 'Last week',
numSecondsBack: 7 * secondsPerDay,
},
{
id: 'all',
title: 'All',
numSecondsBack: null,
},
] as const

const numSecondsBack = computed<number | null>({
get: () => {
if (!period.value) return null
return -period.value?.startOffsetSeconds
},
set: (newNumSecondsBack) => {
if (newNumSecondsBack === null) {
period.value = null
} else {
period.value = {
startOffsetSeconds: -newNumSecondsBack,
endOffsetSeconds: 0,
}
}
},
})
</script>
120 changes: 120 additions & 0 deletions src/components/tasks/TaskRunProgress.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
<template>
<v-tooltip location="bottom">
<template #activator="{ props: activatorProps }">
<v-progress-linear
v-bind="activatorProps"
v-model="progress"
:color="color"
:indeterminate="isUnknownProgress"
/>
</template>
{{ details }}
</v-tooltip>
</template>
<script setup lang="ts">
import { createTimer, Timer } from '@/lib/timer'
import { Duration, DurationUnit } from 'luxon'
import { onMounted, onUnmounted, ref } from 'vue'

interface Props {
dispatchTimestamp: number | null
expectedRuntimeSeconds: number | null
color?: string
updateIntervalSeconds?: number
}
const props = withDefaults(defineProps<Props>(), {
updateIntervalSeconds: 0.5,
})

const progress = ref(0)
const isUnknownProgress = ref(false)
const details = ref('')

// Initialise progress, then update every few seconds; remove timer when the
// component is unmounted.
let timer: Timer | null = null
onMounted(() => {
timer = createTimer(updateProgress, props.updateIntervalSeconds, true)
})
onUnmounted(() => timer?.deactivate())

function updateProgress(): void {
if (
props.dispatchTimestamp === null ||
props.expectedRuntimeSeconds === null
) {
// If we do not know how long to take, set progress to unknown to show
// progress bar as indeterminate.
setProgress(null)
return
}

const currentTimestamp = Date.now()
const currentDurationSeconds =
(currentTimestamp - props.dispatchTimestamp) / 1000
// Compute expected fraction done.
const fractionDone = currentDurationSeconds / props.expectedRuntimeSeconds

// If we are over 100%, set progress to null since we cannot predict our
// progress anymore.
setProgress(fractionDone <= 1 ? fractionDone * 100 : null)
}

function setProgress(newProgress: number | null): void {
isUnknownProgress.value = newProgress === null
progress.value = newProgress ?? 0
details.value = getProgressDetails()
}

function getProgressDetails(): string {
if (isUnknownProgress.value) {
return getRemainingTimeString()
}
const percentage = progress.value.toFixed(0)
const remaining = getRemainingTimeString()
return `${percentage}%; ${remaining}`
}

function getRemainingTimeString(): string {
const currentTimestamp = Date.now()
const currentDurationMilliseconds =
currentTimestamp - props.dispatchTimestamp!
const remainingMilliseconds =
props.expectedRuntimeSeconds! * 1000 - currentDurationMilliseconds

const hasOverrunExpectedTime = remainingMilliseconds < 0
if (hasOverrunExpectedTime) {
// We have overrun our expected task duration, negate the remaining time and
// format this as the amount of time we've overrun.
const remaining = formatDuration(-remainingMilliseconds)
return `Overran expected time by: ${remaining}`
} else {
const remaining = formatDuration(remainingMilliseconds)
return `expected time remaining: ${remaining}`
}
}

function formatDuration(durationMilliseconds: number): string {
// Convert to human-readable duration in hours, minutes and seconds; drop
// the milliseconds.
// FIXME: workaround for Luxon's weird behaviour of toHuman(), which leaves
// units that are 0 in the final string.
const units: DurationUnit[] = ['seconds']
if (durationMilliseconds > 1000 * 60) {
units.push('minutes')
}
if (durationMilliseconds > 1000 * 60 * 60) {
units.push('hours')
}
if (durationMilliseconds > 1000 * 60 * 60 * 24) {
units.push('days')
}

const duration = Duration.fromMillis(durationMilliseconds).shiftTo(...units)
// Remove milliseconds.
const durationWithoutMilliseconds = duration.set({
seconds: Math.round(duration.seconds),
})
return durationWithoutMilliseconds.toHuman()
}
</script>
Loading
Loading