Task groups & viewer in admin panel #52 (#91)

* feat: historical tasks in database, better scheduling, and unified API for accessing tasks

* feat: new UI for everything

* fix: add translations and fix formatting
This commit is contained in:
DecDuck
2025-06-07 15:39:01 +10:00
committed by GitHub
parent d976ac87e3
commit 4184705b14
15 changed files with 468 additions and 105 deletions

View File

@ -404,6 +404,31 @@
"search": "Search library...",
"subheader": "Organize your games into collections for easy access, and access all your games."
},
"tasks": {
"admin": {
"scheduled": {
"cleanupInvitationsName": "Clean up invitations",
"cleanupInvitationsDescription": "Cleans up expired invitations from the database to save space.",
"cleanupObjectsName": "Clean up objects",
"cleanupObjectsDescription": "Detects and deletes unreferenced and unused objects to save space.",
"cleanupSessionsName": "Clean up sessions.",
"cleanupSessionsDescription": "Cleans up expired sessions to save space and ensure security.",
"checkUpdateName": "Check update.",
"checkUpdateDescription": "Check if Drop has an update."
},
"runningTasksTitle": "Running tasks",
"noTasksRunning": "No tasks currently running",
"completedTasksTitle": "Completed tasks",
"dailyScheduledTitle": "Daily scheduled tasks",
"viewTask": "View {arrow}",
"back": "{arrow} Back to Tasks"
}
},
"lowest": "lowest",
"name": "Name",
"news": {
@ -478,10 +503,6 @@
"settings": "Account settings"
}
},
"task": {
"successful": "Successful!",
"successfulDescription": "\"{0}\" completed successfully"
},
"todo": "Todo",
"welcome": "American, Welcome!"
}

View File

@ -1,24 +1,18 @@
<template>
<div
v-if="task && task.success"
class="grow w-full flex items-center justify-center"
<div>
<NuxtLink
to="/admin/task"
class="mb-2 transition text-sm/6 font-semibold text-zinc-400 hover:text-zinc-100 inline-flex gap-x-2 items-center duration-200 hover:scale-105"
>
<div class="flex flex-col items-center">
<CheckCircleIcon class="h-12 w-12 text-green-600" aria-hidden="true" />
<div class="mt-3 text-center sm:mt-5">
<h1 class="text-3xl font-semibold font-display leading-6 text-zinc-100">
{{ $t("task.successful") }}
</h1>
<div class="mt-4">
<p class="text-sm text-zinc-400 max-w-md">
{{ $t("task.successfulDescription", [task.name]) }}
</p>
</div>
</div>
</div>
</div>
<i18n-t keypath="tasks.admin.back" tag="span" scope="global">
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrowBack") }}</span>
</template>
</i18n-t>
</NuxtLink>
<div
v-else-if="task && task.error"
v-if="task && task.error"
class="grow w-full flex items-center justify-center"
>
<div class="flex flex-col items-center">
@ -27,7 +21,9 @@
aria-hidden="true"
/>
<div class="mt-3 text-center sm:mt-5">
<h1 class="text-3xl font-semibold font-display leading-6 text-zinc-100">
<h1
class="text-3xl font-semibold font-display leading-6 text-zinc-100"
>
{{ task.error.title }}
</h1>
<div class="mt-4">
@ -39,17 +35,25 @@
</div>
</div>
<div v-else-if="task" class="flex flex-col w-full gap-y-4">
<h1 class="text-3xl text-zinc-100 font-bold font-display">
<h1
class="inline-flex items-center gap-x-3 text-3xl text-zinc-100 font-bold font-display"
>
<div>
<CheckCircleIcon v-if="task.success" class="size-5 text-green-600" />
<div v-else class="size-4 bg-blue-600 rounded-full animate-pulse" />
</div>
{{ task.name }}
</h1>
<div class="h-3 rounded-full bg-zinc-950 overflow-hidden">
<div class="h-2 rounded-full bg-zinc-950 overflow-hidden">
<div
:style="{ width: `${task.progress}%` }"
class="transition-all bg-blue-600 h-full"
/>
</div>
<div class="bg-zinc-950/50 rounded-md p-2 text-zinc-100">
<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">{{ line }}</pre>
</div>
</div>
@ -76,6 +80,7 @@
</svg>
<span class="sr-only">{{ $t("common.srLoading") }}</span>
</div>
</div>
</template>
<script setup lang="ts">

