diff --git a/packages/core/src/database/contexts/jobContext.ts b/packages/core/src/database/contexts/jobContext.ts index 501d01f4..4a692139 100644 --- a/packages/core/src/database/contexts/jobContext.ts +++ b/packages/core/src/database/contexts/jobContext.ts @@ -20,6 +20,8 @@ import { Id, JobStatSummary, JobTrack, + QueryJobHistory, + Paginated, } from "../../types"; import { isString, promiseMultiSingle, multiSingle } from "../../tools"; import logger from "../../logger"; @@ -448,7 +450,6 @@ export class JobContext extends SubContext { public async getJobHistoryStream(since: Date, limit: number): Promise> { let query = "SELECT * FROM job_history WHERE start < ? ORDER BY start DESC"; const values = [since.toISOString()] as any[]; - console.log(values); if (limit >= 0) { query += " LIMIT ?;"; @@ -461,6 +462,49 @@ export class JobContext extends SubContext { return this.query("SELECT * FROM job_history ORDER BY start;"); } + /** + * Return a paginated query result. + * Returns at most 1000 items but at least 5. + * + * @param filter the query filter + * @returns an array of items + */ + public async getJobHistoryPaginated(filter: QueryJobHistory): Promise> { + let conditions = "WHERE start < ?"; + const values: any[] = [filter.since.toISOString()]; + + if (filter.name) { + values.push(`%${filter.name}%`); + conditions += " AND name like ?"; + } + + if (filter.type) { + values.push(filter.type); + conditions += " AND type = ?"; + } + + if (filter.result) { + values.push(filter.result); + conditions += " AND result = ?"; + } + + conditions += " ORDER BY start DESC"; + const countValues = [...values]; + + const limit = " LIMIT ?;"; + values.push(Math.max(Math.min(filter.limit, 1000), 5)); + + const totalPromise = this.query("SELECT count(*) as total FROM job_history " + conditions, countValues); + const items: JobHistoryItem[] = await this.query("SELECT * FROM job_history " + conditions + limit, values); + const [{ total }]: [{ total: number }] = await totalPromise; + + return { + items, + next: items[items.length - 1] && new Date(items[items.length - 1].start), + total, + }; + } + private async addJobHistory(jobs: JobItem | JobItem[], finished: Date): EmptyPromise { await promiseMultiSingle(jobs, async (value: JobItem) => { let args = value.arguments; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index d06cbfcd..d6b21cff 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1439,6 +1439,8 @@ export interface JobStats extends AllJobStats { name: string; } +export type JobHistoryResult = "warning" | "failed" | "success"; + /** * @openapi * components: @@ -1475,7 +1477,7 @@ export type JobHistoryItem = Pick { + items: T[]; + next: T[K]; + total: number; +} + +export interface QueryJobHistory { + since: Date; + limit: number; + type?: ScrapeName; + result?: JobHistoryResult; + name?: string; +} + export interface Modification { created: number; deleted: number; diff --git a/packages/server/src/api/jobs.ts b/packages/server/src/api/jobs.ts index 8145959e..f4a8504d 100644 --- a/packages/server/src/api/jobs.ts +++ b/packages/server/src/api/jobs.ts @@ -2,6 +2,8 @@ import { jobStorage } from "enterprise-core/dist/database/storages/storage"; import { Router } from "express"; import { GetHistoryJobs, + GetHistoryJobsPaginated, + getHistoryJobsPaginatedSchema, getHistoryJobsSchema, GetJobDetails, getJobDetailsSchema, @@ -29,6 +31,26 @@ export const getHistoryJobs = createHandler( { query: getHistoryJobsSchema }, ); +export const getHistoryJobsPaginated = createHandler( + (req) => { + const query = castQuery(req); + let since = new Date(query.since || ""); + + if (Number.isNaN(since.getTime())) { + since = new Date(); + } + + return jobStorage.getJobHistoryPaginated({ + since, + limit: query.limit ?? 0, + name: query.name, + result: query.result, + type: query.type, + }); + }, + { query: getHistoryJobsPaginatedSchema }, +); + export const postJobEnable = createHandler((req) => { const { id, enabled }: PostJobEnable = req.body; return jobStorage.updateJobsEnable(id, enabled); @@ -128,6 +150,7 @@ export function jobsRouter(): Router { * description: queried job history */ router.get("/history", getHistoryJobs); + router.get("/history-paginated", getHistoryJobsPaginated); /** * @openapi diff --git a/packages/server/src/validation.ts b/packages/server/src/validation.ts index 6850ce1c..8368dffe 100644 --- a/packages/server/src/validation.ts +++ b/packages/server/src/validation.ts @@ -4,12 +4,14 @@ import { AppEventProgram, AppEventType, Id, + JobHistoryResult, Json, Link, MediumInWait, MediumInWaitSearch, MinList, QueryItems, + ScrapeName, SimpleEpisode, SimpleMedium, TimeBucket, @@ -838,6 +840,42 @@ export const getHistoryJobsSchema: JSONSchemaType = { }, }; +export interface GetHistoryJobsPaginated { + since?: string; + name?: string; + type?: ScrapeName; + result?: JobHistoryResult; + limit?: number; +} + +export const getHistoryJobsPaginatedSchema: JSONSchemaType = { + $id: "/GetHistoryJobsPaginated", + type: "object", + properties: { + since: { ...string(), nullable: true }, + name: { ...string(), nullable: true }, + limit: { ...integer({ minimum: 1, maximum: 1000 }), nullable: true }, + type: { + type: "string", + enum: [ + ScrapeName.checkTocs, + ScrapeName.feed, + ScrapeName.news, + ScrapeName.newsAdapter, + ScrapeName.oneTimeToc, + ScrapeName.oneTimeUser, + ScrapeName.queueExternalUser, + ScrapeName.queueTocs, + ScrapeName.remapMediaParts, + ScrapeName.searchForToc, + ScrapeName.toc, + ], + nullable: true, + }, + result: { type: "string", enum: ["failed", "success", "warning"], nullable: true }, + }, +}; + export interface PostJobEnable { id: Id; enabled: boolean; diff --git a/packages/website/src/Httpclient.ts b/packages/website/src/Httpclient.ts index 42c447ef..602699b6 100644 --- a/packages/website/src/Httpclient.ts +++ b/packages/website/src/Httpclient.ts @@ -28,7 +28,8 @@ import { } from "./siteTypes"; import { AddPart, AppEvent, AppEventFilter, EmptyPromise, JobStatSummary } from "enterprise-core/src/types"; import { HookTest, HookTestV2, Status } from "enterprise-server/src/types"; -import { CustomHook, Id, Notification, Nullable, SimpleUser } from "enterprise-core/dist/types"; +import { GetHistoryJobsPaginated } from "enterprise-server/dist/validation"; +import { CustomHook, Id, Notification, Nullable, Paginated, SimpleUser } from "enterprise-core/dist/types"; import qs from "qs"; /** @@ -73,6 +74,9 @@ const restApi = createRestDefinition({ history: { get: true, }, + "history-paginated": { + get: true, + }, stats: { summary: { get: true, @@ -479,6 +483,10 @@ export const HttpClient = { return this.queryServer(serverRestApi.api.user.jobs.history.get, { since, limit }); }, + getJobHistoryPaginated(query: GetHistoryJobsPaginated): Promise> { + return this.queryServer(serverRestApi.api.user.jobs["history-paginated"].get, query); + }, + postJobEnabled(id: number, enabled: boolean): Promise { return this.queryServer(serverRestApi.api.user.jobs.enable.post, { id, enabled }); }, diff --git a/packages/website/src/views/JobHistory.vue b/packages/website/src/views/JobHistory.vue index 2e95e4d4..98c7917f 100644 --- a/packages/website/src/views/JobHistory.vue +++ b/packages/website/src/views/JobHistory.vue @@ -16,34 +16,68 @@ :min="0" /> + - + + - + + @@ -89,6 +123,8 @@ import { defineComponent } from "vue"; import { formatDate, round } from "../init"; import { HttpClient } from "../Httpclient"; import { JobTrack, Modification } from "../siteTypes"; +import { JobHistoryResult, ScrapeName } from "enterprise-core/dist/types"; +import { FilterMatchMode } from "primevue/api"; const tocRegex = /toc-(\d+)-(.+)/; const domainRegex = /https?:\/\/(.+\.)?(\w+)(\.\w+)\/?.*/; @@ -105,6 +141,31 @@ interface HistoryItem { modifications: number; } +interface PageEvent { + filters: unknown; + first: number; + multiSortMeta: unknown[]; + originalEvent: unknown; + page: number; + pageCount: number; + rows: number; + sortField: null; + sortOrder: null; +} + +interface FilterEvent { + filters: { + state: { value: undefined | JobHistoryResult; matchMode: string }; + name: { value: undefined | string; matchMode: string }; + }; + first: number; + multiSortMeta: unknown[]; + originalEvent: unknown; + rows: number; + sortField: null; + sortOrder: null; +} + export default defineComponent({ name: "JobHistory", data() { @@ -113,6 +174,31 @@ export default defineComponent({ now: new Date(), minModifications: -1, loading: false, + total: 0, + rowsPerPage: 100, + currentPage: 0, + pages: [] as Date[], + state: undefined as undefined | JobHistoryResult, + type: undefined as undefined | ScrapeName, + name: undefined as undefined | string, + statuses: ["failed", "success", "warning"] as JobHistoryResult[], + types: [ + ScrapeName.checkTocs, + ScrapeName.feed, + ScrapeName.news, + ScrapeName.newsAdapter, + ScrapeName.oneTimeToc, + ScrapeName.oneTimeUser, + ScrapeName.queueExternalUser, + ScrapeName.queueTocs, + ScrapeName.remapMediaParts, + ScrapeName.searchForToc, + ScrapeName.toc, + ], + filters: { + name: { value: null, matchMode: FilterMatchMode.CONTAINS }, + state: { value: null, matchMode: FilterMatchMode.EQUALS }, + }, }; }, computed: { @@ -120,12 +206,30 @@ export default defineComponent({ return this.jobs.filter((item) => item.modifications >= this.minModifications); }, }, + watch: { + rowsPerPage() { + this.fetch(); + }, + type() { + this.fetch(); + }, + }, mounted() { this.fetch(); }, methods: { - jobStateResult(historyItem: HistoryItem) { - switch (historyItem.state) { + onPage(event: PageEvent) { + this.currentPage = event.page; + this.fetch(); + }, + onFilter(event: FilterEvent) { + this.name = event.filters.name.value; + this.state = event.filters.state.value; + this.fetch(); + console.log(event); + }, + jobStateResult(state: JobHistoryResult) { + switch (state) { case "success": return "success"; case "failed": @@ -164,8 +268,20 @@ export default defineComponent({ this.loading = true; try { // fetch storage jobs data - const history = await HttpClient.getJobHistory(undefined, 100); - this.jobs = history.map((item) => { + const pagination = await HttpClient.getJobHistoryPaginated({ + limit: this.rowsPerPage, + since: this.pages[this.currentPage]?.toISOString(), + name: this.name, + result: this.state, + type: this.type, + }); + this.total = pagination.total; + + if (pagination.next) { + this.pages[this.currentPage + 1] = new Date(pagination.next); + } + + this.jobs = pagination.items.map((item) => { let track: JobTrack | undefined; try { track = JSON.parse(item.message);