├── public ├── robots.txt └── favicon.ico ├── .dockerignore ├── app ├── components │ ├── ui │ │ ├── input │ │ │ ├── index.ts │ │ │ └── Input.vue │ │ ├── checkbox │ │ │ ├── index.ts │ │ │ └── Checkbox.vue │ │ ├── skeleton │ │ │ ├── index.ts │ │ │ └── Skeleton.vue │ │ ├── popover │ │ │ ├── index.ts │ │ │ ├── PopoverTrigger.vue │ │ │ ├── PopoverAnchor.vue │ │ │ ├── Popover.vue │ │ │ └── PopoverContent.vue │ │ ├── table │ │ │ ├── utils.ts │ │ │ ├── TableHeader.vue │ │ │ ├── TableBody.vue │ │ │ ├── TableCaption.vue │ │ │ ├── TableFooter.vue │ │ │ ├── TableRow.vue │ │ │ ├── Table.vue │ │ │ ├── TableHead.vue │ │ │ ├── TableCell.vue │ │ │ ├── index.ts │ │ │ └── TableEmpty.vue │ │ ├── select │ │ │ ├── SelectGroup.vue │ │ │ ├── SelectValue.vue │ │ │ ├── SelectItemText.vue │ │ │ ├── SelectLabel.vue │ │ │ ├── Select.vue │ │ │ ├── SelectSeparator.vue │ │ │ ├── index.ts │ │ │ ├── SelectScrollUpButton.vue │ │ │ ├── SelectScrollDownButton.vue │ │ │ ├── SelectItem.vue │ │ │ ├── SelectTrigger.vue │ │ │ └── SelectContent.vue │ │ ├── dialog │ │ │ ├── DialogClose.vue │ │ │ ├── DialogTrigger.vue │ │ │ ├── DialogFooter.vue │ │ │ ├── DialogHeader.vue │ │ │ ├── Dialog.vue │ │ │ ├── index.ts │ │ │ ├── DialogTitle.vue │ │ │ ├── DialogDescription.vue │ │ │ ├── DialogOverlay.vue │ │ │ ├── DialogScrollContent.vue │ │ │ └── DialogContent.vue │ │ ├── calendar │ │ │ ├── CalendarGridBody.vue │ │ │ ├── CalendarGridHead.vue │ │ │ ├── CalendarGridRow.vue │ │ │ ├── CalendarGrid.vue │ │ │ ├── CalendarHeader.vue │ │ │ ├── CalendarHeadCell.vue │ │ │ ├── index.ts │ │ │ ├── CalendarCell.vue │ │ │ ├── CalendarHeading.vue │ │ │ ├── CalendarNextButton.vue │ │ │ ├── CalendarPrevButton.vue │ │ │ ├── CalendarCellTrigger.vue │ │ │ └── Calendar.vue │ │ ├── card │ │ │ ├── CardContent.vue │ │ │ ├── CardTitle.vue │ │ │ ├── CardDescription.vue │ │ │ ├── CardFooter.vue │ │ │ ├── index.ts │ │ │ ├── CardAction.vue │ │ │ ├── Card.vue │ │ │ └── CardHeader.vue │ │ ├── dropdown-menu │ │ │ ├── DropdownMenuGroup.vue │ │ │ ├── DropdownMenuShortcut.vue │ │ │ ├── DropdownMenuTrigger.vue │ │ │ ├── DropdownMenu.vue │ │ │ ├── DropdownMenuSub.vue │ │ │ ├── DropdownMenuRadioGroup.vue │ │ │ ├── DropdownMenuSeparator.vue │ │ │ ├── DropdownMenuLabel.vue │ │ │ ├── index.ts │ │ │ ├── DropdownMenuSubTrigger.vue │ │ │ ├── DropdownMenuSubContent.vue │ │ │ ├── DropdownMenuItem.vue │ │ │ ├── DropdownMenuRadioItem.vue │ │ │ ├── DropdownMenuCheckboxItem.vue │ │ │ └── DropdownMenuContent.vue │ │ ├── range-calendar │ │ │ ├── RangeCalendarGridBody.vue │ │ │ ├── RangeCalendarGridHead.vue │ │ │ ├── RangeCalendarGridRow.vue │ │ │ ├── RangeCalendarGrid.vue │ │ │ ├── RangeCalendarHeader.vue │ │ │ ├── RangeCalendarHeadCell.vue │ │ │ ├── index.ts │ │ │ ├── RangeCalendarHeading.vue │ │ │ ├── RangeCalendarCell.vue │ │ │ ├── RangeCalendarPrevButton.vue │ │ │ ├── RangeCalendarNextButton.vue │ │ │ ├── RangeCalendarCellTrigger.vue │ │ │ └── RangeCalendar.vue │ │ └── button │ │ │ ├── Button.vue │ │ │ └── index.ts │ ├── header.vue │ ├── task-state.vue │ ├── sortable-arrow.vue │ ├── theme.vue │ ├── range-picker.vue │ └── tasks-table.vue ├── pages │ ├── index.vue │ └── tasks │ │ ├── [id].vue │ │ └── index.vue ├── app.vue ├── lib │ └── utils.ts └── assets │ └── css │ └── main.css ├── server ├── tsconfig.json ├── middleware │ └── log.ts ├── plugins │ ├── shutdown.ts │ └── startup.ts ├── tasks │ ├── vacuum.ts │ └── delete-old.ts ├── api │ └── tasks │ │ ├── [id] │ │ ├── index.get.ts │ │ ├── executed.post.ts │ │ ├── queued.post.ts │ │ └── started.post.ts │ │ ├── backup.get.ts │ │ └── index.get.ts └── repositories │ └── tasks.ts ├── entrypoint.sh ├── docs └── images │ ├── preview1.png │ └── preview2.png ├── env-example ├── tsconfig.json ├── shared ├── env.ts ├── exceptions.ts ├── db │ ├── index.ts │ └── schema.ts ├── utils.ts ├── types.ts └── schemas │ └── tasks.ts ├── drizzle.config.ts ├── .prettierrc.cjs ├── tests ├── helpers │ └── db-test-utils.ts └── task-flow.nuxt.spec.ts ├── .gitignore ├── makefile ├── nuxt.config.ts ├── components.json ├── .github └── workflows │ └── release.yaml ├── Dockerfile ├── LICENSE ├── package.json └── README.md /public/robots.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /app/components/ui/input/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Input } from './Input.vue' 2 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.nuxt/tsconfig.server.json" 3 | } 4 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | exec node /usr/app/.output/server/index.mjs -------------------------------------------------------------------------------- /app/components/ui/checkbox/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Checkbox } from "./Checkbox.vue" 2 | -------------------------------------------------------------------------------- /app/components/ui/skeleton/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Skeleton } from "./Skeleton.vue" 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taskiq-python/taskiq-admin/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /docs/images/preview1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taskiq-python/taskiq-admin/HEAD/docs/images/preview1.png -------------------------------------------------------------------------------- /docs/images/preview2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taskiq-python/taskiq-admin/HEAD/docs/images/preview2.png -------------------------------------------------------------------------------- /env-example: -------------------------------------------------------------------------------- 1 | DB_FILE_PATH=database/database.db 2 | BACKUP_FILE_PATH=database/backup.db 3 | TASKIQ_ADMIN_API_TOKEN=supersecret -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://nuxt.com/docs/guide/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json" 4 | } 5 | -------------------------------------------------------------------------------- /shared/env.ts: -------------------------------------------------------------------------------- 1 | export const envVariables = { 2 | dbFilePath: process.env.DB_FILE_PATH!, 3 | backupFilePath: process.env.BACKUP_FILE_PATH!, 4 | taskiqAdminApiToken: process.env.TASKIQ_ADMIN_API_TOKEN! 5 | } 6 | -------------------------------------------------------------------------------- /server/middleware/log.ts: -------------------------------------------------------------------------------- 1 | import { defineEventHandler } from '#imports' 2 | 3 | export default defineEventHandler((event) => { 4 | const { method, url } = event.node.req 5 | console.log(`[API] ${method} ${url}`) 6 | }) 7 | -------------------------------------------------------------------------------- /shared/exceptions.ts: -------------------------------------------------------------------------------- 1 | export class NotFoundError extends Error { 2 | status: number; 3 | constructor(message: string, status = 404) { 4 | super(message); 5 | this.name = "ValidationError"; 6 | this.status = status; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /app/components/ui/popover/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Popover } from './Popover.vue' 2 | export { default as PopoverAnchor } from './PopoverAnchor.vue' 3 | export { default as PopoverContent } from './PopoverContent.vue' 4 | export { default as PopoverTrigger } from './PopoverTrigger.vue' 5 | -------------------------------------------------------------------------------- /server/plugins/shutdown.ts: -------------------------------------------------------------------------------- 1 | import { defineNitroPlugin } from '#imports' 2 | import { tasksRepository } from '../repositories/tasks' 3 | 4 | export default defineNitroPlugin((nitroApp) => { 5 | nitroApp.hooks.hook('close', async () => { 6 | await tasksRepository.setAbandoned() 7 | }) 8 | }) 9 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config' 2 | import { defineConfig } from 'drizzle-kit' 3 | 4 | export default defineConfig({ 5 | out: './drizzle', 6 | schema: './shared/db/schema.ts', 7 | dialect: 'sqlite', 8 | dbCredentials: { 9 | url: process.env.DB_FILE_PATH! 10 | } 11 | }) 12 | -------------------------------------------------------------------------------- /server/plugins/startup.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises' 2 | import { db } from '../../shared/db' 3 | import { defineNitroPlugin } from '#imports' 4 | 5 | export default defineNitroPlugin(async (nitroApp) => { 6 | const sqlScript = await fs.readFile('dbschema.sql', 'utf-8') 7 | db.$client.exec(sqlScript) 8 | }) 9 | -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @see https://prettier.io/docs/configuration 3 | * @type {import("prettier").Config} 4 | */ 5 | const config = { 6 | trailingComma: 'none', 7 | tabWidth: 2, 8 | semi: false, 9 | singleQuote: true, 10 | singleAttributePerLine: true 11 | } 12 | 13 | module.exports = config 14 | -------------------------------------------------------------------------------- /shared/db/index.ts: -------------------------------------------------------------------------------- 1 | import Database from 'better-sqlite3' 2 | import { drizzle } from 'drizzle-orm/better-sqlite3' 3 | 4 | const conn = new Database(process.env.DB_FILE_PATH!) 5 | 6 | conn.pragma('synchronous = NORMAL') 7 | conn.pragma('journal_size_limit = 6144000') 8 | 9 | export const db = drizzle({ 10 | client: conn 11 | }) 12 | -------------------------------------------------------------------------------- /app/components/ui/table/utils.ts: -------------------------------------------------------------------------------- 1 | import type { Updater } from '@tanstack/vue-table' 2 | import type { Ref } from 'vue' 3 | 4 | export function valueUpdater>(updaterOrValue: T, ref: Ref) { 5 | ref.value 6 | = typeof updaterOrValue === 'function' 7 | ? updaterOrValue(ref.value) 8 | : updaterOrValue 9 | } 10 | -------------------------------------------------------------------------------- /app/components/ui/select/SelectGroup.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 15 | -------------------------------------------------------------------------------- /app/components/ui/select/SelectValue.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 15 | -------------------------------------------------------------------------------- /tests/helpers/db-test-utils.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs' 2 | import { db } from '~~/shared/db' 3 | 4 | export async function resetDatabase() { 5 | const schema = fs.readFileSync('dbschema.sql', 'utf8') 6 | db.$client.exec('PRAGMA foreign_keys = OFF;') 7 | db.$client.exec('DROP TABLE IF EXISTS tasks;') // в schema уже IF NOT EXISTS 8 | db.$client.exec(schema) 9 | } 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.db 2 | dbschema.sql 3 | *.db-shm 4 | *.db-wal 5 | .data 6 | 7 | # Nuxt dev/build outputs 8 | .output 9 | .data 10 | .nuxt 11 | .nitro 12 | .cache 13 | dist 14 | 15 | # Node dependencies 16 | node_modules 17 | 18 | # Logs 19 | logs 20 | *.log 21 | 22 | # Misc 23 | .DS_Store 24 | .fleet 25 | .idea 26 | 27 | # Local env files 28 | .env 29 | .env.* 30 | !.env.example 31 | -------------------------------------------------------------------------------- /app/components/ui/popover/PopoverTrigger.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 15 | -------------------------------------------------------------------------------- /app/components/ui/select/SelectItemText.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 15 | -------------------------------------------------------------------------------- /app/components/ui/dialog/DialogClose.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 16 | -------------------------------------------------------------------------------- /app/components/ui/calendar/CalendarGridBody.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 15 | -------------------------------------------------------------------------------- /app/pages/index.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | -------------------------------------------------------------------------------- /app/components/ui/dialog/DialogTrigger.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 16 | -------------------------------------------------------------------------------- /app/components/ui/popover/PopoverAnchor.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 16 | -------------------------------------------------------------------------------- /app/components/ui/card/CardContent.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 18 | -------------------------------------------------------------------------------- /app/components/ui/dropdown-menu/DropdownMenuGroup.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 15 | -------------------------------------------------------------------------------- /server/tasks/vacuum.ts: -------------------------------------------------------------------------------- 1 | import { db } from '~/server/db' 2 | import { defineTask } from '#imports' 3 | 4 | export default defineTask({ 5 | meta: { 6 | name: 'db:vacuum', 7 | description: 'Perform VACUUM in the DB' 8 | }, 9 | run({ payload, context }) { 10 | console.log('🚩 Running VACUUM') 11 | db.$client.prepare('VACUUM').run() 12 | console.log('✅ Done running VACUUM') 13 | } 14 | }) 15 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | MODE ?= dev 2 | 3 | ifneq (,$(wildcard ./.env)) 4 | include .env 5 | export 6 | endif 7 | 8 | .PHONY: all dev prod install gen run build 9 | 10 | all: $(MODE) 11 | 12 | install: 13 | pnpm install --frozen-lockfile 14 | 15 | gen: 16 | pnpm run generate:sql 17 | 18 | dev: install gen 19 | pnpm dev 20 | 21 | build: install gen 22 | pnpm build 23 | 24 | prod: build 25 | node .output/server/index.mjs 26 | -------------------------------------------------------------------------------- /app/components/ui/card/CardTitle.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 18 | -------------------------------------------------------------------------------- /app/components/ui/table/TableHeader.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 18 | -------------------------------------------------------------------------------- /app/components/ui/table/TableBody.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 18 | -------------------------------------------------------------------------------- /nuxt.config.ts: -------------------------------------------------------------------------------- 1 | import tailwindcss from '@tailwindcss/vite' 2 | 3 | export default defineNuxtConfig({ 4 | compatibilityDate: '2024-11-01', 5 | devtools: { enabled: true }, 6 | css: ['~/assets/css/main.css'], 7 | srcDir: 'app', 8 | imports: { 9 | autoImport: false 10 | }, 11 | vite: { 12 | plugins: [tailwindcss()] 13 | }, 14 | typescript: { 15 | strict: true 16 | }, 17 | modules: ['@nuxt/fonts'] 18 | }) 19 | -------------------------------------------------------------------------------- /app/components/ui/card/CardDescription.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 18 | -------------------------------------------------------------------------------- /app/components/ui/card/CardFooter.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 18 | -------------------------------------------------------------------------------- /app/components/ui/card/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Card } from './Card.vue' 2 | export { default as CardAction } from './CardAction.vue' 3 | export { default as CardContent } from './CardContent.vue' 4 | export { default as CardDescription } from './CardDescription.vue' 5 | export { default as CardFooter } from './CardFooter.vue' 6 | export { default as CardHeader } from './CardHeader.vue' 7 | export { default as CardTitle } from './CardTitle.vue' 8 | -------------------------------------------------------------------------------- /app/components/ui/range-calendar/RangeCalendarGridBody.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 15 | -------------------------------------------------------------------------------- /app/components/ui/range-calendar/RangeCalendarGridHead.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 15 | -------------------------------------------------------------------------------- /app/app.vue: -------------------------------------------------------------------------------- 1 | 6 | 15 | 16 | 21 | -------------------------------------------------------------------------------- /app/components/ui/dialog/DialogFooter.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 16 | -------------------------------------------------------------------------------- /app/components/ui/table/TableCaption.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 18 | -------------------------------------------------------------------------------- /app/components/ui/dialog/DialogHeader.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 18 | -------------------------------------------------------------------------------- /app/components/ui/skeleton/Skeleton.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 18 | -------------------------------------------------------------------------------- /app/components/ui/card/CardAction.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 18 | -------------------------------------------------------------------------------- /app/components/ui/table/TableFooter.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 18 | -------------------------------------------------------------------------------- /app/components/header.vue: -------------------------------------------------------------------------------- 1 | 4 | 16 | -------------------------------------------------------------------------------- /app/components/ui/table/TableRow.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 18 | -------------------------------------------------------------------------------- /app/components/ui/calendar/CalendarGridHead.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 16 | -------------------------------------------------------------------------------- /server/api/tasks/[id]/index.get.ts: -------------------------------------------------------------------------------- 1 | import { defineEventHandler, getValidatedRouterParams } from 'h3' 2 | import { taskRouteParamsSchema } from '../../../../shared/schemas/tasks' 3 | import { tasksRepository } from '../../../repositories/tasks' 4 | 5 | export default defineEventHandler(async (event) => { 6 | const params = await getValidatedRouterParams( 7 | event, 8 | taskRouteParamsSchema.parse 9 | ) 10 | 11 | return await tasksRepository.getById(params.id) 12 | }) 13 | -------------------------------------------------------------------------------- /app/components/ui/dropdown-menu/DropdownMenuShortcut.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 18 | -------------------------------------------------------------------------------- /app/components/ui/table/Table.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 17 | -------------------------------------------------------------------------------- /app/components/ui/dropdown-menu/DropdownMenuTrigger.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 17 | -------------------------------------------------------------------------------- /app/components/ui/select/SelectLabel.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 17 | -------------------------------------------------------------------------------- /app/components/ui/select/Select.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 19 | -------------------------------------------------------------------------------- /app/components/ui/card/Card.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 23 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://shadcn-vue.com/schema.json", 3 | "style": "new-york", 4 | "typescript": true, 5 | "tailwind": { 6 | "config": "", 7 | "css": "src/assets/css/main.css", 8 | "baseColor": "neutral", 9 | "cssVariables": true, 10 | "prefix": "" 11 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "composables": "@/composables", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib" 18 | }, 19 | "iconLibrary": "lucide" 20 | } -------------------------------------------------------------------------------- /app/components/ui/popover/Popover.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 19 | -------------------------------------------------------------------------------- /app/components/ui/table/TableHead.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 18 | -------------------------------------------------------------------------------- /app/components/ui/card/CardHeader.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 18 | -------------------------------------------------------------------------------- /app/components/ui/dropdown-menu/DropdownMenu.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 18 | -------------------------------------------------------------------------------- /app/components/ui/table/TableCell.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 23 | -------------------------------------------------------------------------------- /app/components/ui/table/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Table } from './Table.vue' 2 | export { default as TableBody } from './TableBody.vue' 3 | export { default as TableCaption } from './TableCaption.vue' 4 | export { default as TableCell } from './TableCell.vue' 5 | export { default as TableEmpty } from './TableEmpty.vue' 6 | export { default as TableFooter } from './TableFooter.vue' 7 | export { default as TableHead } from './TableHead.vue' 8 | export { default as TableHeader } from './TableHeader.vue' 9 | export { default as TableRow } from './TableRow.vue' 10 | -------------------------------------------------------------------------------- /shared/utils.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | import utc from 'dayjs/plugin/utc.js' 3 | import { NotFoundError } from './exceptions' 4 | 5 | dayjs.extend(utc) 6 | 7 | export const takeUniqueOrThrow = (values: T): T[number] => { 8 | if (values.length !== 1) 9 | throw new NotFoundError('Found non unique or inexistent value') 10 | return values[0]! 11 | } 12 | 13 | export const utcNow = () => { 14 | return dayjs.utc() 15 | } 16 | 17 | export const capitalize = (text: string) => { 18 | return text[0]?.toUpperCase() + text.slice(1) 19 | } 20 | -------------------------------------------------------------------------------- /app/components/ui/dialog/Dialog.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 20 | -------------------------------------------------------------------------------- /app/components/ui/dropdown-menu/DropdownMenuSub.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 20 | -------------------------------------------------------------------------------- /app/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 23 | -------------------------------------------------------------------------------- /app/components/ui/dialog/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Dialog } from "./Dialog.vue" 2 | export { default as DialogClose } from "./DialogClose.vue" 3 | export { default as DialogContent } from "./DialogContent.vue" 4 | export { default as DialogDescription } from "./DialogDescription.vue" 5 | export { default as DialogFooter } from "./DialogFooter.vue" 6 | export { default as DialogHeader } from "./DialogHeader.vue" 7 | export { default as DialogOverlay } from "./DialogOverlay.vue" 8 | export { default as DialogScrollContent } from "./DialogScrollContent.vue" 9 | export { default as DialogTitle } from "./DialogTitle.vue" 10 | export { default as DialogTrigger } from "./DialogTrigger.vue" 11 | -------------------------------------------------------------------------------- /app/components/ui/select/SelectSeparator.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 22 | -------------------------------------------------------------------------------- /app/components/task-state.vue: -------------------------------------------------------------------------------- 1 | 18 | 23 | -------------------------------------------------------------------------------- /app/components/ui/dropdown-menu/DropdownMenuSeparator.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 27 | -------------------------------------------------------------------------------- /app/components/ui/select/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Select } from './Select.vue' 2 | export { default as SelectContent } from './SelectContent.vue' 3 | export { default as SelectGroup } from './SelectGroup.vue' 4 | export { default as SelectItem } from './SelectItem.vue' 5 | export { default as SelectItemText } from './SelectItemText.vue' 6 | export { default as SelectLabel } from './SelectLabel.vue' 7 | export { default as SelectScrollDownButton } from './SelectScrollDownButton.vue' 8 | export { default as SelectScrollUpButton } from './SelectScrollUpButton.vue' 9 | export { default as SelectSeparator } from './SelectSeparator.vue' 10 | export { default as SelectTrigger } from './SelectTrigger.vue' 11 | export { default as SelectValue } from './SelectValue.vue' 12 | -------------------------------------------------------------------------------- /app/components/sortable-arrow.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 30 | -------------------------------------------------------------------------------- /app/components/ui/calendar/CalendarGridRow.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 25 | -------------------------------------------------------------------------------- /app/components/ui/dialog/DialogTitle.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 24 | -------------------------------------------------------------------------------- /app/components/ui/calendar/CalendarGrid.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 26 | -------------------------------------------------------------------------------- /server/api/tasks/backup.get.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import { db } from '../../../shared/db' 3 | import { utcNow } from '../../../shared/utils' 4 | import { envVariables } from '../../../shared/env' 5 | import { defineEventHandler, sendStream, setHeader } from '#imports' 6 | 7 | export default defineEventHandler(async (event) => { 8 | await db.$client.backup(envVariables.backupFilePath) 9 | const stream = fs.createReadStream(envVariables.backupFilePath) 10 | 11 | const now = utcNow() 12 | const formatted = now.format('YYYY-MM-DD HH-mm-ss') 13 | setHeader(event, 'Content-Type', 'application/octet-stream') 14 | setHeader( 15 | event, 16 | 'Content-Disposition', 17 | `attachment; filename="${formatted}-backup.db"` 18 | ) 19 | 20 | return sendStream(event, stream) 21 | }) 22 | -------------------------------------------------------------------------------- /app/components/ui/dialog/DialogDescription.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 24 | -------------------------------------------------------------------------------- /app/components/ui/range-calendar/RangeCalendarGridRow.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 25 | -------------------------------------------------------------------------------- /app/components/ui/calendar/CalendarHeader.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 26 | -------------------------------------------------------------------------------- /app/components/ui/dialog/DialogOverlay.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 22 | -------------------------------------------------------------------------------- /app/components/ui/range-calendar/RangeCalendarGrid.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 26 | -------------------------------------------------------------------------------- /app/components/ui/button/Button.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 30 | -------------------------------------------------------------------------------- /app/components/ui/calendar/CalendarHeadCell.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 26 | -------------------------------------------------------------------------------- /app/components/ui/range-calendar/RangeCalendarHeader.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 26 | -------------------------------------------------------------------------------- /app/components/ui/calendar/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Calendar } from './Calendar.vue' 2 | export { default as CalendarCell } from './CalendarCell.vue' 3 | export { default as CalendarCellTrigger } from './CalendarCellTrigger.vue' 4 | export { default as CalendarGrid } from './CalendarGrid.vue' 5 | export { default as CalendarGridBody } from './CalendarGridBody.vue' 6 | export { default as CalendarGridHead } from './CalendarGridHead.vue' 7 | export { default as CalendarGridRow } from './CalendarGridRow.vue' 8 | export { default as CalendarHeadCell } from './CalendarHeadCell.vue' 9 | export { default as CalendarHeader } from './CalendarHeader.vue' 10 | export { default as CalendarHeading } from './CalendarHeading.vue' 11 | export { default as CalendarNextButton } from './CalendarNextButton.vue' 12 | export { default as CalendarPrevButton } from './CalendarPrevButton.vue' 13 | -------------------------------------------------------------------------------- /app/components/ui/dropdown-menu/DropdownMenuLabel.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 23 | -------------------------------------------------------------------------------- /app/components/ui/range-calendar/RangeCalendarHeadCell.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 26 | -------------------------------------------------------------------------------- /app/components/ui/calendar/CalendarCell.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 26 | -------------------------------------------------------------------------------- /server/api/tasks/index.get.ts: -------------------------------------------------------------------------------- 1 | import { defineEventHandler, getValidatedQuery } from 'h3' 2 | import { tasksRepository } from '../../repositories/tasks' 3 | import { getTasksQueryParamsSchema } from '../../../shared/schemas/tasks' 4 | 5 | export default defineEventHandler(async (event) => { 6 | const query = await getValidatedQuery(event, getTasksQueryParamsSchema.parse) 7 | 8 | const { tasks, count } = await tasksRepository.getAll({ 9 | name: query.search ? query.search : null, 10 | limit: query.limit, 11 | offset: query.offset, 12 | state: query.state, 13 | sortByRuntime: query.sortByRuntime, 14 | sortByStartedAt: query.sortByStartedAt, 15 | sortByQueuedAt: query.sortByQueuedAt, 16 | startDate: query.startDate ? new Date(query.startDate) : undefined, 17 | endDate: query.endDate ? new Date(query.endDate) : undefined 18 | }) 19 | 20 | return { tasks, count } 21 | }) 22 | -------------------------------------------------------------------------------- /shared/types.ts: -------------------------------------------------------------------------------- 1 | export type QueryParams = { 2 | page: number 3 | perPage: number 4 | state?: string 5 | search?: string 6 | sortByRuntime?: string 7 | sortByStartedAt?: string 8 | sortByQueuedAt?: string 9 | startDate?: string 10 | endDate?: string 11 | } 12 | 13 | export const StateEnum = { 14 | queued: 'queued', 15 | running: 'running', 16 | success: 'success', 17 | failure: 'failure', 18 | abandoned: 'abandoned' 19 | } as const 20 | export const StateEnumValues = Object.values(StateEnum) 21 | export type TaskState = (typeof StateEnum)[keyof typeof StateEnum] 22 | 23 | export type TaskCreate = { 24 | id: string 25 | name: string 26 | args: Array 27 | queuedAt: Date 28 | startedAt: Date | null 29 | worker: string | null 30 | finishedAt: Date | null 31 | kwargs: Record 32 | executionTime: number | null 33 | returnValue: { return_value: any } | null 34 | state: TaskState 35 | } 36 | -------------------------------------------------------------------------------- /server/tasks/delete-old.ts: -------------------------------------------------------------------------------- 1 | import { defineTask } from '#imports' 2 | import { tasksRepository } from '~/server/repositories/tasks' 3 | export default defineTask<{ ttlMinutes: number }, { result: string }>({ 4 | meta: { 5 | name: 'delete-old', 6 | description: 'Delete old task information using the TTL parameter' 7 | }, 8 | run({ payload, success }) { 9 | const TTL_MINUTES = process.env.TTL_MINUTES 10 | if (TTL_MINUTES) { 11 | console.log( 12 | `🚩 TTL_MINUTES is set to ${TTL_MINUTES}. Running delete query...` 13 | ) 14 | const ttlMinutes = Number(TTL_MINUTES) 15 | tasksRepository 16 | .deleteOld({ ttlMinutes }) 17 | .then(() => { 18 | console.log('✅ Old tasks deleted successfully') 19 | }) 20 | .catch((err) => { 21 | console.error('❌ Failed to delete old tasks', err) 22 | }) 23 | return { result: 'Success' } 24 | } 25 | } 26 | }) 27 | -------------------------------------------------------------------------------- /app/components/ui/select/SelectScrollUpButton.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 29 | -------------------------------------------------------------------------------- /app/components/ui/table/TableEmpty.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 38 | -------------------------------------------------------------------------------- /app/components/ui/calendar/CalendarHeading.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 33 | -------------------------------------------------------------------------------- /app/components/ui/select/SelectScrollDownButton.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 29 | -------------------------------------------------------------------------------- /app/components/ui/range-calendar/index.ts: -------------------------------------------------------------------------------- 1 | export { default as RangeCalendar } from './RangeCalendar.vue' 2 | export { default as RangeCalendarCell } from './RangeCalendarCell.vue' 3 | export { default as RangeCalendarCellTrigger } from './RangeCalendarCellTrigger.vue' 4 | export { default as RangeCalendarGrid } from './RangeCalendarGrid.vue' 5 | export { default as RangeCalendarGridBody } from './RangeCalendarGridBody.vue' 6 | export { default as RangeCalendarGridHead } from './RangeCalendarGridHead.vue' 7 | export { default as RangeCalendarGridRow } from './RangeCalendarGridRow.vue' 8 | export { default as RangeCalendarHeadCell } from './RangeCalendarHeadCell.vue' 9 | export { default as RangeCalendarHeader } from './RangeCalendarHeader.vue' 10 | export { default as RangeCalendarHeading } from './RangeCalendarHeading.vue' 11 | export { default as RangeCalendarNextButton } from './RangeCalendarNextButton.vue' 12 | export { default as RangeCalendarPrevButton } from './RangeCalendarPrevButton.vue' 13 | -------------------------------------------------------------------------------- /app/components/ui/range-calendar/RangeCalendarHeading.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 33 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [released] 6 | 7 | jobs: 8 | release_image: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | packages: write 12 | contents: read 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | - name: Set up Docker 17 | uses: docker/setup-qemu-action@v3 18 | - name: Set up Docker Buildx 19 | uses: docker/setup-buildx-action@v3 20 | - name: Login to GitHub Container Registry 21 | uses: docker/login-action@v2 22 | with: 23 | registry: ghcr.io 24 | username: ${{ github.actor }} 25 | password: ${{ secrets.GITHUB_TOKEN }} 26 | - name: Build and push 27 | uses: docker/build-push-action@v2 28 | with: 29 | context: . 30 | file: ./Dockerfile 31 | platforms: linux/amd64,linux/arm/v7 32 | push: true 33 | tags: ghcr.io/taskiq-python/taskiq-admin:latest,ghcr.io/taskiq-python/taskiq-admin:${{ github.ref_name }} 34 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG NODE_VERSION=22.14.0 2 | 3 | FROM node:${NODE_VERSION}-slim AS build 4 | 5 | # enable pnpm 6 | ENV PNPM_HOME="/pnpm" 7 | ENV PATH="$PNPM_HOME:$PATH" 8 | RUN corepack enable 9 | 10 | WORKDIR /app 11 | 12 | # install dependencies 13 | COPY ./package.json /app/ 14 | COPY ./pnpm-lock.yaml /app/ 15 | RUN pnpm install --frozen-lockfile 16 | 17 | # copy other files 18 | COPY . ./ 19 | RUN pnpm run generate:sql 20 | 21 | # build the app 22 | RUN pnpm run build 23 | 24 | # ======================= 25 | FROM node:${NODE_VERSION}-slim 26 | 27 | WORKDIR /usr/app 28 | 29 | # .output 30 | COPY --from=build /app/.output/ /usr/app/.output/ 31 | COPY --from=build /app/dbschema.sql /usr/app/dbschema.sql 32 | COPY --from=build /app/entrypoint.sh /usr/app/entrypoint.sh 33 | 34 | EXPOSE 3000 35 | ENV HOST=0.0.0.0 NODE_ENV=production 36 | ENV DB_FILE_PATH=/usr/database/database.db 37 | ENV BACKUP_FILE_PATH=/usr/database/backup.db 38 | 39 | RUN npm install dotenv 40 | 41 | RUN mkdir -p /usr/database/ 42 | RUN chmod +x /usr/app/entrypoint.sh 43 | 44 | CMD [ "/usr/app/entrypoint.sh" ] 45 | -------------------------------------------------------------------------------- /app/components/ui/calendar/CalendarNextButton.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 35 | -------------------------------------------------------------------------------- /app/components/ui/calendar/CalendarPrevButton.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 35 | -------------------------------------------------------------------------------- /app/components/ui/range-calendar/RangeCalendarCell.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 26 | -------------------------------------------------------------------------------- /app/components/ui/range-calendar/RangeCalendarPrevButton.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 35 | -------------------------------------------------------------------------------- /app/components/ui/range-calendar/RangeCalendarNextButton.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024-2025 Artur Samvelian 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /app/components/ui/dropdown-menu/index.ts: -------------------------------------------------------------------------------- 1 | export { default as DropdownMenu } from './DropdownMenu.vue' 2 | 3 | export { default as DropdownMenuCheckboxItem } from './DropdownMenuCheckboxItem.vue' 4 | export { default as DropdownMenuContent } from './DropdownMenuContent.vue' 5 | export { default as DropdownMenuGroup } from './DropdownMenuGroup.vue' 6 | export { default as DropdownMenuItem } from './DropdownMenuItem.vue' 7 | export { default as DropdownMenuLabel } from './DropdownMenuLabel.vue' 8 | export { default as DropdownMenuRadioGroup } from './DropdownMenuRadioGroup.vue' 9 | export { default as DropdownMenuRadioItem } from './DropdownMenuRadioItem.vue' 10 | export { default as DropdownMenuSeparator } from './DropdownMenuSeparator.vue' 11 | export { default as DropdownMenuShortcut } from './DropdownMenuShortcut.vue' 12 | export { default as DropdownMenuSub } from './DropdownMenuSub.vue' 13 | export { default as DropdownMenuSubContent } from './DropdownMenuSubContent.vue' 14 | export { default as DropdownMenuSubTrigger } from './DropdownMenuSubTrigger.vue' 15 | export { default as DropdownMenuTrigger } from './DropdownMenuTrigger.vue' 16 | export { DropdownMenuPortal } from 'reka-ui' 17 | -------------------------------------------------------------------------------- /app/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 31 | -------------------------------------------------------------------------------- /server/api/tasks/[id]/executed.post.ts: -------------------------------------------------------------------------------- 1 | import { 2 | taskExecutedRequestSchema, 3 | taskRouteParamsSchema 4 | } from '../../../../shared/schemas/tasks' 5 | import { tasksRepository } from '../../../repositories/tasks' 6 | import { envVariables } from '../../../../shared/env' 7 | import { 8 | createError, 9 | defineEventHandler, 10 | getRequestHeader, 11 | getValidatedRouterParams, 12 | readValidatedBody 13 | } from '#imports' 14 | 15 | export default defineEventHandler(async (event) => { 16 | const accessToken = getRequestHeader(event, 'access-token') 17 | if (!accessToken || accessToken !== envVariables.taskiqAdminApiToken) { 18 | throw createError({ 19 | status: 401, 20 | statusMessage: 'Unauthorized', 21 | message: 'Invalid access token' 22 | }) 23 | } 24 | const params = await getValidatedRouterParams( 25 | event, 26 | taskRouteParamsSchema.parse 27 | ) 28 | const body = await readValidatedBody(event, taskExecutedRequestSchema.parse) 29 | 30 | const state = body.error ? 'failure' : 'success' 31 | 32 | await tasksRepository.update(params.id, { 33 | state: state, 34 | error: body.error, 35 | finishedAt: body.finishedAt, 36 | returnValue: body.returnValue, 37 | executionTime: body.executionTime 38 | }) 39 | 40 | return { success: true } 41 | }) 42 | -------------------------------------------------------------------------------- /app/components/theme.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 37 | -------------------------------------------------------------------------------- /app/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | import utc from 'dayjs/plugin/utc.js' 3 | import { type ClassValue, clsx } from 'clsx' 4 | import { twMerge } from 'tailwind-merge' 5 | import type { TaskSelect } from '~/server/db/schema' 6 | 7 | dayjs.extend(utc) 8 | 9 | export function formatDate(date: string, includeMilliseconds: boolean = false) { 10 | let formatString = 'MMM D, YYYY HH:mm:ss' 11 | if (includeMilliseconds) { 12 | formatString += '.SSS' 13 | } 14 | return dayjs.utc(date).local().format(formatString) 15 | } 16 | 17 | export function cn(...inputs: ClassValue[]) { 18 | return twMerge(clsx(inputs)) 19 | } 20 | 21 | export function limitText(text: string, length: number) { 22 | if (text.length > length) { 23 | return text.slice(0, length).trim() + '...' 24 | } 25 | return text 26 | } 27 | 28 | export function formatReturnValue(task: TaskSelect) { 29 | if (task.returnValue === null) { 30 | return '...' 31 | } else { 32 | if (task.returnValue?.return_value) { 33 | return task.returnValue.return_value 34 | } 35 | return 'null' 36 | } 37 | } 38 | 39 | // temporarily, while dishka hasn't fixed it's module naming bug 40 | export function formatTaskName(taskName: string) { 41 | if (taskName.includes(':')) { 42 | const parts = taskName.split(':') 43 | return parts[parts.length - 1] 44 | } else { 45 | return taskName 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /shared/schemas/tasks.ts: -------------------------------------------------------------------------------- 1 | import * as z from 'zod' 2 | 3 | export const taskQueuedRequestSchema = z.object({ 4 | taskName: z.string(), 5 | queuedAt: z.coerce.date(), 6 | args: z.array(z.unknown()), 7 | worker: z.string().nullable(), 8 | kwargs: z.record(z.string(), z.unknown()) 9 | }) 10 | 11 | export const taskStartedRequestSchema = z.object({ 12 | taskName: z.string(), 13 | startedAt: z.coerce.date(), 14 | args: z.array(z.unknown()), 15 | worker: z.string().nullable(), 16 | kwargs: z.record(z.string(), z.unknown()) 17 | }) 18 | 19 | export const taskExecutedRequestSchema = z.object({ 20 | error: z.string().nullable(), 21 | executionTime: z.number(), 22 | finishedAt: z.coerce.date(), 23 | returnValue: z.record(z.string(), z.unknown()).nullable() 24 | }) 25 | 26 | export const taskRouteParamsSchema = z.object({ 27 | id: z.string() 28 | }) 29 | 30 | export const getTasksQueryParamsSchema = z.object({ 31 | search: z.string().optional(), 32 | limit: z.coerce.number().gte(0), 33 | offset: z.coerce.number().gte(0), 34 | state: z 35 | .enum(['queued', 'success', 'running', 'failure', 'abandoned']) 36 | .optional(), 37 | sortByRuntime: z.enum(['asc', 'desc']).optional(), 38 | sortByStartedAt: z.enum(['asc', 'desc']).optional(), 39 | sortByQueuedAt: z.enum(['asc', 'desc']).optional(), 40 | startDate: z.date().optional(), 41 | endDate: z.date().optional() 42 | }) 43 | -------------------------------------------------------------------------------- /app/components/ui/input/Input.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 34 | -------------------------------------------------------------------------------- /app/components/ui/dropdown-menu/DropdownMenuSubContent.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 32 | -------------------------------------------------------------------------------- /server/api/tasks/[id]/queued.post.ts: -------------------------------------------------------------------------------- 1 | import { 2 | taskQueuedRequestSchema, 3 | taskRouteParamsSchema 4 | } from '../../../../shared/schemas/tasks' 5 | import { tasksRepository } from '../../../repositories/tasks' 6 | import { envVariables } from '../../../../shared/env' 7 | import { 8 | createError, 9 | defineEventHandler, 10 | getRequestHeader, 11 | getValidatedRouterParams, 12 | readValidatedBody 13 | } from '#imports' 14 | 15 | export default defineEventHandler(async (event) => { 16 | const accessToken = getRequestHeader(event, 'access-token') 17 | if (!accessToken || accessToken !== envVariables.taskiqAdminApiToken) { 18 | throw createError({ 19 | status: 401, 20 | statusMessage: 'Unauthorized', 21 | message: 'Invalid access token' 22 | }) 23 | } 24 | const params = await getValidatedRouterParams( 25 | event, 26 | taskRouteParamsSchema.parse 27 | ) 28 | const body = await readValidatedBody(event, taskQueuedRequestSchema.parse) 29 | 30 | await tasksRepository.upsert( 31 | { 32 | id: params.id, 33 | returnValue: null, 34 | executionTime: null, 35 | state: 'queued', 36 | args: body.args, 37 | worker: body.worker, 38 | kwargs: body.kwargs, 39 | name: body.taskName, 40 | startedAt: null, 41 | queuedAt: body.queuedAt, 42 | finishedAt: null 43 | }, 44 | ['queuedAt'] 45 | ) 46 | 47 | return { success: true } 48 | }) 49 | -------------------------------------------------------------------------------- /shared/db/schema.ts: -------------------------------------------------------------------------------- 1 | import { type InferSelectModel, sql } from 'drizzle-orm' 2 | import { int, text, real, sqliteTable, index } from 'drizzle-orm/sqlite-core' 3 | 4 | export const tasksTable = sqliteTable( 5 | 'tasks', 6 | { 7 | id: text().primaryKey(), 8 | name: text().notNull(), 9 | state: text({ 10 | enum: ['queued', 'running', 'success', 'failure', 'abandoned'] 11 | }).notNull(), 12 | error: text(), 13 | worker: text(), 14 | executionTime: real('execution_time'), 15 | queuedAt: int('queued_at', { mode: 'timestamp_ms' }).notNull(), 16 | startedAt: int('started_at', { mode: 'timestamp_ms' }), 17 | finishedAt: int('finished_at', { mode: 'timestamp_ms' }), 18 | args: text({ mode: 'json' }).$type>(), 19 | kwargs: text({ mode: 'json' }).$type>(), 20 | returnValue: text('return_value', { mode: 'json' }).$type<{ 21 | return_value: any 22 | }>() 23 | }, 24 | (t) => [ 25 | index('idx_tasks__state').on(t.state), 26 | index('idx_tasks__queued_at').on(t.queuedAt), 27 | index('idx_tasks__started_at').on(t.startedAt), 28 | index('idx_tasks__finished_at').on(t.finishedAt), 29 | index('idx_tasks__execution_time').on(t.executionTime), 30 | index('idx_tasks__name').on(sql`name COLLATE NOCASE`), 31 | index('idx_tasks__worker').on(sql`worker COLLATE NOCASE`) 32 | ] 33 | ) 34 | 35 | export type TaskSelect = InferSelectModel 36 | -------------------------------------------------------------------------------- /app/components/ui/checkbox/Checkbox.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 35 | -------------------------------------------------------------------------------- /app/components/ui/select/SelectItem.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 46 | -------------------------------------------------------------------------------- /server/api/tasks/[id]/started.post.ts: -------------------------------------------------------------------------------- 1 | import { 2 | taskRouteParamsSchema, 3 | taskStartedRequestSchema 4 | } from '../../../../shared/schemas/tasks' 5 | import { envVariables } from '../../../../shared/env' 6 | import { tasksRepository } from '../../../repositories/tasks' 7 | import { 8 | createError, 9 | defineEventHandler, 10 | getRequestHeader, 11 | getValidatedRouterParams, 12 | readValidatedBody 13 | } from '#imports' 14 | 15 | export default defineEventHandler(async (event) => { 16 | const accessToken = getRequestHeader(event, 'access-token') 17 | if (!accessToken || accessToken !== envVariables.taskiqAdminApiToken) { 18 | throw createError({ 19 | status: 401, 20 | statusMessage: 'Unauthorized', 21 | message: 'Invalid access token' 22 | }) 23 | } 24 | const params = await getValidatedRouterParams( 25 | event, 26 | taskRouteParamsSchema.parse 27 | ) 28 | const body = await readValidatedBody(event, taskStartedRequestSchema.parse) 29 | 30 | // Upserts the task if QUEUED event has not been received yet 31 | await tasksRepository.upsert( 32 | { 33 | id: params.id, 34 | returnValue: null, 35 | executionTime: null, 36 | state: 'running', 37 | args: body.args, 38 | worker: body.worker, 39 | kwargs: body.kwargs, 40 | name: body.taskName, 41 | queuedAt: body.startedAt, 42 | startedAt: body.startedAt, 43 | finishedAt: null 44 | }, 45 | ['startedAt'] 46 | ) 47 | await tasksRepository.promoteToRunning(params.id, body.startedAt) 48 | 49 | return { success: true } 50 | }) 51 | -------------------------------------------------------------------------------- /app/components/ui/dropdown-menu/DropdownMenuItem.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 31 | -------------------------------------------------------------------------------- /app/components/ui/dropdown-menu/DropdownMenuRadioItem.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 43 | -------------------------------------------------------------------------------- /app/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 42 | -------------------------------------------------------------------------------- /app/components/ui/dropdown-menu/DropdownMenuContent.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 40 | -------------------------------------------------------------------------------- /app/components/ui/popover/PopoverContent.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 50 | -------------------------------------------------------------------------------- /app/components/ui/calendar/CalendarCellTrigger.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 42 | -------------------------------------------------------------------------------- /app/components/ui/select/SelectTrigger.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 33 | -------------------------------------------------------------------------------- /tests/task-flow.nuxt.spec.ts: -------------------------------------------------------------------------------- 1 | // tests/task-flow.nuxt.spec.ts 2 | import { setup, $fetch } from '@nuxt/test-utils' 3 | import { resolve } from 'path' 4 | import { expect, test } from 'vitest' 5 | 6 | await setup({ 7 | rootDir: resolve(__dirname, '../..'), 8 | server: true, // up the Nitro server 9 | env: { 10 | NODE_ENV: 'test', 11 | DB_FILE_PATH: ':memory:', 12 | BACKUP_FILE_PATH: ':memory:', 13 | TASKIQ_ADMIN_API_TOKEN: 'supersecret' 14 | } 15 | }) 16 | 17 | const id = 'nuxt-spec-1' 18 | const token = { 'access-token': 'supersecret' } 19 | const queued = new Date('2025-01-01T10:00:00Z') 20 | const start = new Date('2025-01-01T10:00:02Z') 21 | const finish = new Date('2025-01-01T10:00:05Z') 22 | 23 | test('started → queued → executed saves dates', async () => { 24 | await $fetch(`/api/tasks/${id}/started`, { 25 | method: 'POST', 26 | headers: token, 27 | body: { 28 | taskName: 'demo', 29 | args: [], 30 | kwargs: {}, 31 | worker: 'w', 32 | startedAt: start.toISOString() 33 | } 34 | }) 35 | 36 | await $fetch(`/api/tasks/${id}/queued`, { 37 | method: 'POST', 38 | headers: token, 39 | body: { 40 | taskName: 'demo', 41 | args: [], 42 | kwargs: {}, 43 | worker: 'w', 44 | queuedAt: queued.toISOString() 45 | } 46 | }) 47 | 48 | await $fetch(`/api/tasks/${id}/executed`, { 49 | method: 'POST', 50 | headers: token, 51 | body: { 52 | error: null, 53 | executionTime: 3, 54 | finishedAt: finish.toISOString(), 55 | returnValue: { return_value: 'ok' } 56 | } 57 | }) 58 | 59 | const task = await $fetch(`/api/tasks/${id}`) 60 | 61 | expect(task?.state).toBe('success') 62 | expect(task?.queuedAt).toBe(queued.toISOString()) 63 | expect(task?.startedAt).toBe(start.toISOString()) 64 | expect(task?.finishedAt).toBe(finish.toISOString()) 65 | }) 66 | -------------------------------------------------------------------------------- /app/components/ui/button/index.ts: -------------------------------------------------------------------------------- 1 | import type { VariantProps } from 'class-variance-authority' 2 | import { cva } from 'class-variance-authority' 3 | 4 | export { default as Button } from './Button.vue' 5 | 6 | export const buttonVariants = cva( 7 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 8 | { 9 | variants: { 10 | variant: { 11 | default: 'bg-primary text-primary-foreground hover:bg-primary/90', 12 | destructive: 13 | 'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', 14 | outline: 15 | 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50', 16 | secondary: 17 | 'bg-secondary text-secondary-foreground hover:bg-secondary/80', 18 | ghost: 19 | 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', 20 | link: 'text-primary underline-offset-4 hover:underline' 21 | }, 22 | size: { 23 | default: 'h-9 px-4 py-2 has-[>svg]:px-3', 24 | sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5', 25 | lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', 26 | icon: 'size-9', 27 | 'icon-sm': 'size-8', 28 | 'icon-lg': 'size-10' 29 | } 30 | }, 31 | defaultVariants: { 32 | variant: 'default', 33 | size: 'default' 34 | } 35 | } 36 | ) 37 | export type ButtonVariants = VariantProps 38 | -------------------------------------------------------------------------------- /app/components/ui/range-calendar/RangeCalendarCellTrigger.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 44 | -------------------------------------------------------------------------------- /app/components/ui/dialog/DialogScrollContent.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 60 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "taskiq-admin", 3 | "private": true, 4 | "type": "module", 5 | "version": "1.8.1", 6 | "scripts": { 7 | "build": "nuxt build", 8 | "dev": "nuxt dev", 9 | "generate": "nuxt generate", 10 | "preview": "nuxt preview", 11 | "postinstall": "nuxt prepare", 12 | "typecheck": "tsc --noEmit", 13 | "test": "vitest --run", 14 | "db:push": "drizzle-kit push", 15 | "generate:sql": "drizzle-kit export --sql | sed 's/CREATE TABLE/CREATE TABLE IF NOT EXISTS/g; s/CREATE INDEX/CREATE INDEX IF NOT EXISTS/g' > dbschema.sql; sed -i '1s/^/PRAGMA journal_mode = WAL; PRAGMA synchronous = normal; PRAGMA journal_size_limit = 6144000;\\n/' dbschema.sql", 16 | "generate:deprecated:sql": "drizzle-kit export --sql | sed 's/CREATE TABLE/CREATE TABLE IF NOT EXISTS/g; s/CREATE INDEX/CREATE INDEX IF NOT EXISTS/g' > dbschema.sql" 17 | }, 18 | "dependencies": { 19 | "@internationalized/date": "^3.10.0", 20 | "@nuxt/fonts": "0.12.1", 21 | "@tailwindcss/vite": "^4.1.17", 22 | "@tanstack/vue-table": "^8.21.3", 23 | "@vueuse/core": "^14.1.0", 24 | "better-sqlite3": "^12.5.0", 25 | "class-variance-authority": "^0.7.1", 26 | "clsx": "^2.1.1", 27 | "dayjs": "^1.11.19", 28 | "dotenv": "^17.2.3", 29 | "drizzle-orm": "^0.44.7", 30 | "lucide-vue-next": "^0.524.0", 31 | "nuxt": "^4.2.1", 32 | "reka-ui": "^2.6.1", 33 | "tailwind-merge": "^3.4.0", 34 | "tailwindcss": "^4.1.17", 35 | "tw-animate-css": "^1.4.0", 36 | "vue": "^3.5.17", 37 | "vue-router": "^4.5.1", 38 | "vue-sonner": "^2.0.9", 39 | "zod": "^4.1.13" 40 | }, 41 | "packageManager": "pnpm@8.7.6+sha1.a428b12202bc4f23b17e6dffe730734dae5728e2", 42 | "devDependencies": { 43 | "@iconify-json/radix-icons": "^1.2.5", 44 | "@iconify/vue": "^5.0.0", 45 | "@nuxt/test-utils": "^3.20.1", 46 | "@types/better-sqlite3": "^7.6.12", 47 | "@vue/test-utils": "^2.4.6", 48 | "drizzle-kit": "^0.31.7", 49 | "happy-dom": "^18.0.1", 50 | "playwright-core": "^1.57.0", 51 | "prettier": "^3.7.3", 52 | "tsx": "^4.21.0", 53 | "typescript": "^5.9.3", 54 | "vitest": "^3.2.4" 55 | } 56 | } -------------------------------------------------------------------------------- /app/components/ui/select/SelectContent.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 56 | -------------------------------------------------------------------------------- /app/components/ui/dialog/DialogContent.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 54 | -------------------------------------------------------------------------------- /app/components/ui/calendar/Calendar.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 64 | -------------------------------------------------------------------------------- /app/components/range-picker.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 83 | -------------------------------------------------------------------------------- /app/components/ui/range-calendar/RangeCalendar.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 92 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Broker-agnostic admin panel for Taskiq 2 | 3 | Standalone admin panel with all data stored in SQLite database 4 | 5 | 6 | - [Broker-agnostic admin panel for Taskiq](#broker-agnostic-admin-panel-for-taskiq) 7 | - [Previews](#previews) 8 | - [Usage](#usage) 9 | - [Docker Compose Example](#docker-compose-example) 10 | - [Running without Docker](#running-without-docker) 11 | - [Task States](#task-states) 12 | - [Development](#development) 13 | 14 | ### Previews 15 | Tasks Page | Task Details Page 16 | :-------------------------:|:-------------------------: 17 | ![Alt text](./docs/images/preview1.png) | ![Alt text](./docs/images/preview2.png) 18 | 19 | ### Usage 20 | 21 | 1) Import and connect the middleware to the broker: 22 | 23 | ```python 24 | ... 25 | from taskiq.middlewares.taskiq_admin_middleware import TaskiqAdminMiddleware 26 | 27 | broker = ( 28 | RedisStreamBroker( 29 | url=redis_url, 30 | queue_name="my_lovely_queue", 31 | ) 32 | .with_result_backend(result_backend) 33 | .with_middlewares( 34 | TaskiqAdminMiddleware( 35 | url="http://localhost:3000", # the url to your taskiq-admin instance 36 | api_token="supersecret", # any secret enough string 37 | taskiq_broker_name="mybroker", 38 | ) 39 | ) 40 | ) 41 | ... 42 | ``` 43 | 44 | 2) Pull the image from GitHub Container Registry: `docker pull ghcr.io/taskiq-python/taskiq-admin:latest` 45 | 46 | 3) Replace `TASKIQ_ADMIN_API_TOKEN` with any secret enough string and run: 47 | ```bash 48 | docker run -d --rm \ 49 | -p "3000:3000" \ 50 | -v "./taskiq-admin-data/:/usr/database/" \ 51 | -e "TASKIQ_ADMIN_API_TOKEN=supersecret" \ 52 | --name "taskiq-admin" \ 53 | "ghcr.io/taskiq-python/taskiq-admin:latest" 54 | ``` 55 | 56 | 4) Go to `http://localhost:3000/tasks` 57 | 58 | ### Docker Compose Example 59 | 60 | ```yaml 61 | services: 62 | queue: 63 | build: 64 | context: . 65 | dockerfile: ./Dockerfile 66 | container_name: my_queue 67 | command: taskiq worker app.tasks.queue:broker --workers 1 --max-async-tasks 20 68 | environment: 69 | - TASKIQ_ADMIN_URL=http://taskiq_admin:3000 70 | - TASKIQ_ADMIN_API_TOKEN=supersecret 71 | depends_on: 72 | - redis 73 | - taskiq_admin 74 | 75 | taskiq_admin: 76 | image: ghcr.io/taskiq-python/taskiq-admin:latest 77 | container_name: taskiq_admin 78 | ports: 79 | - 3000:3000 80 | environment: 81 | - TASKIQ_ADMIN_API_TOKEN=supersecret 82 | volumes: 83 | - admin_data:/usr/database/ 84 | 85 | volumes: 86 | admin_data: 87 | ``` 88 | 89 | ### Running without Docker 90 | 1) `cp env-example .env`, enter `.env` file and fill in all needed variables 91 | 2) run `make dev` to run it locally in dev mode 92 | 3) run `make prod` to run it locally in prod mode 93 | 94 | ### Task States 95 | Let's assume we have a task 'do_smth', there are all states it can embrace: 96 | 1) `queued` - the task has been sent to the queue without an error 97 | 2) `running` - the task is grabbed by a worker and is being processed 98 | 3) `success` - the task is fully processed without any errors 99 | 4) `failure` - an error occured during the task processing 100 | 5) `abandoned` - taskiq-admin sets all 'running' tasks as 'abandoned' if there was a downtime between the time these tasks were in 'running' state and the time of next startup of taskiq-admin 101 | 102 | ### Development 103 | 1) Run `pnpm install` to install all dependencies 104 | 2) Run `pnpm db:push` to create the sqlite database if needed 105 | 3) Run `pnpm dev` to run the project 106 | -------------------------------------------------------------------------------- /server/repositories/tasks.ts: -------------------------------------------------------------------------------- 1 | import { db } from '../../shared/db' 2 | import { tasksTable } from '../../shared/db/schema' 3 | import { takeUniqueOrThrow, utcNow } from '../../shared/utils' 4 | import { TaskCreate, TaskState } from '../../shared/types' 5 | import { count, eq, desc, like, and, asc, gte, lte } from 'drizzle-orm' 6 | 7 | class TasksRepository { 8 | async getAll({ 9 | name, 10 | state, 11 | limit, 12 | offset, 13 | sortByRuntime, 14 | sortByStartedAt, 15 | sortByQueuedAt, 16 | startDate, 17 | endDate 18 | }: { 19 | limit: number 20 | offset: number 21 | name: string | null 22 | state?: TaskState 23 | sortByRuntime?: 'asc' | 'desc' 24 | sortByStartedAt?: 'asc' | 'desc' 25 | sortByQueuedAt?: 'asc' | 'desc' 26 | startDate?: Date 27 | endDate?: Date 28 | }) { 29 | const whereConditions = [] 30 | if (name) { 31 | whereConditions.push(like(tasksTable.name, `%${name.toLowerCase()}%`)) 32 | } 33 | if (state) { 34 | whereConditions.push(eq(tasksTable.state, state)) 35 | } 36 | if (startDate) { 37 | whereConditions.push(gte(tasksTable.startedAt, startDate)) 38 | } 39 | if (endDate) { 40 | whereConditions.push(lte(tasksTable.startedAt, endDate)) 41 | } 42 | 43 | const orderMap = { asc, desc } 44 | const sortConditions = [] 45 | if (sortByRuntime) { 46 | sortConditions.push(orderMap[sortByRuntime](tasksTable.executionTime)) 47 | } 48 | if (sortByStartedAt) { 49 | sortConditions.push(orderMap[sortByStartedAt](tasksTable.startedAt)) 50 | } 51 | if (sortByQueuedAt) { 52 | sortConditions.push(orderMap[sortByQueuedAt](tasksTable.queuedAt)) 53 | } 54 | if (sortConditions.length === 0) { 55 | sortConditions.push(desc(tasksTable.queuedAt)) 56 | } 57 | 58 | const whereClause = whereConditions.length 59 | ? and(...whereConditions) 60 | : undefined 61 | 62 | const countQuery = db 63 | .select({ 64 | count: count() 65 | }) 66 | .from(tasksTable) 67 | 68 | const tasksQuery = db.select().from(tasksTable) 69 | 70 | const countResult = await ( 71 | whereClause ? countQuery.where(whereClause) : countQuery 72 | ).then(takeUniqueOrThrow) 73 | 74 | const tasks = await ( 75 | whereClause ? tasksQuery.where(whereClause) : tasksQuery 76 | ) 77 | .orderBy(...sortConditions) 78 | .limit(limit) 79 | .offset(offset) 80 | 81 | return { tasks, count: countResult.count } 82 | } 83 | 84 | async getById(taskId: string) { 85 | const result = await db 86 | .select() 87 | .from(tasksTable) 88 | .where(eq(tasksTable.id, taskId)) 89 | 90 | if (result.length > 0) { 91 | return result[0] 92 | } 93 | 94 | return null 95 | } 96 | 97 | async create(values: TaskCreate) { 98 | return db.insert(tasksTable).values(values) 99 | } 100 | 101 | async upsert( 102 | values: TaskCreate, 103 | onConflictSet?: (keyof Pick< 104 | TaskCreate, 105 | 'startedAt' | 'state' | 'queuedAt' 106 | >)[] 107 | ) { 108 | if (!onConflictSet || onConflictSet?.length === 0) { 109 | return db.insert(tasksTable).values(values).onConflictDoNothing({ 110 | target: tasksTable.id 111 | }) 112 | } 113 | 114 | const set: Record = {} 115 | if (onConflictSet) { 116 | for (const key of onConflictSet) { 117 | set[key] = values[key] 118 | } 119 | } 120 | return db.insert(tasksTable).values(values).onConflictDoUpdate({ 121 | target: tasksTable.id, 122 | set 123 | }) 124 | } 125 | 126 | async update( 127 | taskId: string, 128 | values: { 129 | startedAt?: Date | null 130 | error?: string | null 131 | executionTime?: number 132 | finishedAt?: Date | null 133 | returnValue?: { return_value: any } | null 134 | state?: TaskState 135 | } 136 | ) { 137 | return db.update(tasksTable).set(values).where(eq(tasksTable.id, taskId)) 138 | } 139 | 140 | async deleteOld({ ttlMinutes }: { ttlMinutes: number }) { 141 | const now_ = utcNow() 142 | const dateToCompate = now_.subtract(ttlMinutes, 'minutes').toDate() 143 | return db.delete(tasksTable).where(lte(tasksTable.queuedAt, dateToCompate)) 144 | } 145 | 146 | async setAbandoned() { 147 | return db 148 | .update(tasksTable) 149 | .set({ state: 'abandoned' }) 150 | .where(eq(tasksTable.state, 'running')) 151 | } 152 | async promoteToRunning(id: string, startedAt: Date) { 153 | return db 154 | .update(tasksTable) 155 | .set({ startedAt, state: 'running' }) 156 | .where(and(eq(tasksTable.id, id), eq(tasksTable.state, 'queued'))) 157 | } 158 | } 159 | 160 | export const tasksRepository = new TasksRepository() 161 | -------------------------------------------------------------------------------- /app/assets/css/main.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | 3 | @custom-variant dark (&:is(.dark *)); 4 | 5 | :root { 6 | --background: oklch(1 0 0); 7 | --foreground: oklch(0.145 0 0); 8 | --card: oklch(1 0 0); 9 | --card-foreground: oklch(0.145 0 0); 10 | --popover: oklch(1 0 0); 11 | --popover-foreground: oklch(0.145 0 0); 12 | --primary: oklch(0.205 0 0); 13 | --primary-foreground: oklch(0.985 0 0); 14 | --secondary: oklch(0.97 0 0); 15 | --secondary-foreground: oklch(0.205 0 0); 16 | --muted: oklch(0.97 0 0); 17 | --muted-foreground: oklch(0.556 0 0); 18 | --accent: oklch(0.97 0 0); 19 | --accent-foreground: oklch(0.205 0 0); 20 | --destructive: oklch(0.577 0.245 27.325); 21 | --destructive-foreground: oklch(0.577 0.245 27.325); 22 | --border: oklch(0.922 0 0); 23 | --input: oklch(0.922 0 0); 24 | --ring: oklch(0.708 0 0); 25 | --chart-1: oklch(0.646 0.222 41.116); 26 | --chart-2: oklch(0.6 0.118 184.704); 27 | --chart-3: oklch(0.398 0.07 227.392); 28 | --chart-4: oklch(0.828 0.189 84.429); 29 | --chart-5: oklch(0.769 0.188 70.08); 30 | --radius: 0.625rem; 31 | --sidebar: oklch(0.985 0 0); 32 | --sidebar-foreground: oklch(0.145 0 0); 33 | --sidebar-primary: oklch(0.205 0 0); 34 | --sidebar-primary-foreground: oklch(0.985 0 0); 35 | --sidebar-accent: oklch(0.97 0 0); 36 | --sidebar-accent-foreground: oklch(0.205 0 0); 37 | --sidebar-border: oklch(0.922 0 0); 38 | --sidebar-ring: oklch(0.708 0 0); 39 | } 40 | 41 | .dark { 42 | --background: oklch(0.145 0 0); 43 | --foreground: oklch(0.985 0 0); 44 | --card: oklch(0.145 0 0); 45 | --card-foreground: oklch(0.985 0 0); 46 | --popover: oklch(0.145 0 0); 47 | --popover-foreground: oklch(0.985 0 0); 48 | --primary: oklch(0.985 0 0); 49 | --primary-foreground: oklch(0.205 0 0); 50 | --secondary: oklch(0.269 0 0); 51 | --secondary-foreground: oklch(0.985 0 0); 52 | --muted: oklch(0.269 0 0); 53 | --muted-foreground: oklch(0.708 0 0); 54 | --accent: oklch(0.269 0 0); 55 | --accent-foreground: oklch(0.985 0 0); 56 | --destructive: oklch(0.396 0.141 25.723); 57 | --destructive-foreground: oklch(0.637 0.237 25.331); 58 | --border: oklch(0.269 0 0); 59 | --input: oklch(0.269 0 0); 60 | --ring: oklch(0.439 0 0); 61 | --chart-1: oklch(0.488 0.243 264.376); 62 | --chart-2: oklch(0.696 0.17 162.48); 63 | --chart-3: oklch(0.769 0.188 70.08); 64 | --chart-4: oklch(0.627 0.265 303.9); 65 | --chart-5: oklch(0.645 0.246 16.439); 66 | --sidebar: oklch(0.205 0 0); 67 | --sidebar-foreground: oklch(0.985 0 0); 68 | --sidebar-primary: oklch(0.488 0.243 264.376); 69 | --sidebar-primary-foreground: oklch(0.985 0 0); 70 | --sidebar-accent: oklch(0.269 0 0); 71 | --sidebar-accent-foreground: oklch(0.985 0 0); 72 | --sidebar-border: oklch(0.269 0 0); 73 | --sidebar-ring: oklch(0.439 0 0); 74 | } 75 | 76 | @theme inline { 77 | --color-background: var(--background); 78 | --color-foreground: var(--foreground); 79 | --color-card: var(--card); 80 | --color-card-foreground: var(--card-foreground); 81 | --color-popover: var(--popover); 82 | --color-popover-foreground: var(--popover-foreground); 83 | --color-primary: var(--primary); 84 | --color-primary-foreground: var(--primary-foreground); 85 | --color-secondary: var(--secondary); 86 | --color-secondary-foreground: var(--secondary-foreground); 87 | --color-muted: var(--muted); 88 | --color-muted-foreground: var(--muted-foreground); 89 | --color-accent: var(--accent); 90 | --color-accent-foreground: var(--accent-foreground); 91 | --color-destructive: var(--destructive); 92 | --color-destructive-foreground: var(--destructive-foreground); 93 | --color-border: var(--border); 94 | --color-input: var(--input); 95 | --color-ring: var(--ring); 96 | --color-chart-1: var(--chart-1); 97 | --color-chart-2: var(--chart-2); 98 | --color-chart-3: var(--chart-3); 99 | --color-chart-4: var(--chart-4); 100 | --color-chart-5: var(--chart-5); 101 | --radius-sm: calc(var(--radius) - 4px); 102 | --radius-md: calc(var(--radius) - 2px); 103 | --radius-lg: var(--radius); 104 | --radius-xl: calc(var(--radius) + 4px); 105 | --color-sidebar: var(--sidebar); 106 | --color-sidebar-foreground: var(--sidebar-foreground); 107 | --color-sidebar-primary: var(--sidebar-primary); 108 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); 109 | --color-sidebar-accent: var(--sidebar-accent); 110 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); 111 | --color-sidebar-border: var(--sidebar-border); 112 | --color-sidebar-ring: var(--sidebar-ring); 113 | } 114 | 115 | @layer base { 116 | * { 117 | @apply border-border outline-ring/50; 118 | } 119 | body { 120 | @apply bg-background text-foreground; 121 | } 122 | ::-webkit-scrollbar { 123 | @apply w-2.5 h-2.5; 124 | } 125 | 126 | ::-webkit-scrollbar-track { 127 | @apply bg-transparent; 128 | } 129 | 130 | ::-webkit-scrollbar-thumb { 131 | @apply rounded-full bg-border border-[1px] border-transparent border-solid bg-clip-padding; 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /app/components/tasks-table.vue: -------------------------------------------------------------------------------- 1 | 79 | 80 | 205 | -------------------------------------------------------------------------------- /app/pages/tasks/[id].vue: -------------------------------------------------------------------------------- 1 | 47 | 48 | 235 | 236 | 254 | -------------------------------------------------------------------------------- /app/pages/tasks/index.vue: -------------------------------------------------------------------------------- 1 | 199 | 200 | 337 | --------------------------------------------------------------------------------