diff --git a/src/app.css b/src/app.css index 421c1462..028c7f93 100644 --- a/src/app.css +++ b/src/app.css @@ -1,6 +1,7 @@ @import 'tailwindcss'; @plugin 'daisyui' { + exclude: rootscrollgutter; themes: light --default, dark --prefersdark; @@ -38,6 +39,8 @@ --border: 1px; --depth: 0; --noise: 0; + + --color-link: #55f; } @plugin "daisyui/theme" { name: 'dark'; @@ -72,6 +75,8 @@ --border: 1px; --depth: 0; --noise: 0; + + --color-link: #68f; } /* @@ -134,7 +139,11 @@ 0 4px 6px -4px rgb(0 0 0 / 0.1); } a.link { - color: #55f; + color: var(--color-link); +} + +a.link:hover { + color: color-mix(in oklab, var(--color-link), #fff 15%); } .toggle { diff --git a/src/lib/components/AppTypeSelector.svelte b/src/lib/components/AppTypeSelector.svelte new file mode 100644 index 00000000..37724a02 --- /dev/null +++ b/src/lib/components/AppTypeSelector.svelte @@ -0,0 +1,99 @@ + + +{#if attr.name} + +{/if} + + + {#snippet label()} +
+ {#if current} + + + {current} + + {:else} + All application types + {/if} + +
+ {/snippet} + {#snippet content()} + + {/snippet} +
diff --git a/src/lib/components/Dropdown.svelte b/src/lib/components/Dropdown.svelte new file mode 100644 index 00000000..cc63bad3 --- /dev/null +++ b/src/lib/components/Dropdown.svelte @@ -0,0 +1,62 @@ + + + + + + { + // Only close if click is outside this dropdown + if (open && dropEl && !dropEl.contains(e.target as Node)) { + open = false; + // stopPropagation prevents the click from registering on the dropdown again and reopening it + e.stopPropagation(); + } + }} +/> + +
+ onclick?.()}> + {@render label()} + +
+ {@render content()} +
+
diff --git a/src/lib/components/IconContainer.svelte b/src/lib/components/IconContainer.svelte deleted file mode 100644 index f34fb0ad..00000000 --- a/src/lib/components/IconContainer.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - -
- -
diff --git a/src/lib/components/SearchBar.svelte b/src/lib/components/SearchBar.svelte new file mode 100644 index 00000000..253ad747 --- /dev/null +++ b/src/lib/components/SearchBar.svelte @@ -0,0 +1,61 @@ + + + + +
+ +
diff --git a/src/lib/components/SecureDisplay.svelte b/src/lib/components/SecureDisplay.svelte new file mode 100644 index 00000000..50310574 --- /dev/null +++ b/src/lib/components/SecureDisplay.svelte @@ -0,0 +1,29 @@ + + + + + {#if value?.length} + + {/if} + diff --git a/src/lib/components/SortTable.svelte b/src/lib/components/SortTable.svelte index 58b72aa3..f052094e 100644 --- a/src/lib/components/SortTable.svelte +++ b/src/lib/components/SortTable.svelte @@ -4,7 +4,8 @@ --> + + + {@render children?.()} + + + diff --git a/src/lib/icons/ArrowDownIcon.svelte b/src/lib/icons/ArrowDownIcon.svelte deleted file mode 100644 index bd0bba2e..00000000 --- a/src/lib/icons/ArrowDownIcon.svelte +++ /dev/null @@ -1,10 +0,0 @@ - - - - diff --git a/src/lib/icons/ArrowUpIcon.svelte b/src/lib/icons/ArrowUpIcon.svelte deleted file mode 100644 index fb9ad43a..00000000 --- a/src/lib/icons/ArrowUpIcon.svelte +++ /dev/null @@ -1,10 +0,0 @@ - - - - diff --git a/src/lib/icons/IconContainer.svelte b/src/lib/icons/IconContainer.svelte new file mode 100644 index 00000000..8343b9bb --- /dev/null +++ b/src/lib/icons/IconContainer.svelte @@ -0,0 +1,28 @@ + + + + {#if imageCheck.test(icon)} + + {:else} + + {/if} + diff --git a/src/lib/icons/app-builders/dictionaryappbuilder.svg b/src/lib/icons/app-builders/dictionaryappbuilder.svg new file mode 100644 index 00000000..6329653c --- /dev/null +++ b/src/lib/icons/app-builders/dictionaryappbuilder.svg @@ -0,0 +1,421 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + diff --git a/src/lib/icons/app-builders/keyboardappbuilder.svg b/src/lib/icons/app-builders/keyboardappbuilder.svg new file mode 100644 index 00000000..74d0514e --- /dev/null +++ b/src/lib/icons/app-builders/keyboardappbuilder.svg @@ -0,0 +1,289 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/lib/icons/app-builders/readingappbuilder.svg b/src/lib/icons/app-builders/readingappbuilder.svg new file mode 100644 index 00000000..080b6d0e --- /dev/null +++ b/src/lib/icons/app-builders/readingappbuilder.svg @@ -0,0 +1,422 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + diff --git a/src/lib/icons/app-builders/scriptureappbuilder.svg b/src/lib/icons/app-builders/scriptureappbuilder.svg new file mode 100644 index 00000000..7a1a5dc8 --- /dev/null +++ b/src/lib/icons/app-builders/scriptureappbuilder.svg @@ -0,0 +1,430 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + diff --git a/src/lib/icons/index.ts b/src/lib/icons/index.ts index 1799b70c..e04e0d8a 100644 --- a/src/lib/icons/index.ts +++ b/src/lib/icons/index.ts @@ -1,4 +1,56 @@ -import ArrowDownIcon from './ArrowDownIcon.svelte'; -import ArrowUpIcon from './ArrowUpIcon.svelte'; +import type { ApplicationType } from '$lib/valibot'; -export { ArrowDownIcon, ArrowUpIcon }; +const appIcons = import.meta.glob('/src/lib/icons/app-builders/*.svg', { + eager: true, + import: 'default' +}) as Record; + +export function getAppIcon(type: ApplicationType) { + return appIcons[`/src/lib/icons/app-builders/${type}.svg`] ?? ''; +} + +export function getStatusIcon(status: string) { + switch (status.toLowerCase()) { + case 'success': + return { color: 'text-success', icon: 'icon-park-outline:success' }; + case 'failure': + return { color: 'text-error', icon: 'material-symbols:error-outline-rounded' }; + case 'aborted': + return { color: 'text-warning', icon: 'ix:cancelled' }; + case 'pending': + return { icon: 'material-symbols:pending-outline' }; + default: + return { icon: Icons.Unknown }; + } +} + +export const Icons = { + Bucket: 'logos:aws-s3', + Build: 'material-symbols:build', + Close: 'mdi:close', + CodeBuild: 'logos:aws-codebuild', + CodeCommit: 'logos:aws-codecommit', + Dashboard: 'clarity:dashboard-line', + Delete: 'mdi:trash', + Edit: 'mdi:pencil', + Hamburger: 'mdi:hamburger-menu', + Invisible: 'mdi:eye-off-outline', + Key: 'material-symbols:key', + Logout: 'mdi:logout', + Open: 'mdi:open-in-new', + Product: 'system-uicons:box', + Project: 'material-symbols:credit-card-outline', + Publish: 'material-symbols:publish', + Search: 'mdi:search', + SortAsc: 'bx:sort-a-z', + SortDesc: 'bx:sort-z-a', + Unknown: 'carbon:unknown', + User: 'mdi:user', + View: 'mdi:eye', + Visible: 'mdi:eye' +} as const; + +export type IconType = + | (typeof Icons)[keyof typeof Icons] + | ReturnType + | ReturnType['icon']; diff --git a/src/lib/server/job-executors/system.ts b/src/lib/server/job-executors/system.ts index 65ca7c25..c36fbe08 100644 --- a/src/lib/server/job-executors/system.ts +++ b/src/lib/server/job-executors/system.ts @@ -17,6 +17,7 @@ import { S3 } from '../aws/s3'; import type { BullMQ } from '../bullmq'; import { prisma } from '../prisma'; import { AWSVars } from '$lib/server/aws/vars'; +import { type ApplicationType, applicationTypes } from '$lib/valibot'; type Logger = (msg: string) => void; @@ -118,21 +119,12 @@ async function copyFolder(sourceFolder: string, bucket: string, log: Logger) { } } -const appNames = [ - 'scriptureappbuilder', - 'readingappbuilder', - 'dictionaryappbuilder', - 'keyboardappbuilder' -] as const; - -type App = (typeof appNames)[number]; - export async function refreshAppVersions( job: Job ): Promise { // db connectivity handled by server hooks // Prepare default response structure - let versions: Map | null = null; + let versions: Map | null = null; let imageHash: string | null = null; const repoConfig = AWSVars.imageRepo(); @@ -305,7 +297,7 @@ async function fetchAllAppVersionsFromManifest( repoName: string, imageTag: string, log: Logger -): Promise | null> { +): Promise | null> { try { log(`batchGetImage for ${imageTag}`); const resp = await client.send( @@ -403,14 +395,14 @@ async function fetchAllAppVersionsFromManifest( } // Extract version for each app from labels - const appVersions = new Map( - appNames + const appVersions = new Map( + applicationTypes .map((app) => { const labelKey = `org.opencontainers.image.version_${app}`; if (labels[labelKey]) { const version = labels[labelKey]; log(`found version for ${app}: ${version} (label: ${labelKey}) for ${imageTag}`); - return [app, version] as [App, string]; + return [app, version] as [ApplicationType, string]; } else { log(`no version found for ${app} (looked for label: ${labelKey}) for ${imageTag}`); return null; diff --git a/src/lib/server/models/job.ts b/src/lib/server/models/job.ts index a9e83999..c3a2edec 100644 --- a/src/lib/server/models/job.ts +++ b/src/lib/server/models/job.ts @@ -1,9 +1,11 @@ +import type { ApplicationType } from '$lib/valibot'; + // eslint-disable-next-line @typescript-eslint/no-namespace export namespace Job { - export enum AppType { - ScriptureApp = 'scriptureappbuilder', - ReadingApp = 'readingappbuilder', - DictionaryApp = 'dictionaryappbuilder', - KeyboardApp = 'keyboardappbuilder' - } + export const AppType = { + ScriptureApp: 'scriptureappbuilder', + ReadingApp: 'readingappbuilder', + DictionaryApp: 'dictionaryappbuilder', + KeyboardApp: 'keyboardappbuilder' + } satisfies Record; } diff --git a/src/lib/server/utils.ts b/src/lib/server/utils.ts index 98863cea..e69de29b 100644 --- a/src/lib/server/utils.ts +++ b/src/lib/server/utils.ts @@ -1,5 +0,0 @@ -export class Utils { - public static getPrefix() { - return new Date().toISOString(); - } -} diff --git a/src/lib/utils/sorting.ts b/src/lib/utils/sorting.ts new file mode 100644 index 00000000..2dc69b13 --- /dev/null +++ b/src/lib/utils/sorting.ts @@ -0,0 +1,18 @@ +interface NamedEntity { + Name: string | null | undefined; +} + +export function byName( + a: NamedEntity | null | undefined, + b: NamedEntity | null | undefined +): number { + return byString(a?.Name, b?.Name); +} + +export function byString(a: string | null | undefined, b: string | null | undefined): number { + return a?.localeCompare(b ?? '', 'en-US') ?? 0; +} + +export function byNumber(a: number | bigint | null, b: number | bigint | null): number { + return a === b ? 0 : (a ?? 0) > (b ?? 0) ? 1 : -1; +} diff --git a/src/lib/utils/time.ts b/src/lib/utils/time.ts index 3382e0b2..21b04f17 100644 --- a/src/lib/utils/time.ts +++ b/src/lib/utils/time.ts @@ -1,13 +1,64 @@ +import { type Readable, readable } from 'svelte/store'; + +const locale = 'en-US'; +const relativeTimeFormatter = new Intl.RelativeTimeFormat(locale); + type DateType = Date | string | null; +export function getRelativeTime(date: DateType): Readable; +export function getRelativeTime(date: DateType[]): Readable; +export function getRelativeTime(date: DateType | DateType[]): Readable { + if (Array.isArray(date)) { + return readable( + date.map((d) => getRelativeTimeValue(d)), + (set) => { + const interval = setInterval(() => { + set(date.map((d) => getRelativeTimeValue(d))); + }, 1000); + return () => clearInterval(interval); + } + ); + } + return readable(getRelativeTimeValue(date), (set) => { + const interval = setInterval(() => { + set(getRelativeTimeValue(date)); + }, 1000); + return () => clearInterval(interval); + }); +} +export function getRelativeTimeValue(date: DateType): string { + if (typeof date === 'string') { + date = new Date(date); + } + if (!date) return '-'; + // in miliseconds + const units = { + year: 24 * 60 * 60 * 1000 * 365, + month: (24 * 60 * 60 * 1000 * 365) / 12, + day: 24 * 60 * 60 * 1000, + hour: 60 * 60 * 1000, + minute: 60 * 1000, + second: 1000 + } as const; + const elapsed = date.valueOf() - new Date().valueOf(); + for (const u in units) { + if (Math.abs(elapsed) > units[u as keyof typeof units] || u == 'second') { + const ret = relativeTimeFormatter.format( + Math.round(elapsed / units[u as keyof typeof units]), + u as keyof typeof units + ); + return ret; + } + } + return 'ERROR'; +} export function getTimeDateString(date: DateType): string { if (typeof date === 'string') { date = new Date(date); } - // using en-US as locale until we need to support other locales - return `${date?.toLocaleDateString('en-US') ?? '-'} ${ + return `${date?.toLocaleDateString(locale) ?? '-'} ${ date - ?.toLocaleTimeString('en-US', { + ?.toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit', hour12: true diff --git a/src/lib/valibot.ts b/src/lib/valibot.ts index cbd25fd4..d978e2bd 100644 --- a/src/lib/valibot.ts +++ b/src/lib/valibot.ts @@ -35,6 +35,7 @@ export function selectFrom>(entries: T) { } export const tableSchema = v.object({ + search: v.string(), page: paginateSchema, sort: v.nullable( v.object({ @@ -119,3 +120,11 @@ export function trimStrings>( } return obj; } + +export const applicationTypes = [ + 'scriptureappbuilder', + 'dictionaryappbuilder', + 'readingappbuilder', + 'keyboardappbuilder' +] as const; +export type ApplicationType = (typeof applicationTypes)[number]; diff --git a/src/routes/(api)/project/+server.ts b/src/routes/(api)/project/+server.ts index bcc512d7..e9582e1f 100644 --- a/src/routes/(api)/project/+server.ts +++ b/src/routes/(api)/project/+server.ts @@ -4,18 +4,10 @@ import type { RequestHandler } from './$types'; import { AWSVars } from '$lib/server/aws/vars'; import { prisma } from '$lib/server/prisma'; import { ErrorResponse } from '$lib/utils'; -import { stringLimits } from '$lib/valibot'; +import { applicationTypes, stringLimits } from '$lib/valibot'; const projectSchema = v.strictObject({ - app_id: v.pipe( - v.string(), - v.picklist([ - 'scriptureappbuilder', - 'dictionaryappbuilder', - 'readingappbuilder', - 'keyboardappbuilder' - ]) - ), + app_id: v.pipe(v.string(), v.picklist(applicationTypes)), project_name: v.pipe(v.string(), v.maxBytes(stringLimits.project.project_name)), language_code: v.pipe(v.string(), v.maxBytes(stringLimits.project.language_code)), storage_type: v.literal('s3') diff --git a/src/routes/(ui)/+layout.server.ts b/src/routes/(ui)/+layout.server.ts index e5cf9daa..62e7f036 100644 --- a/src/routes/(ui)/+layout.server.ts +++ b/src/routes/(ui)/+layout.server.ts @@ -1,5 +1,15 @@ import type { LayoutServerLoad } from './$types'; +import { prisma } from '$lib/server/prisma'; export const load = (async ({ locals }) => { - return { userEmail: locals.userEmail }; + return { + userEmail: locals.userEmail, + count: { + client: await prisma.client.count(), + project: await prisma.project.count(), + job: await prisma.job.count(), + build: await prisma.build.count(), + release: await prisma.release.count() + } + }; }) satisfies LayoutServerLoad; diff --git a/src/routes/(ui)/+layout.svelte b/src/routes/(ui)/+layout.svelte index e1fe5692..c3a9f6f2 100644 --- a/src/routes/(ui)/+layout.svelte +++ b/src/routes/(ui)/+layout.svelte @@ -2,7 +2,9 @@ import type { Snippet } from 'svelte'; import type { PageData } from './$types'; import { page } from '$app/state'; - import IconContainer from '$lib/components/IconContainer.svelte'; + import Dropdown from '$lib/components/Dropdown.svelte'; + import { type IconType, Icons } from '$lib/icons'; + import IconContainer from '$lib/icons/IconContainer.svelte'; interface Props { children: Snippet; @@ -11,61 +13,150 @@ let { children, data }: Props = $props(); - function isUrlActive(route: string) { - return page.url.pathname === route; + function isUrlActive(route: string, exact = false) { + return exact ? page.url.pathname === route : page.url.pathname.startsWith(route); } + + let drawerToggle: HTMLInputElement; + function closeDrawer() { + if (drawerToggle.checked) { + drawerToggle.click(); + } + } + + const links: { target: keyof PageData['count']; icon: IconType; title: string }[] = [ + { target: 'client', icon: Icons.User, title: 'Clients' }, + { target: 'project', icon: Icons.Project, title: 'Projects' }, + { target: 'job', icon: Icons.Product, title: 'Jobs' }, + { target: 'build', icon: Icons.Build, title: 'Builds' }, + { target: 'release', icon: Icons.Publish, title: 'Releases' } + ]; -
- +
+
+
+ {@render children?.()} +
+
+
+
© SIL Global {new Date().getFullYear()}
+
+ Powered by  + SvelteKit +
+
- - -
-
- {@render children?.()} -
-
-
-
© SIL Global {new Date().getFullYear()}
-
- Powered by  - SvelteKit
-
+ diff --git a/src/routes/(ui)/+page.server.ts b/src/routes/(ui)/+page.server.ts new file mode 100644 index 00000000..214a6ce2 --- /dev/null +++ b/src/routes/(ui)/+page.server.ts @@ -0,0 +1,29 @@ +import type { PageServerLoad } from './$types'; +import { prisma } from '$lib/server/prisma'; + +export const load = (async () => { + return { + aggregate: { + project: await prisma.project.groupBy({ + by: ['app_id'], + _count: true, + orderBy: { _count: { app_id: 'desc' } } + }), + job: await prisma.job.groupBy({ + by: ['app_id'], + _count: true, + orderBy: { _count: { app_id: 'desc' } } + }), + build: await prisma.build.groupBy({ + by: ['result'], + _count: true, + orderBy: { _count: { result: 'desc' } } + }), + release: await prisma.release.groupBy({ + by: ['result'], + _count: true, + orderBy: { _count: { result: 'desc' } } + }) + } + }; +}) satisfies PageServerLoad; diff --git a/src/routes/(ui)/+page.svelte b/src/routes/(ui)/+page.svelte index b730a1c5..55778a51 100644 --- a/src/routes/(ui)/+page.svelte +++ b/src/routes/(ui)/+page.svelte @@ -1,9 +1,28 @@ -
@@ -11,69 +30,49 @@

App Publishing Service

Administration

-
-
-

Job

-

- View, edit, or remove entries from the job table. Jobs point to the AWS S3 repository that - contains the source for the builds and publishes associated with this job. Deleting job - entries also deletes any associated builds and releases associated with the job. -

- Job Administration » -
-
-

Build

-

- View, edit, or remove entries from the build table. Each entry in this table contains a url - link to the instance of the associated AWS Codebuild build attempt. Deleting the build also - deletes any releases associated with this build. -

- Build Administration » -
-
-

Release

-

- View, edit, or remove entries from the release table. Entries in this table relate to - attempts to publish builds in Google Play store or other customized locations. Each entry in - this table contains a url link to the instance of the associated AWS Codebuild publish - attempt. -

- Release Administration » -
-
-

Client

-

- View, edit, or remove entries from the client table. Used if multiple Scriptoria sites are - sending requests to the build engine. Access tokens, which are used for the Authentication: - Bearer fields of requests are entered along with a prefix that is used in naming jobs - associated with this client. -

- Client Administration » -
-
-

Project

-

- View, edit, or remove entries from the project table. Each entry contains a link to be used - by Scripture App Builder to create or update a source repository in AWS S3. -

- Project Administration » -
-
-

Operation Queue

-

- View, edit, or remove entries from the operation queue table. Entries in this table relate - to internal operations that are either queued to be performed, waiting to be retried, or - have failed the maximum number of times and are present for reporting purposes only. -

- - Operation Queue Administration » - -
+
+ {#each cards as { target, title, icon }} + {@const getIcon = target === 'project' || target === 'job' ? getAppIcon : getStatusIcon} +
+
+

+ + + {title} + +

+
+ + + {#each data.aggregate[target] as entry} + {@const result = + ('result' in entry ? (entry.result ?? 'PENDING') : entry.app_id) || 'UNKNOWN'} + {@const icon = getIcon(result as ApplicationType)} + + + + + {/each} + +
+ + {result} + + {entry._count} +
+
+ {/each}
diff --git a/src/routes/(ui)/about/+page.server.ts b/src/routes/(ui)/about/+page.server.ts index af07b7dc..7a9bafd8 100644 --- a/src/routes/(ui)/about/+page.server.ts +++ b/src/routes/(ui)/about/+page.server.ts @@ -3,6 +3,6 @@ import { prisma } from '$lib/server/prisma'; export const load = (async () => { return { - appVersions: await prisma.appVersion.findMany() + appVersions: await prisma.appVersion.findMany({ orderBy: { updated: 'desc' } }) }; }) satisfies PageServerLoad; diff --git a/src/routes/(ui)/about/+page.svelte b/src/routes/(ui)/about/+page.svelte index 8e9439ff..a47ffd10 100644 --- a/src/routes/(ui)/about/+page.svelte +++ b/src/routes/(ui)/about/+page.svelte @@ -1,7 +1,13 @@
@@ -22,27 +30,22 @@

App Publishing Service

Copyright 2011-{new Date().getFullYear()} - SIL Global + SIL Global

Credits

Chris Hubbard ( - SIL Global + SIL Global ) : Dev Lead, Programming

David Moore ( - SIL Global + SIL Global ) : Programming

-

- Rick MacLean ( - SIL Global - ) : Dev Ops -

Aidan Jones ( - SIL Global + SIL Global ) : Programming

AppBuilder Versions

@@ -52,13 +55,20 @@

Updated: - {data.appVersions[0].updated?.toLocaleString()} + {$updated}

- {#each data.appVersions.toSorted((a, b) => a.appName.localeCompare(b.appName)) as version} + {#each data.appVersions.toSorted((a, b) => byString(a.appName, b.appName)) as version} + diff --git a/src/routes/(ui)/build-admin/+page.server.ts b/src/routes/(ui)/build-admin/+page.server.ts index 47e42e52..5cef4334 100644 --- a/src/routes/(ui)/build-admin/+page.server.ts +++ b/src/routes/(ui)/build-admin/+page.server.ts @@ -1,16 +1,26 @@ +import type { Prisma } from '@prisma/client'; import { fail, superValidate } from 'sveltekit-superforms'; import { valibot } from 'sveltekit-superforms/adapters'; import type { Actions, PageServerLoad } from './$types'; import { prisma } from '$lib/server/prisma'; import { tableSchema } from '$lib/valibot'; +const select: Prisma.buildSelect = { + id: true, + job_id: true, + status: true, + result: true, + codebuild_url: true +}; + export const load = (async () => { - const builds = await prisma.build.findMany({ take: 20, orderBy: { id: 'desc' } }); + const builds = await prisma.build.findMany({ select, take: 20, orderBy: { id: 'desc' } }); return { builds, count: await prisma.build.count(), form: await superValidate( { + sort: { field: 'id', direction: 'desc' }, page: { page: 0, size: 20 @@ -26,7 +36,27 @@ export const actions: Actions = { const form = await superValidate(request, valibot(tableSchema)); if (!form.valid) return fail(400, { form, ok: false }); + const jobId = parseInt(form.data.search); + + const where = { + AND: [ + form.data.search + ? { + OR: [ + { job: { request_id: { contains: form.data.search, mode: 'insensitive' } } }, + { job_id: isNaN(jobId) ? undefined : jobId }, + { build_guid: { contains: form.data.search, mode: 'insensitive' } }, + { result: { contains: form.data.search, mode: 'insensitive' } }, + { status: { contains: form.data.search, mode: 'insensitive' } } + ] + } + : {} + ] + } as const satisfies Prisma.buildWhereInput; + const builds = await prisma.build.findMany({ + where, + select, orderBy: form.data.sort ? { [form.data.sort.field]: form.data.sort.direction } : undefined, skip: form.data.page.page * form.data.page.size, take: form.data.page.size @@ -36,7 +66,8 @@ export const actions: Actions = { form, ok: true, query: { - data: builds + data: builds, + count: await prisma.build.count({ where }) } }; } diff --git a/src/routes/(ui)/build-admin/+page.svelte b/src/routes/(ui)/build-admin/+page.svelte index 0616c50c..36a6f5f2 100644 --- a/src/routes/(ui)/build-admin/+page.svelte +++ b/src/routes/(ui)/build-admin/+page.svelte @@ -2,9 +2,10 @@ import { type FormResult, superForm } from 'sveltekit-superforms'; import type { PageData } from './$types'; import Breadcrumbs from '$lib/components/Breadcrumbs.svelte'; - import IconContainer from '$lib/components/IconContainer.svelte'; import Pagination from '$lib/components/Pagination.svelte'; - import SortTable from '$lib/components/SortTable.svelte'; + import SearchBar from '$lib/components/SearchBar.svelte'; + import { Icons, getStatusIcon } from '$lib/icons'; + import IconContainer from '$lib/icons/IconContainer.svelte'; import { title } from '$lib/stores'; $title = 'Builds'; @@ -16,12 +17,15 @@ let { data }: Props = $props(); let builds = $state(data.builds); + let count = $state(data.count); const { form, enhance, submit } = superForm(data.form, { dataType: 'json', resetForm: false, - onChange() { - submit(); + onChange({ paths }) { + if (!paths.includes('search')) { + submit(); + } }, onUpdate(event) { const data = event.result.data as FormResult<{ @@ -29,9 +33,20 @@ }>; if (event.form.valid && data.query) { builds = data.query.data; + count = data.query.count; } } }); + + function submitSearch() { + if ($form.page.page) { + $form.page.page = 0; + } else { + submit(); + } + } + + const mobileSizing = 'w-full md:w-auto';
@@ -39,81 +54,86 @@
  • Home
  • {$title}
  • -

    {$title}

    + +
    { + if (event.key === 'Enter') submitSearch(); + }} + > +
    +
    +

    {$title}

    +
    +
    + +
    +
    +

    Showing {$form.page.page * $form.page.size + 1}-{Math.min( ($form.page.page + 1) * $form.page.size, - data.count + count )} of - {data.count} + {count} items

    - 0 - }, - { - id: 'job_id', - header: 'Job ID', - compare: () => 0 - }, - { - id: 'status', - header: 'Status', - compare: () => 0 - }, - { - id: 'result', - header: 'Result', - compare: () => 0 - }, - { - id: 'build_guid', - header: 'Build GUID', - compare: () => 0 - }, - { - id: 'menu', - header: '' - } - ]} - serverSide={true} - startDesc={true} - onSort={(field, direction) => form.update((data) => ({ ...data, sort: { field, direction } }))} - > - {#snippet row(build, index)} -
    - - - - - - - - - {/snippet} - + {#if build.codebuild_url} + + + CodeBuild + + {/if} + + + {/each} +
    - +
    diff --git a/src/routes/(ui)/client-admin/+page.server.ts b/src/routes/(ui)/client-admin/+page.server.ts index 31782327..4ff8af89 100644 --- a/src/routes/(ui)/client-admin/+page.server.ts +++ b/src/routes/(ui)/client-admin/+page.server.ts @@ -1,17 +1,32 @@ +import type { Prisma } from '@prisma/client'; import { fail, superValidate } from 'sveltekit-superforms'; import { valibot } from 'sveltekit-superforms/adapters'; -import * as v from 'valibot'; import type { Actions, PageServerLoad } from './$types'; import { prisma } from '$lib/server/prisma'; -import { idSchema, tableSchema } from '$lib/valibot'; +import { tableSchema } from '$lib/valibot'; + +const select: Prisma.clientSelect = { + id: true, + prefix: true, + access_token: true, + created: true, + updated: true, + _count: { + select: { + job: true, + project: true + } + } +}; export const load = (async () => { - const clients = await prisma.client.findMany({ take: 20, orderBy: { id: 'desc' } }); + const clients = await prisma.client.findMany({ select, take: 20, orderBy: { id: 'desc' } }); return { clients, count: await prisma.client.count(), form: await superValidate( { + sort: { field: 'id', direction: 'desc' }, page: { page: 0, size: 20 @@ -28,6 +43,7 @@ export const actions: Actions = { if (!form.valid) return fail(400, { form, ok: false }); const clients = await prisma.client.findMany({ + select, orderBy: form.data.sort ? { [form.data.sort.field]: form.data.sort.direction } : undefined, skip: form.data.page.page * form.data.page.size, take: form.data.page.size @@ -40,13 +56,5 @@ export const actions: Actions = { data: clients } }; - }, - deleteClient: async function ({ request }) { - const form = await superValidate(request, valibot(v.object({ id: idSchema }))); - if (!form.valid) return fail(400, { form, ok: false }); - - await prisma.client.delete({ where: { id: form.data.id } }); - - return { form, ok: true }; } }; diff --git a/src/routes/(ui)/client-admin/+page.svelte b/src/routes/(ui)/client-admin/+page.svelte index 58feb18a..6164417d 100644 --- a/src/routes/(ui)/client-admin/+page.svelte +++ b/src/routes/(ui)/client-admin/+page.svelte @@ -1,13 +1,14 @@
    @@ -58,82 +58,41 @@ {data.count} items

    - 0 - }, - { - id: 'access_token', - header: 'Access Token', - compare: () => 0 - }, - { - id: 'prefix', - header: 'Prefix', - compare: () => 0 - }, - { - id: 'created', - header: 'Created', - compare: () => 0 - }, - { - id: 'updated', - header: 'Updated', - compare: () => 0 - }, - { - id: 'menu', - header: '' - } - ]} - serverSide={true} - startDesc={true} - onSort={(field, direction) => form.update((data) => ({ ...data, sort: { field, direction } }))} - > - {#snippet row(client, index)} -
    - - - - - - - - - {/snippet} - - +
    + {#each clients as client, i} +
    +
    +

    + #{client.id} + {client.prefix} +

    +
    + + Created: {$created[i]} + + + Updated: {$updated[i]} + +
    +
    +
    + + +
    +
    +
    + + {client._count.project} +
    +
    + + {client._count.job} +
    +
    +
    + {/each} +
    +
    diff --git a/src/routes/(ui)/job-admin/+page.server.ts b/src/routes/(ui)/job-admin/+page.server.ts index b42da4d8..4532af69 100644 --- a/src/routes/(ui)/job-admin/+page.server.ts +++ b/src/routes/(ui)/job-admin/+page.server.ts @@ -1,32 +1,70 @@ +import type { Prisma } from '@prisma/client'; import { fail, superValidate } from 'sveltekit-superforms'; import { valibot } from 'sveltekit-superforms/adapters'; +import * as v from 'valibot'; import type { Actions, PageServerLoad } from './$types'; import { prisma } from '$lib/server/prisma'; -import { tableSchema } from '$lib/valibot'; +import { applicationTypes, tableSchema } from '$lib/valibot'; + +const select: Prisma.jobSelect = { + id: true, + request_id: true, + app_id: true, + git_url: true, + client: { + select: { + id: true, + prefix: true + } + } +}; + +const searchSchema = v.object({ + appType: v.nullable(v.picklist(applicationTypes)), + ...tableSchema.entries +}); export const load = (async () => { - const jobs = await prisma.job.findMany({ take: 20, orderBy: { id: 'desc' } }); + const jobs = await prisma.job.findMany({ select, take: 20, orderBy: { id: 'desc' } }); return { jobs, count: await prisma.job.count(), form: await superValidate( { + sort: { field: 'id', direction: 'desc' }, page: { page: 0, size: 20 } }, - valibot(tableSchema) + valibot(searchSchema) ) }; }) satisfies PageServerLoad; export const actions: Actions = { page: async function ({ request }) { - const form = await superValidate(request, valibot(tableSchema)); + const form = await superValidate(request, valibot(searchSchema)); if (!form.valid) return fail(400, { form, ok: false }); + const where = { + AND: [ + form.data.appType ? { app_id: form.data.appType } : {}, + form.data.search + ? { + OR: [ + { request_id: { contains: form.data.search, mode: 'insensitive' } }, + { client: { prefix: { contains: form.data.search, mode: 'insensitive' } } }, + { git_url: { contains: form.data.search, mode: 'insensitive' } } + ] + } + : {} + ] + } as const satisfies Prisma.jobWhereInput; + const jobs = await prisma.job.findMany({ + where, + select, orderBy: form.data.sort ? { [form.data.sort.field]: form.data.sort.direction } : undefined, skip: form.data.page.page * form.data.page.size, take: form.data.page.size @@ -36,7 +74,8 @@ export const actions: Actions = { form, ok: true, query: { - data: jobs + data: jobs, + count: await prisma.job.count({ where }) } }; } diff --git a/src/routes/(ui)/job-admin/+page.svelte b/src/routes/(ui)/job-admin/+page.svelte index 48f6e528..28bb5acf 100644 --- a/src/routes/(ui)/job-admin/+page.svelte +++ b/src/routes/(ui)/job-admin/+page.svelte @@ -1,11 +1,15 @@
    @@ -39,93 +59,79 @@
  • Home
  • {$title}
  • -

    {$title}

    + + { + if (event.key === 'Enter') submitSearch(); + }} + > +
    +
    +

    {$title}

    +
    +
    + + +
    +
    +

    Showing {$form.page.page * $form.page.size + 1}-{Math.min( ($form.page.page + 1) * $form.page.size, - data.count + count )} of - {data.count} + {count} items

    - 0 - }, - { - id: 'request_id', - header: 'Request Id', - compare: () => 0 - }, - { - id: 'git_url', - header: 'Git Url', - compare: () => 0 - }, - { - id: 'app_id', - header: 'App ID', - compare: () => 0 - }, - { - id: 'publisher_id', - header: 'Publisher ID', - compare: () => 0 - }, - { - id: 'client_id', - header: 'Client ID', - compare: () => 0 - }, - { - id: 'existing_version_code', - header: 'Existing Version Code', - compare: () => 0 - }, - { - id: 'menu', - header: '' - } - ]} - serverSide={true} - startDesc={true} - onSort={(field, direction) => form.update((data) => ({ ...data, sort: { field, direction } }))} - > - {#snippet row(job, index)} -
    - - - - - - - - - - - {/snippet} - +
    + {#each jobs as job} +
    + +
    + {#if job.client} + + + {job.client.prefix} + + {/if} + {#if job.git_url} + {@const icon = job.git_url.startsWith('s3') ? Icons.Bucket : Icons.CodeCommit} + {@const title = job.git_url.startsWith('s3') ? 'S3 Bucket' : 'CodeCommit Repo'} + + + {title} + + + {/if} +
    +
    + {/each} +
    - +
    diff --git a/src/routes/(ui)/project-admin/+page.server.ts b/src/routes/(ui)/project-admin/+page.server.ts index c6f72524..9005a3a5 100644 --- a/src/routes/(ui)/project-admin/+page.server.ts +++ b/src/routes/(ui)/project-admin/+page.server.ts @@ -1,32 +1,70 @@ +import type { Prisma } from '@prisma/client'; import { fail, superValidate } from 'sveltekit-superforms'; import { valibot } from 'sveltekit-superforms/adapters'; +import * as v from 'valibot'; import type { Actions, PageServerLoad } from './$types'; import { prisma } from '$lib/server/prisma'; -import { tableSchema } from '$lib/valibot'; +import { applicationTypes, tableSchema } from '$lib/valibot'; + +const select: Prisma.projectSelect = { + id: true, + project_name: true, + app_id: true, + client: { + select: { + id: true, + prefix: true + } + }, + url: true +}; + +const searchSchema = v.object({ + appType: v.nullable(v.picklist(applicationTypes)), + ...tableSchema.entries +}); export const load = (async () => { - const projects = await prisma.project.findMany({ take: 20, orderBy: { id: 'desc' } }); + const projects = await prisma.project.findMany({ select, take: 20, orderBy: { id: 'desc' } }); return { projects, count: await prisma.project.count(), form: await superValidate( { + sort: { field: 'id', direction: 'desc' }, page: { page: 0, size: 20 } }, - valibot(tableSchema) + valibot(searchSchema) ) }; }) satisfies PageServerLoad; export const actions: Actions = { page: async function ({ request }) { - const form = await superValidate(request, valibot(tableSchema)); + const form = await superValidate(request, valibot(searchSchema)); if (!form.valid) return fail(400, { form, ok: false }); + const where = { + AND: [ + form.data.appType ? { app_id: form.data.appType } : {}, + form.data.search + ? { + OR: [ + { project_name: { contains: form.data.search, mode: 'insensitive' } }, + { client: { prefix: { contains: form.data.search, mode: 'insensitive' } } }, + { url: { contains: form.data.search, mode: 'insensitive' } } + ] + } + : {} + ] + } as const satisfies Prisma.projectWhereInput; + const projects = await prisma.project.findMany({ + where, + select, orderBy: form.data.sort ? { [form.data.sort.field]: form.data.sort.direction } : undefined, skip: form.data.page.page * form.data.page.size, take: form.data.page.size @@ -36,7 +74,8 @@ export const actions: Actions = { form, ok: true, query: { - data: projects + data: projects, + count: await prisma.project.count({ where }) } }; } diff --git a/src/routes/(ui)/project-admin/+page.svelte b/src/routes/(ui)/project-admin/+page.svelte index 4fb88ef5..cff4f078 100644 --- a/src/routes/(ui)/project-admin/+page.svelte +++ b/src/routes/(ui)/project-admin/+page.svelte @@ -1,11 +1,14 @@
    @@ -39,93 +58,74 @@
  • Home
  • {$title}
  • -

    {$title}

    + +
    { + if (event.key === 'Enter') submitSearch(); + }} + > +
    +
    +

    {$title}

    +
    +
    + + +
    +
    +

    Showing {$form.page.page * $form.page.size + 1}-{Math.min( ($form.page.page + 1) * $form.page.size, - data.count + count )} of - {data.count} + {count} items

    - 0 - }, - { - id: 'status', - header: 'Status', - compare: () => 0 - }, - { - id: 'url', - header: 'Url', - compare: () => 0 - }, - { - id: 'user_id', - header: 'User ID', - compare: () => 0 - }, - { - id: 'group_id', - header: 'Group ID', - compare: () => 0 - }, - { - id: 'app_id', - header: 'App ID', - compare: () => 0 - }, - { - id: 'project_name', - header: 'Project Name', - compare: () => 0 - }, - { - id: 'menu', - header: '' - } - ]} - serverSide={true} - startDesc={true} - onSort={(field, direction) => form.update((data) => ({ ...data, sort: { field, direction } }))} - > - {#snippet row(project, index)} -
    - - - - - - - - - - - {/snippet} - +
    + {#each projects as project} +
    +
    +

    + #{project.id} + {project.app_id} + {project.project_name} +

    +
    +
    + {#if project.client} + + + {project.client.prefix} + + {/if} + {#if project.url} + + + S3 Bucket + + {/if} +
    +
    + {/each} +
    - +
    diff --git a/src/routes/(ui)/release-admin/+page.server.ts b/src/routes/(ui)/release-admin/+page.server.ts index b707bd51..766f07c0 100644 --- a/src/routes/(ui)/release-admin/+page.server.ts +++ b/src/routes/(ui)/release-admin/+page.server.ts @@ -1,20 +1,35 @@ +import type { Prisma } from '@prisma/client'; import { fail, superValidate } from 'sveltekit-superforms'; import { valibot } from 'sveltekit-superforms/adapters'; import type { Actions, PageServerLoad } from './$types'; import { prisma } from '$lib/server/prisma'; import { tableSchema } from '$lib/valibot'; +const select: Prisma.releaseSelect = { + id: true, + build: { + select: { + id: true, + job_id: true + } + }, + status: true, + result: true, + codebuild_url: true +}; + export const load = (async () => { const releases = await prisma.release.findMany({ + select, take: 20, - orderBy: { id: 'desc' }, - include: { build: true } + orderBy: { id: 'desc' } }); return { releases, count: await prisma.release.count(), form: await superValidate( { + sort: { field: 'id', direction: 'desc' }, page: { page: 0, size: 20 @@ -30,7 +45,35 @@ export const actions: Actions = { const form = await superValidate(request, valibot(tableSchema)); if (!form.valid) return fail(400, { form, ok: false }); + const jobOrBuildId = parseInt(form.data.search); + + const where = { + AND: [ + form.data.search + ? { + OR: [ + { + build: { + OR: [ + { job: { request_id: { contains: form.data.search, mode: 'insensitive' } } }, + { build_guid: { contains: form.data.search, mode: 'insensitive' } }, + { job_id: isNaN(jobOrBuildId) ? undefined : jobOrBuildId } + ] + } + }, + { build_id: isNaN(jobOrBuildId) ? undefined : jobOrBuildId }, + { build_guid: { contains: form.data.search, mode: 'insensitive' } }, + { result: { contains: form.data.search, mode: 'insensitive' } }, + { status: { contains: form.data.search, mode: 'insensitive' } } + ] + } + : {} + ] + } as const satisfies Prisma.releaseWhereInput; + const releases = await prisma.release.findMany({ + where, + select, orderBy: form.data.sort ? { [form.data.sort.field]: form.data.sort.direction } : undefined, skip: form.data.page.page * form.data.page.size, take: form.data.page.size @@ -40,7 +83,8 @@ export const actions: Actions = { form, ok: true, query: { - data: releases + data: releases, + count: await prisma.release.count({ where }) } }; } diff --git a/src/routes/(ui)/release-admin/+page.svelte b/src/routes/(ui)/release-admin/+page.svelte index 8a96bc12..ff2d5835 100644 --- a/src/routes/(ui)/release-admin/+page.svelte +++ b/src/routes/(ui)/release-admin/+page.svelte @@ -2,9 +2,10 @@ import { type FormResult, superForm } from 'sveltekit-superforms'; import type { PageData } from './$types'; import Breadcrumbs from '$lib/components/Breadcrumbs.svelte'; - import IconContainer from '$lib/components/IconContainer.svelte'; import Pagination from '$lib/components/Pagination.svelte'; - import SortTable from '$lib/components/SortTable.svelte'; + import SearchBar from '$lib/components/SearchBar.svelte'; + import { Icons, getStatusIcon } from '$lib/icons'; + import IconContainer from '$lib/icons/IconContainer.svelte'; import { title } from '$lib/stores'; $title = 'Releases'; @@ -16,12 +17,15 @@ let { data }: Props = $props(); let releases = $state(data.releases); + let count = $state(data.count); const { form, enhance, submit } = superForm(data.form, { dataType: 'json', resetForm: false, - onChange() { - submit(); + onChange({ paths }) { + if (!paths.includes('search')) { + submit(); + } }, onUpdate(event) { const data = event.result.data as FormResult<{ @@ -29,9 +33,20 @@ }>; if (event.form.valid && data.query) { releases = data.query.data; + count = data.query.count; } } }); + + function submitSearch() { + if ($form.page.page) { + $form.page.page = 0; + } else { + submit(); + } + } + + const mobileSizing = 'w-full md:w-auto';
    @@ -39,93 +54,90 @@
  • Home
  • {$title}
  • -

    {$title}

    + +
    { + if (event.key === 'Enter') submitSearch(); + }} + > +
    +
    +

    {$title}

    +
    +
    + +
    +
    +

    Showing {$form.page.page * $form.page.size + 1}-{Math.min( ($form.page.page + 1) * $form.page.size, - data.count + count )} of - {data.count} + {count} items

    - 0 - }, - { - id: 'job_id', - header: 'Job ID', - compare: () => 0 - }, - { - id: 'build_id', - header: 'Build ID', - compare: () => 0 - }, - { - id: 'status', - header: 'Status', - compare: () => 0 - }, - { - id: 'result', - header: 'Result', - compare: () => 0 - }, - { - id: 'build_guid', - header: 'Build GUID', - compare: () => 0 - }, - { - id: 'menu', - header: '' - } - ]} - serverSide={true} - startDesc={true} - onSort={(field, direction) => form.update((data) => ({ ...data, sort: { field, direction } }))} - > - {#snippet row(release, index)} -
    - - - - - - - - - - {/snippet} - + {#if release.codebuild_url} + + + CodeBuild + + {/if} + + + {/each} +
    - +
    + + {version.appName}: {version.version}
    {index + 1}{build.id}{build.job_id}{build.status}{build.result}{build.build_guid} - - - - - +
    + {#each builds as build} + {@const status = build.result || build.status} +
    +
    +

    + #{build.id} + {#if status} + {@const { icon } = getStatusIcon(status)} + + {#if icon !== Icons.Unknown} + + {/if} + {status} + + {/if} +

    +
    +
    {index + 1}{client.id}{client.access_token}{client.prefix}{getTimeDateString(client.created)}{getTimeDateString(client.updated)} - - - - - - -
    - - -
    -
    {index + 1}{job.id}{job.request_id}{job.git_url}{job.app_id}{job.publisher_id}{job.client_id}{job.existing_version_code} - - - - - - -
    {index + 1}{project.id}{project.status}{project.url}{project.user_id}{project.group_id}{project.app_id}{project.project_name} - - - - - - -
    {index + 1}{release.id} - - {release.build.job_id} - - - {release.build_id} - {release.status}{release.result}{release.build_guid} - - +
    + {#each releases as release} + {@const status = release.result || release.status} +
    +
    +

    + #{release.id} + {#if status} + {@const { icon } = getStatusIcon(status)} + + {#if icon !== Icons.Unknown} + + {/if} + {status} + + {/if} +

    +
    +