Admin task UI update & QoL (#194)

* feat: revise library source names & update droplet

* feat: add internal name hint to library sources

* feat: update library source table with new name + icons

* fix: admin invitation localisation issue

* feat: #164

* feat: overhaul task UIs, #163

* fix: remove debug task

* fix: lint
This commit is contained in:
DecDuck
2025-08-19 15:03:20 +10:00
committed by GitHub
parent 6baddc10e9
commit 6d89b7e510
18 changed files with 420 additions and 215 deletions

View File

@ -242,11 +242,40 @@
{{ $t("common.noResults") }}
</p>
<p
v-if="filteredLibraryGames.length == 0 && libraryGames.length == 0"
v-if="
filteredLibraryGames.length == 0 &&
libraryGames.length == 0 &&
libraryState.hasLibraries
"
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
>
{{ $t("library.admin.noGames") }}
</p>
<p
v-else-if="!libraryState.hasLibraries"
class="flex flex-col gap-2 text-zinc-600 text-center col-span-4"
>
<span class="text-sm font-display font-bold uppercase">{{
$t("library.admin.libraryHint")
}}</span>
<NuxtLink
class="transition text-xs text-zinc-600 hover:underline hover:text-zinc-400"
href="https://docs.droposs.org/docs/library"
target="_blank"
>
<i18n-t
keypath="library.admin.libraryHintDocsLink"
tag="span"
scope="global"
class="inline-flex items-center gap-x-1"
>
<template #arrow>
<ArrowTopRightOnSquareIcon class="size-4" />
</template>
</i18n-t>
</NuxtLink>
</p>
</ul>
</div>
</template>
@ -256,7 +285,11 @@ import {
ExclamationTriangleIcon,
ExclamationCircleIcon,
} from "@heroicons/vue/16/solid";
import { InformationCircleIcon, StarIcon } from "@heroicons/vue/20/solid";
import {
ArrowTopRightOnSquareIcon,
InformationCircleIcon,
StarIcon,
} from "@heroicons/vue/20/solid";
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline";
const { t } = useI18n();

View File

@ -64,8 +64,14 @@
>
{{ source.name }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
{{ source.backend }}
<td
class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400 inline-flex gap-x-1 items-center"
>
<component
:is="optionsMetadata[source.backend].icon"
class="size-5 text-zinc-400"
/>
{{ optionsMetadata[source.backend].title }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
<CheckIcon
@ -189,11 +195,34 @@
<RadioGroupLabel
as="span"
class="font-semibold text-zinc-100"
>{{ source }}</RadioGroupLabel
>{{ metadata.title }}
<span class="ml-2 font-mono text-zinc-500 text-xs">{{
source
}}</span></RadioGroupLabel
>
<RadioGroupDescription
as="span"
class="text-zinc-400 text-xs"
>
<RadioGroupDescription as="span" class="text-zinc-400">
{{ metadata.description }}
</RadioGroupDescription>
<NuxtLink
:href="metadata.docsLink"
:external="true"
target="_blank"
class="mt-2 block w-fit rounded-md bg-blue-600 px-2 py-1 text-center text-xs font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
<i18n-t
keypath="library.admin.sources.documentationLink"
tag="span"
scope="global"
class="inline-flex items-center gap-x-1"
>
<template #arrow>
<ArrowTopRightOnSquareIcon class="size-4" />
</template>
</i18n-t>
</NuxtLink>
</span>
</span>
<span
@ -269,6 +298,7 @@
*/
import {
DropLogo,
SourceOptionsFilesystem,
SourceOptionsFlatFilesystem,
} from "#components";
@ -279,8 +309,11 @@ import {
RadioGroupLabel,
RadioGroupOption,
} from "@headlessui/vue";
import { XCircleIcon } from "@heroicons/vue/20/solid";
import { CheckIcon, DocumentIcon, XMarkIcon } from "@heroicons/vue/24/outline";
import {
XCircleIcon,
ArrowTopRightOnSquareIcon,
} from "@heroicons/vue/20/solid";
import { BackwardIcon, CheckIcon, XMarkIcon } from "@heroicons/vue/24/outline";
import { FetchError } from "ofetch";
import type { Component } from "vue";
import type { LibraryBackend } from "~/prisma/client/enums";
@ -324,17 +357,23 @@ const optionUIs: { [key in LibraryBackend]: Component } = {
};
const optionsMetadata: {
[key in LibraryBackend]: {
title: string;
description: string;
docsLink: string;
icon: Component;
};
} = {
Filesystem: {
title: t("library.admin.sources.fsTitle"),
description: t("library.admin.sources.fsDesc"),
icon: DocumentIcon,
docsLink: "https://docs.droposs.org/docs/library#drop-style",
icon: DropLogo,
},
FlatFilesystem: {
title: t("library.admin.sources.fsFlatTitle"),
description: t("library.admin.sources.fsFlatDesc"),
icon: DocumentIcon,
docsLink: "https://docs.droposs.org/docs/library#flat-style-or-compat",
icon: BackwardIcon,
},
};
const optionsMetadataIter = Object.entries(optionsMetadata);

View File

@ -44,19 +44,26 @@
</div>
{{ task.name }}
</h1>
<div class="h-2 rounded-full bg-zinc-950 overflow-hidden">
<div
class="bg-zinc-950 p-2 rounded-md h-[80vh] flex flex-col flex-col-reverse overflow-y-scroll gap-y-1"
>
<LogLine
v-for="(_, idx) in task.log"
:key="idx"
:log="parseTaskLog(task.log.at(-(idx + 1)))"
/>
</div>
<div class="relative h-5 rounded-xl bg-zinc-950 overflow-hidden">
<div
:style="{ width: `${task.progress}%` }"
class="transition-all bg-blue-600 h-full"
/>
</div>
<div
class="relative bg-zinc-950/50 rounded-md p-2 text-zinc-100 h-[80vh] overflow-y-scroll"
>
<pre v-for="(line, idx) in task.log" :key="idx">{{
formatLine(line)
}}</pre>
<span
class="absolute inset-0 flex items-center justify-center text-blue-200 text-sm font-bold font-display"
>{{
$t("tasks.admin.progress", [Math.round(task.progress * 10) / 10])
}}</span
>
</div>
</div>
<div v-else role="status" class="w-full flex items-center justify-center">
@ -90,11 +97,6 @@ const taskId = route.params.id.toString();
const task = useTask(taskId);
function formatLine(line: string): string {
const res = parseTaskLog(line);
return `[${res.timestamp}] ${res.message}`;
}
definePageMeta({
layout: "admin",
});

View File

@ -13,62 +13,7 @@
:key="task.value?.id"
class="col-span-1 divide-y divide-gray-200 rounded-lg bg-zinc-800 border border-zinc-700 shadow-sm"
>
<div
v-if="task.value"
class="flex w-full items-center justify-between space-x-6 p-6"
>
<div class="flex-1 truncate">
<div class="flex items-center space-x-2">
<div>
<CheckIcon
v-if="task.value.success"
class="size-4 text-green-600"
/>
<XMarkIcon
v-else-if="task.value.error"
class="size-4 text-red-600"
/>
<div
v-else
class="size-2 bg-blue-600 rounded-full animate-pulse"
/>
</div>
<h3 class="truncate text-sm font-medium text-zinc-100">
{{ task.value.name }}
</h3>
</div>
<p class="text-xs text-zinc-600 mt-0.5 font-mono">
{{ task.value.id }}
</p>
<div class="mt-1 w-full rounded-full overflow-hidden bg-zinc-900">
<div
:style="{ width: `${task.value.progress}%` }"
class="bg-blue-600 h-1.5 transition-all"
/>
</div>
<p class="mt-1 truncate text-sm text-zinc-400">
{{ parseTaskLog(task.value.log.at(-1)).message }}
</p>
<NuxtLink
type="button"
:href="`/admin/task/${task.value.id}`"
class="mt-3 rounded-md text-xs font-medium text-zinc-100 hover:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-100 focus:ring-offset-2"
>
<i18n-t
keypath="tasks.admin.viewTask"
tag="span"
scope="global"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
</div>
</div>
<div v-else>
<!-- renders server side when we don't want to access the current tasks -->
</div>
<TaskWidget :task="task.value" :active="true" />
</li>
</ul>
<div
@ -89,51 +34,7 @@
:key="task.id"
class="col-span-1 divide-y divide-gray-200 rounded-lg bg-zinc-800 border border-zinc-700 shadow-sm"
>
<div class="flex w-full items-center justify-between space-x-6 p-6">
<div class="flex-1 truncate">
<div class="flex items-center space-x-2">
<div>
<CheckIcon
v-if="task.success"
class="size-4 text-green-600"
/>
<XMarkIcon
v-else-if="task.error"
class="size-4 text-red-600"
/>
<div
v-else
class="size-2 bg-blue-600 rounded-full animate-pulse"
/>
</div>
<h3 class="truncate text-sm font-medium text-zinc-100">
{{ task.name }}
</h3>
<RelativeTime class="text-zinc-500" :date="task.ended" />
</div>
<p class="text-xs text-zinc-600 mt-0.5 font-mono">
{{ task.id }}
</p>
<p class="mt-1 truncate text-sm text-zinc-400">
{{ parseTaskLog(task.log.at(-1)).message }}
</p>
<NuxtLink
type="button"
:href="`/admin/task/${task.id}`"
class="mt-3 rounded-md text-xs font-medium text-zinc-100 hover:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-100 focus:ring-offset-2"
>
<i18n-t
keypath="tasks.admin.viewTask"
tag="span"
scope="global"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
</div>
</div>
<TaskWidget :task="task" />
</li>
</ul>
</div>
@ -157,6 +58,21 @@
<p class="mt-1 text-sm text-zinc-400">
{{ scheduledTasks[task].description }}
</p>
<button
class="mt-3 rounded-md text-xs font-medium text-zinc-100 hover:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-100 focus:ring-offset-2"
@click="() => startTask(task)"
>
<i18n-t
keypath="tasks.admin.execute"
tag="span"
scope="global"
class="inline-flex items-center gap-x-1"
>
<template #arrow>
<PlayIcon class="size-4" aria-hidden="true" />
</template>
</i18n-t>
</button>
</div>
</div>
</li>
@ -180,6 +96,21 @@
<p class="mt-1 text-sm text-zinc-400">
{{ scheduledTasks[task].description }}
</p>
<button
class="mt-3 rounded-md text-xs font-medium text-zinc-100 hover:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-100 focus:ring-offset-2"
@click="() => startTask(task)"
>
<i18n-t
keypath="tasks.admin.execute"
tag="span"
scope="global"
class="inline-flex items-center gap-x-1"
>
<template #arrow>
<PlayIcon class="size-4" aria-hidden="true" />
</template>
</i18n-t>
</button>
</div>
</div>
</li>
@ -189,7 +120,7 @@
</div>
</template>
<script lang="ts" setup>
import { CheckIcon, XMarkIcon } from "@heroicons/vue/24/outline";
import { PlayIcon } from "@heroicons/vue/24/outline";
import type { TaskGroup } from "~/server/internal/tasks/group";
useHead({
@ -205,7 +136,9 @@ const { t } = useI18n();
const { runningTasks, historicalTasks, dailyTasks, weeklyTasks } =
await $dropFetch("/api/v1/admin/task");
const liveRunningTasks = await Promise.all(runningTasks.map((e) => useTask(e)));
const liveRunningTasks = ref(
await Promise.all(runningTasks.map((e) => useTask(e))),
);
const scheduledTasks: {
[key in TaskGroup]: { name: string; description: string };
@ -230,5 +163,19 @@ const scheduledTasks: {
name: "",
description: "",
},
debug: {
name: "Debug Task",
description: "Does debugging things.",
},
};
async function startTask(taskGroup: string) {
const task = await $dropFetch("/api/v1/admin/task", {
method: "POST",
body: { taskGroup },
failTitle: "Failed to start task",
});
const taskRef = await useTask(task.id);
liveRunningTasks.value.push(taskRef);
}
</script>