409 lines
14 KiB
Vue
409 lines
14 KiB
Vue
<template>
|
|
<div class="w-full min-w-0 max-w-full space-y-5">
|
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
<div class="min-w-0 space-y-2">
|
|
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
|
|
<UIcon name="i-lucide-gauge" class="size-4 text-toned" />
|
|
<span>Queue overview</span>
|
|
</div>
|
|
|
|
<p class="max-w-3xl text-sm text-toned">
|
|
Data is based on the current queue state and configured limits.
|
|
</p>
|
|
</div>
|
|
|
|
<UButton
|
|
color="neutral"
|
|
variant="outline"
|
|
size="sm"
|
|
icon="i-lucide-refresh-cw"
|
|
:loading="limitsLoading"
|
|
:disabled="limitsLoading"
|
|
@click="void loadLimits(true)"
|
|
>
|
|
Refresh
|
|
</UButton>
|
|
</div>
|
|
|
|
<div
|
|
v-if="!limits && limitsLoading"
|
|
class="flex min-h-72 items-center justify-center rounded-md border border-default bg-default/90"
|
|
>
|
|
<div class="flex flex-col items-center gap-3 text-center text-toned">
|
|
<UIcon name="i-lucide-loader-circle" class="size-10 animate-spin text-info" />
|
|
|
|
<div class="space-y-1">
|
|
<p class="text-sm font-medium text-default">Loading limits</p>
|
|
<p class="text-xs">Fetching current queue state and runtime rules.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<UAlert
|
|
v-else-if="!limits && limitsError"
|
|
color="error"
|
|
variant="soft"
|
|
icon="i-lucide-triangle-alert"
|
|
title="Failed to load limits"
|
|
:description="limitsError"
|
|
/>
|
|
|
|
<div v-else-if="limits" class="space-y-5">
|
|
<UAlert
|
|
v-if="limitsError"
|
|
color="warning"
|
|
variant="soft"
|
|
icon="i-lucide-triangle-alert"
|
|
title="Showing last successful snapshot"
|
|
:description="limitsError"
|
|
/>
|
|
|
|
<UAlert
|
|
v-if="limits.downloads.paused"
|
|
color="warning"
|
|
variant="soft"
|
|
icon="i-lucide-pause"
|
|
title="Download queue is paused"
|
|
/>
|
|
|
|
<div class="rounded-md border border-default bg-default shadow-sm">
|
|
<div class="grid gap-0 lg:grid-cols-[minmax(0,1.05fr)_minmax(0,0.95fr)]">
|
|
<section
|
|
class="space-y-4 border-b border-default px-4 py-4 sm:px-5 lg:border-r lg:border-b-0"
|
|
>
|
|
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
|
|
<UIcon name="i-lucide-download" class="size-4 text-toned" />
|
|
<span>Download capacity</span>
|
|
</div>
|
|
|
|
<div class="space-y-3">
|
|
<div
|
|
v-for="row in capacityRows"
|
|
:key="row.label"
|
|
class="rounded-md border border-default bg-muted/20 px-3 py-3"
|
|
>
|
|
<div class="flex items-start justify-between gap-3">
|
|
<div class="min-w-0 space-y-1">
|
|
<p class="text-sm font-medium text-default">{{ row.label }}</p>
|
|
<p class="text-xs text-toned">{{ row.description }}</p>
|
|
</div>
|
|
|
|
<div class="shrink-0 text-right">
|
|
<p class="text-lg font-semibold text-default">{{ row.value }}</p>
|
|
<p v-if="row.meta" class="text-xs text-toned">{{ row.meta }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="space-y-4 px-4 py-4 sm:px-5">
|
|
<div class="space-y-3">
|
|
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
|
|
<UIcon name="i-lucide-settings-2" class="size-4 text-toned" />
|
|
<span>Extraction rules</span>
|
|
</div>
|
|
|
|
<div class="space-y-3">
|
|
<div
|
|
v-for="row in extractionRows"
|
|
:key="row.label"
|
|
class="rounded-md border border-default bg-muted/20 px-3 py-3"
|
|
>
|
|
<div class="flex items-start justify-between gap-3">
|
|
<div class="min-w-0 space-y-1">
|
|
<p class="text-sm font-medium text-default">{{ row.label }}</p>
|
|
<p class="text-xs text-toned">{{ row.description }}</p>
|
|
</div>
|
|
|
|
<div class="shrink-0 text-right">
|
|
<p class="text-lg font-semibold text-default">{{ row.value }}</p>
|
|
<p v-if="row.meta" class="text-xs text-toned">{{ row.meta }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-3 border-t border-default pt-4">
|
|
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
|
|
<UIcon name="i-lucide-clock-3" class="size-4 text-toned" />
|
|
<span>Premiere handling</span>
|
|
</div>
|
|
|
|
<div class="space-y-3">
|
|
<div
|
|
v-for="row in premiereRows"
|
|
:key="row.label"
|
|
class="rounded-md border border-default bg-muted/20 px-3 py-3"
|
|
>
|
|
<div class="flex items-start justify-between gap-3">
|
|
<div class="min-w-0 space-y-1">
|
|
<p class="text-sm font-medium text-default">{{ row.label }}</p>
|
|
<p class="text-xs text-toned">{{ row.description }}</p>
|
|
</div>
|
|
|
|
<div class="shrink-0 text-right">
|
|
<p class="text-lg font-semibold text-default">{{ row.value }}</p>
|
|
<p v-if="row.meta" class="text-xs text-toned">{{ row.meta }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
|
|
<section class="space-y-3">
|
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
<div class="min-w-0 space-y-2">
|
|
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
|
|
<UIcon name="i-lucide-list" class="size-4 text-toned" />
|
|
<span>Per extractor</span>
|
|
</div>
|
|
|
|
<p class="text-sm text-toned" v-if="trackedExtractorCount">
|
|
Extractor-specific usage and overrides currently in effect.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap items-center gap-2 text-xs text-toned">
|
|
<span class="inline-flex items-center gap-1 rounded-sm border border-default px-2 py-1">
|
|
<UIcon name="i-lucide-gauge" class="size-3.5 shrink-0" />
|
|
<span
|
|
><span class="font-semibold">{{
|
|
limits.downloads.per_extractor.default_limit
|
|
}}</span
|
|
>/slots per extractor</span
|
|
>
|
|
</span>
|
|
|
|
<span class="inline-flex items-center gap-1 rounded-sm border border-default px-2 py-1">
|
|
<UIcon name="i-lucide-list" class="size-3.5 shrink-0" />
|
|
<span
|
|
><span class="font-semibold">{{ trackedExtractorCount }}</span> tracked</span
|
|
>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<UAlert
|
|
v-if="trackedExtractorCount === 0"
|
|
color="info"
|
|
variant="soft"
|
|
icon="i-lucide-info"
|
|
title="No activity"
|
|
description="Overrides and extractor usage appear here once activity is detected."
|
|
/>
|
|
|
|
<div
|
|
v-else
|
|
class="w-full min-w-0 max-w-full overflow-hidden rounded-md border border-default bg-default"
|
|
>
|
|
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
|
|
<table class="min-w-180 w-full text-sm">
|
|
<thead class="bg-muted/40 text-xs uppercase tracking-wide text-toned">
|
|
<tr
|
|
class="text-left [&>th]:border-r [&>th]:border-default/60 [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold [&>th:last-child]:border-r-0"
|
|
>
|
|
<th class="w-full min-w-56">Extractor</th>
|
|
<th class="w-36 whitespace-nowrap">Source</th>
|
|
<th class="w-24 whitespace-nowrap">Active</th>
|
|
<th class="w-24 whitespace-nowrap">Limit</th>
|
|
<th class="w-28 whitespace-nowrap">Available</th>
|
|
<th class="w-24 whitespace-nowrap">Queued</th>
|
|
</tr>
|
|
</thead>
|
|
|
|
<tbody class="divide-y divide-default">
|
|
<tr
|
|
v-for="item in extractorItems"
|
|
:key="item.name"
|
|
class="transition-colors hover:bg-elevated/70 [&>td]:border-r [&>td]:border-default/60 [&>td:last-child]:border-r-0"
|
|
>
|
|
<td class="px-3 py-3 align-middle">
|
|
<div class="font-medium text-default">{{ item.name }}</div>
|
|
</td>
|
|
|
|
<td class="px-3 py-3 align-middle whitespace-nowrap text-toned">
|
|
<span
|
|
class="inline-flex items-center gap-1 rounded-sm border border-default px-2 py-1"
|
|
>
|
|
<UIcon :name="extractorSourceIcon(item.source)" class="size-3.5 shrink-0" />
|
|
<span>{{ extractorSourceLabel(item.source) }}</span>
|
|
</span>
|
|
</td>
|
|
|
|
<td class="px-3 py-3 align-middle font-medium whitespace-nowrap text-default">
|
|
{{ item.active }}
|
|
</td>
|
|
|
|
<td class="px-3 py-3 align-middle font-medium whitespace-nowrap text-default">
|
|
{{ item.limit }}
|
|
</td>
|
|
|
|
<td class="px-3 py-3 align-middle whitespace-nowrap text-toned">
|
|
{{ item.available }}
|
|
</td>
|
|
|
|
<td class="px-3 py-3 align-middle whitespace-nowrap text-toned">
|
|
{{ item.queued }}
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onMounted, ref } from 'vue';
|
|
import moment from 'moment';
|
|
import { parse_api_error, parse_api_response, request } from '~/utils';
|
|
import type { SystemLimitsExtractor, SystemLimitsResponse } from '~/types/limits';
|
|
|
|
type DetailRow = {
|
|
label: string;
|
|
description: string;
|
|
value: string;
|
|
meta?: string;
|
|
};
|
|
|
|
const limits = ref<SystemLimitsResponse | null>(null);
|
|
const limitsLoading = ref(false);
|
|
const limitsError = ref('');
|
|
|
|
const formatDuration = (seconds: number): string => {
|
|
return moment.duration(seconds, 'seconds').humanize();
|
|
};
|
|
|
|
const extractorSourceLabel = (source: string): string => {
|
|
return source === 'env_override' ? 'Override' : 'Default';
|
|
};
|
|
|
|
const extractorSourceIcon = (source: string): string => {
|
|
return source === 'env_override' ? 'i-lucide-settings-2' : 'i-lucide-circle-check-big';
|
|
};
|
|
|
|
const capacityRows = computed<Array<DetailRow>>(() => {
|
|
if (!limits.value) {
|
|
return [];
|
|
}
|
|
|
|
const { downloads } = limits.value;
|
|
const { global } = downloads;
|
|
|
|
return [
|
|
{
|
|
label: 'Regular workers',
|
|
description: 'Slots available.',
|
|
value: `${global.active}/${global.limit}`,
|
|
meta: `${global.available} available`,
|
|
},
|
|
{
|
|
label: 'Waiting queue',
|
|
description: 'Downloads waiting for worker slot.',
|
|
value: `${global.queued}`,
|
|
meta: 'items queued',
|
|
},
|
|
{
|
|
label: 'Live downloads',
|
|
description: 'Streams bypass the worker slots limits.',
|
|
value: `${global.live_active}`,
|
|
meta: 'live items',
|
|
},
|
|
];
|
|
});
|
|
|
|
const extractionRows = computed<Array<DetailRow>>(() => {
|
|
if (!limits.value) {
|
|
return [];
|
|
}
|
|
|
|
return [
|
|
{
|
|
label: 'Concurrent requests',
|
|
description: 'Maximum info extraction jobs running at once.',
|
|
value: `${limits.value.extraction.concurrency}`,
|
|
},
|
|
{
|
|
label: 'Request timeout',
|
|
description: 'Per extraction attempt before the backend gives up.',
|
|
value: `${limits.value.extraction.timeout_seconds}s`,
|
|
},
|
|
{
|
|
label: 'Cached info TTL',
|
|
description: 'How long extracted info can be reused before refresh.',
|
|
value: formatDuration(limits.value.extraction.info_cache_ttl_seconds),
|
|
meta: `${limits.value.extraction.info_cache_ttl_seconds} seconds`,
|
|
},
|
|
];
|
|
});
|
|
|
|
const premiereRows = computed<Array<DetailRow>>(() => {
|
|
if (!limits.value) {
|
|
return [];
|
|
}
|
|
|
|
return [
|
|
{
|
|
label: 'Initial premiere capture',
|
|
description: 'Whether the first premiere capture is delayed until the buffer window passes.',
|
|
value: limits.value.live.prevent_premiere ? 'Delayed' : 'Immediate',
|
|
},
|
|
{
|
|
label: 'Premiere buffer',
|
|
description: 'Extra time added after the scheduled start before download begins.',
|
|
value: `${limits.value.live.premiere_buffer_minutes}m`,
|
|
meta: `${limits.value.live.premiere_buffer_minutes} minute buffer`,
|
|
},
|
|
];
|
|
});
|
|
|
|
const extractorItems = computed<Array<SystemLimitsExtractor>>(() => {
|
|
return limits.value?.downloads.per_extractor.items ?? [];
|
|
});
|
|
|
|
const trackedExtractorCount = computed(() => extractorItems.value.length);
|
|
|
|
const loadLimits = async (force: boolean = false): Promise<void> => {
|
|
if (limitsLoading.value) {
|
|
return;
|
|
}
|
|
|
|
if (limits.value && !force) {
|
|
return;
|
|
}
|
|
|
|
limitsError.value = '';
|
|
|
|
try {
|
|
limitsLoading.value = true;
|
|
|
|
const response = await request('/api/system/limits');
|
|
|
|
if (!response.ok) {
|
|
try {
|
|
limitsError.value = await parse_api_error(response.clone().json());
|
|
} catch {
|
|
limitsError.value = response.statusText || 'Failed to load limits.';
|
|
}
|
|
return;
|
|
}
|
|
|
|
limits.value = await parse_api_response<SystemLimitsResponse>(response.json());
|
|
} catch (e) {
|
|
limitsError.value = e instanceof Error ? e.message : 'Failed to load limits.';
|
|
} finally {
|
|
limitsLoading.value = false;
|
|
}
|
|
};
|
|
|
|
onMounted(() => {
|
|
void loadLimits(true);
|
|
});
|
|
</script>
|