View File

@ -1,7 +1,174 @@
<template>
<div class="text-gray-100">{{ $t("todo") }}</div>
<div>
<div>
<h2 class="text-sm font-medium text-zinc-400">
{{ $t("tasks.admin.runningTasksTitle") }}
</h2>
<ul
role="list"
class="mt-4 grid grid-cols-1 gap-6 sm:grid-cols-3 lg:grid-cols-4"
>
<li
v-for="task in liveRunningTasks"
: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">
{{ task.value.log.at(-1) }}
</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>
</li>
</ul>
<div
v-if="liveRunningTasks.length == 0"
class="text-zinc-500 text-sm font-semibold"
>
{{ $t("tasks.admin.noTasksRunning") }}
</div>
</div>
<div class="mt-6 w-full grid lg:grid-cols-2 gap-8">
<div>
<h2 class="text-sm font-medium text-zinc-400">
{{ $t("tasks.admin.completedTasksTitle") }}
</h2>
<ul role="list" class="mt-4 grid grid-cols-1 gap-6 lg:grid-cols-2">
<li
v-for="task in historicalTasks"
: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">
{{ task.log.at(-1) }}
</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>
</li>
</ul>
</div>
<div>
<h2 class="text-sm font-medium text-zinc-400">
{{ $t("tasks.admin.dailyScheduledTitle") }}
</h2>
<ul role="list" class="mt-4 grid grid-cols-1 lg:grid-cols-2 gap-6">
<li
v-for="task in dailyTasks"
:key="task"
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">
<div class="flex items-center space-x-2">
<h3 class="text-sm font-medium text-zinc-100">
{{ dailyScheduledTasks[task].name }}
</h3>
</div>
<p class="mt-1 text-sm text-zinc-400">
{{ dailyScheduledTasks[task].description }}
</p>
</div>
</div>
</li>
</ul>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { CheckIcon, XMarkIcon } from "@heroicons/vue/24/outline";
import type { TaskGroup } from "~/server/internal/tasks/group";
useHead({
title: "Tasks",
});
@ -9,4 +176,36 @@ useHead({
definePageMeta({
layout: "admin",
});
const { t } = useI18n();
const { runningTasks, historicalTasks, dailyTasks } =
await $dropFetch("/api/v1/admin/task");
const liveRunningTasks = await Promise.all(runningTasks.map((e) => useTask(e)));
const dailyScheduledTasks: {
[key in TaskGroup]: { name: string; description: string };
} = {
"cleanup:invitations": {
name: t("tasks.admin.scheduled.cleanupInvitationsName"),
description: t("tasks.admin.scheduled.cleanupInvitationsDescription"),
},
"cleanup:objects": {
name: t("tasks.admin.scheduled.cleanupObjectsName"),
description: t("tasks.admin.scheduled.cleanupObjectsDescription"),
},
"cleanup:sessions": {
name: t("tasks.admin.scheduled.cleanupSessionsName"),
description: t("tasks.admin.scheduled.cleanupSessionsDescription"),
},
"check:update": {
name: t("tasks.admin.scheduled.checkUpdateName"),
description: t("tasks.admin.scheduled.checkUpdateDescription"),
},
"import:game": {
name: "",
description: "",
},
};
</script>

View File

@ -0,0 +1,14 @@
-- CreateTable
CREATE TABLE "Task" (
"id" TEXT NOT NULL,
"taskGroup" TEXT NOT NULL,
"started" TIMESTAMP(3) NOT NULL,
"ended" TIMESTAMP(3) NOT NULL,
"success" BOOLEAN NOT NULL,
"error" JSONB,
"progress" DOUBLE PRECISION NOT NULL,
"log" TEXT[],
"acls" TEXT[],
CONSTRAINT "Task_pkey" PRIMARY KEY ("id")
);

View File

@ -0,0 +1,8 @@
/*
Warnings:
- Added the required column `name` to the `Task` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Task" ADD COLUMN "name" TEXT NOT NULL;

15
prisma/models/task.prisma Normal file
View File

@ -0,0 +1,15 @@
model Task {
id String @id
taskGroup String
name String
started DateTime
ended DateTime
success Boolean
error Json?
progress Float
log String[]
acls String[]
}

View File

@ -0,0 +1,35 @@
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
import taskHandler from "~/server/internal/tasks";
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["task:read"]);
if (!allowed) throw createError({ statusCode: 403 });
const allAcls = await aclManager.fetchAllACLs(h3);
if (!allAcls)
throw createError({
statusCode: 403,
statusMessage: "Somehow no ACLs on authenticated request.",
});
const runningTasks = (await taskHandler.runningTasks()).map((e) => e.id);
const historicalTasks = await prisma.task.findMany({
where: {
OR: [
{
acls: { hasSome: allAcls },
},
{
acls: { isEmpty: true },
},
],
},
orderBy: {
ended: "desc",
},
take: 10,
});
const dailyTasks = await taskHandler.dailyTasks();
return { runningTasks, historicalTasks, dailyTasks };
});

View File

@ -80,4 +80,10 @@ export const systemACLDescriptions: ObjectFromList<typeof systemACLs> = {
"news:read": "Read news articles.",
"news:create": "Create a new news article.",
"news:delete": "Delete a news article.",
"task:read": "Read all tasks currently running on server.",
"task:start": "Manually execute scheduled tasks.",
"maintenance:read":
"Read tasks and maintenance information, like updates available and cleanup.",
};

View File

@ -74,6 +74,11 @@ export const systemACLs = [
"news:read",
"news:create",
"news:delete",
"task:read",
"task:start",
"maintenance:read",
] as const;
const systemACLPrefix = "system:";

View File

@ -1,5 +1,6 @@
import droplet from "@drop-oss/droplet";
import type { MinimumRequestObject } from "~/server/h3";
import type { GlobalACL } from "../acls";
import aclManager from "../acls";
import cleanupInvites from "./registry/invitations";
@ -7,6 +8,7 @@ import cleanupSessions from "./registry/sessions";
import checkUpdate from "./registry/update";
import cleanupObjects from "./registry/objects";
import { taskGroups, type TaskGroup } from "./group";
import prisma from "../db/database";
// a task that has been run
type FinishedTask = {
@ -36,15 +38,19 @@ type TaskPoolEntry = FinishedTask & {
*/
class TaskHandler {
// registry of schedualed tasks to be created
private scheduledTasks: Map<TaskGroup, () => Task> = new Map();
// list of all finished tasks
private finishedTasks: Map<string, FinishedTask> = new Map();
private taskCreators: Map<TaskGroup, () => Task> = new Map();
// list of all currently running tasks
private taskPool = new Map<string, TaskPoolEntry>();
// list of all clients currently connected to tasks
private clientRegistry = new Map<string, PeerImpl>();
private scheduledTasks: TaskGroup[] = [
"cleanup:invitations",
"cleanup:sessions",
"check:update",
];
constructor() {
// register the cleanup invitations task
this.saveScheduledTask(cleanupInvites);
@ -58,7 +64,7 @@ class TaskHandler {
* @param createTask
*/
private saveScheduledTask(task: DropTask) {
this.scheduledTasks.set(task.taskGroup, task.build);
this.taskCreators.set(task.taskGroup, task.build);
}
create(task: Task) {
@ -129,7 +135,7 @@ class TaskHandler {
const taskEntry = this.taskPool.get(task.id);
if (!taskEntry) return;
taskEntry.log.push(entry);
console.log(`[Task ${task.taskGroup}]: ${entry}`);
// console.log(`[Task ${task.taskGroup}]: ${entry}`);
updateAllClients();
};
@ -171,10 +177,25 @@ class TaskHandler {
this.disconnect(clientId, task.id);
}
// so we can drop the clients from the task entry
const { clients, ...copied } = taskEntry;
this.finishedTasks.set(task.id, {
...copied,
await prisma.task.create({
data: {
id: task.id,
taskGroup: taskEntry.taskGroup,
name: taskEntry.name,
started: taskEntry.startTime,
ended: taskEntry.endTime,
success: taskEntry.success,
progress: taskEntry.progress,
log: taskEntry.log,
acls: taskEntry.acls,
...(taskEntry.error
? { error: JSON.stringify(taskEntry.error) }
: undefined),
},
});
this.taskPool.delete(task.id);
@ -187,7 +208,9 @@ class TaskHandler {
peer: PeerImpl,
request: MinimumRequestObject,
) {
const task = this.taskPool.get(taskId);
const task =
this.taskPool.get(taskId) ??
(await prisma.task.findUnique({ where: { id: taskId } }));
if (!task) {
peer.send(
`error/${taskId}/Unknown task/Drop couldn't find the task you're looking for.`,
@ -205,13 +228,17 @@ class TaskHandler {
}
this.clientRegistry.set(clientId, peer);
if ("clients" in task) {
task.clients.set(clientId, true); // Uniquely insert client to avoid sending duplicate traffic
}
const catchupMessage: TaskMessage = {
id: taskId,
name: task.name,
success: task.success,
error: task.error,
error: task.error as unknown as
| { title: string; description: string }
| undefined,
log: task.log,
progress: task.progress,
};
@ -253,8 +280,19 @@ class TaskHandler {
return true;
}
runningTasks() {
return this.taskPool
.entries()
.map(([id, value]) => ({ ...value, id, log: undefined }))
.toArray();
}
dailyTasks() {
return this.scheduledTasks;
}
runTaskGroupByName(name: TaskGroup) {
const task = this.scheduledTasks.get(name);
const task = this.taskCreators.get(name);
if (!task) {
console.warn(`No task found for group ${name}`);
return;
@ -262,13 +300,30 @@ class TaskHandler {
this.create(task());
}
/**]
/**
* Runs all daily tasks that are scheduled to run once a day.
*/
triggerDailyTasks() {
this.runTaskGroupByName("cleanup:invitations");
this.runTaskGroupByName("cleanup:sessions");
this.runTaskGroupByName("check:update");
async triggerDailyTasks() {
for (const taskGroup of this.scheduledTasks) {
const mostRecent = await prisma.task.findFirst({
where: {
taskGroup,
},
orderBy: {
ended: "desc",
},
});
if (mostRecent) {
const currentTime = Date.now();
const lastRun = mostRecent.ended.getTime();
const difference = currentTime - lastRun;
if (difference < 1000 * 60 * 60 * 24) {
// If it's been less than one day
continue; // skip
}
}
await this.runTaskGroupByName(taskGroup);
}
}
}
@ -282,7 +337,7 @@ export interface Task {
taskGroup: TaskGroup;
name: string;
run: (context: TaskRunContext) => Promise<void>;
acls: string[];
acls: GlobalACL[];
}
export type TaskMessage = {
@ -304,7 +359,7 @@ export interface BuildTask {
taskGroup: TaskGroup;
name: string;
run: (context: TaskRunContext) => Promise<void>;
acls: string[];
acls: GlobalACL[];
}
interface DropTask {

View File

@ -4,7 +4,7 @@ import { defineDropTask } from "..";
export default defineDropTask({
buildId: () => `cleanup:invitations:${new Date().toISOString()}`,
name: "Cleanup Invitations",
acls: [],
acls: ["system:maintenance:read"],
taskGroup: "cleanup:invitations",
async run({ log }) {
log("Cleaning invitations");

View File

@ -13,7 +13,7 @@ type FieldReferenceMap = {
export default defineDropTask({
buildId: () => `cleanup:objects:${new Date().toISOString()}`,
name: "Cleanup Objects",
acls: [],
acls: ["system:maintenance:read"],
taskGroup: "cleanup:objects",
async run({ progress, log }) {
log("Cleaning unreferenced objects");

View File

@ -4,7 +4,7 @@ import { defineDropTask } from "..";
export default defineDropTask({
buildId: () => `cleanup:sessions:${new Date().toISOString()}`,
name: "Cleanup Sessions",
acls: [],
acls: ["system:maintenance:read"],
taskGroup: "cleanup:sessions",
async run({ log }) {
log("Cleaning up sessions");

View File

@ -19,7 +19,7 @@ const latestRelease = type({
export default defineDropTask({
buildId: () => `check:update:${new Date().toISOString()}`,
name: "Check for Update",
acls: [],
acls: ["system:maintenance:read"],
taskGroup: "check:update",
async run({ log }) {
// TODO: maybe implement some sort of rate limit thing to prevent this from calling github api a bunch in the event of crashloop or whatever?

View File

@ -5,7 +5,7 @@ export default defineTask({
name: "dailyTasks",
},
async run() {
taskHandler.triggerDailyTasks();
await taskHandler.triggerDailyTasks();
return {};
},