From 4184705b14ffdb13cfd1b8d2dded5e36e4355178 Mon Sep 17 00:00:00 2001 From: DecDuck Date: Sat, 7 Jun 2025 15:39:01 +1000 Subject: [PATCH] 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 --- i18n/locales/en_us.json | 29 ++- pages/admin/task/[id]/index.vue | 155 +++++++------- pages/admin/task/index.vue | 201 +++++++++++++++++- .../migration.sql | 14 ++ .../migration.sql | 8 + prisma/models/task.prisma | 15 ++ server/api/v1/admin/task/index.get.ts | 35 +++ server/internal/acls/descriptions.ts | 6 + server/internal/acls/index.ts | 5 + server/internal/tasks/index.ts | 95 +++++++-- server/internal/tasks/registry/invitations.ts | 2 +- server/internal/tasks/registry/objects.ts | 2 +- server/internal/tasks/registry/sessions.ts | 2 +- server/internal/tasks/registry/update.ts | 2 +- server/tasks/dailyTasks.ts | 2 +- 15 files changed, 468 insertions(+), 105 deletions(-) create mode 100644 prisma/migrations/20250606013242_add_tasks_to_database/migration.sql create mode 100644 prisma/migrations/20250606023802_add_name_to_task/migration.sql create mode 100644 prisma/models/task.prisma create mode 100644 server/api/v1/admin/task/index.get.ts diff --git a/i18n/locales/en_us.json b/i18n/locales/en_us.json index cb513a4..a67470f 100644 --- a/i18n/locales/en_us.json +++ b/i18n/locales/en_us.json @@ -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!" } diff --git a/pages/admin/task/[id]/index.vue b/pages/admin/task/[id]/index.vue index 0ba3ec0..466ca83 100644 --- a/pages/admin/task/[id]/index.vue +++ b/pages/admin/task/[id]/index.vue @@ -1,80 +1,85 @@ diff --git a/pages/admin/task/index.vue b/pages/admin/task/index.vue index c4e2767..6a9dbfd 100644 --- a/pages/admin/task/index.vue +++ b/pages/admin/task/index.vue @@ -1,7 +1,174 @@ diff --git a/prisma/migrations/20250606013242_add_tasks_to_database/migration.sql b/prisma/migrations/20250606013242_add_tasks_to_database/migration.sql new file mode 100644 index 0000000..e3aa449 --- /dev/null +++ b/prisma/migrations/20250606013242_add_tasks_to_database/migration.sql @@ -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") +); diff --git a/prisma/migrations/20250606023802_add_name_to_task/migration.sql b/prisma/migrations/20250606023802_add_name_to_task/migration.sql new file mode 100644 index 0000000..db4f781 --- /dev/null +++ b/prisma/migrations/20250606023802_add_name_to_task/migration.sql @@ -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; diff --git a/prisma/models/task.prisma b/prisma/models/task.prisma new file mode 100644 index 0000000..6d3aed1 --- /dev/null +++ b/prisma/models/task.prisma @@ -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[] +} diff --git a/server/api/v1/admin/task/index.get.ts b/server/api/v1/admin/task/index.get.ts new file mode 100644 index 0000000..b9b7bdd --- /dev/null +++ b/server/api/v1/admin/task/index.get.ts @@ -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 }; +}); diff --git a/server/internal/acls/descriptions.ts b/server/internal/acls/descriptions.ts index 80c1b92..762a355 100644 --- a/server/internal/acls/descriptions.ts +++ b/server/internal/acls/descriptions.ts @@ -80,4 +80,10 @@ export const systemACLDescriptions: ObjectFromList = { "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.", }; diff --git a/server/internal/acls/index.ts b/server/internal/acls/index.ts index 9f96b5b..f37451c 100644 --- a/server/internal/acls/index.ts +++ b/server/internal/acls/index.ts @@ -74,6 +74,11 @@ export const systemACLs = [ "news:read", "news:create", "news:delete", + + "task:read", + "task:start", + + "maintenance:read", ] as const; const systemACLPrefix = "system:"; diff --git a/server/internal/tasks/index.ts b/server/internal/tasks/index.ts index 61d26a6..599f9eb 100644 --- a/server/internal/tasks/index.ts +++ b/server/internal/tasks/index.ts @@ -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 Task> = new Map(); - // list of all finished tasks - private finishedTasks: Map = new Map(); + private taskCreators: Map Task> = new Map(); // list of all currently running tasks private taskPool = new Map(); // list of all clients currently connected to tasks private clientRegistry = new Map(); + 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); - task.clients.set(clientId, true); // Uniquely insert client to avoid sending duplicate traffic + 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; - acls: string[]; + acls: GlobalACL[]; } export type TaskMessage = { @@ -304,7 +359,7 @@ export interface BuildTask { taskGroup: TaskGroup; name: string; run: (context: TaskRunContext) => Promise; - acls: string[]; + acls: GlobalACL[]; } interface DropTask { diff --git a/server/internal/tasks/registry/invitations.ts b/server/internal/tasks/registry/invitations.ts index 4d809ac..7cac166 100644 --- a/server/internal/tasks/registry/invitations.ts +++ b/server/internal/tasks/registry/invitations.ts @@ -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"); diff --git a/server/internal/tasks/registry/objects.ts b/server/internal/tasks/registry/objects.ts index 3d90170..a59e409 100644 --- a/server/internal/tasks/registry/objects.ts +++ b/server/internal/tasks/registry/objects.ts @@ -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"); diff --git a/server/internal/tasks/registry/sessions.ts b/server/internal/tasks/registry/sessions.ts index 17b7f80..99372c2 100644 --- a/server/internal/tasks/registry/sessions.ts +++ b/server/internal/tasks/registry/sessions.ts @@ -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"); diff --git a/server/internal/tasks/registry/update.ts b/server/internal/tasks/registry/update.ts index 814c4e8..39d4134 100644 --- a/server/internal/tasks/registry/update.ts +++ b/server/internal/tasks/registry/update.ts @@ -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? diff --git a/server/tasks/dailyTasks.ts b/server/tasks/dailyTasks.ts index 3088e49..e36735c 100644 --- a/server/tasks/dailyTasks.ts +++ b/server/tasks/dailyTasks.ts @@ -5,7 +5,7 @@ export default defineTask({ name: "dailyTasks", }, async run() { - taskHandler.triggerDailyTasks(); + await taskHandler.triggerDailyTasks(); return {}; },