diff --git a/package.json b/package.json index cac9028..e680561 100644 --- a/package.json +++ b/package.json @@ -16,11 +16,13 @@ "@fontsource-variable/material-symbols-outlined": "^5.0.24", "@fontsource/jetbrains-mono": "^5.0.19", "@fontsource/lato": "^5.0.19", + "@nanostores/preact": "^0.5.1", "astro": "^4.4.11", "async-mutex": "^0.4.1", "dockerode": "^4.0.2", "js-yaml": "^4.1.0", "lodash": "^4.17.21", + "nanostores": "^0.10.3", "preact": "^10.19.6", "typescript": "^5.3.3", "zod": "^3.22.4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec21c8b..43e7cbe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ dependencies: '@fontsource/lato': specifier: ^5.0.19 version: 5.0.19 + '@nanostores/preact': + specifier: ^0.5.1 + version: 0.5.1(nanostores@0.10.3)(preact@10.19.6) astro: specifier: ^4.4.11 version: 4.4.11(sass@1.71.1)(typescript@5.3.3) @@ -38,6 +41,9 @@ dependencies: lodash: specifier: ^4.17.21 version: 4.17.21 + nanostores: + specifier: ^0.10.3 + version: 0.10.3 preact: specifier: ^10.19.6 version: 10.19.6 @@ -722,6 +728,17 @@ packages: resolution: {integrity: sha512-JmU7JIBwyL8RAzefvzALT4sP2M0biGk8i2invAgpQmma/QgfsaqoHIvJ7S0YC8n9hUVG8X3Leul2nGa06PvhbQ==} dev: false + /@nanostores/preact@0.5.1(nanostores@0.10.3)(preact@10.19.6): + resolution: {integrity: sha512-kofyeDwzM3TrOd37ay+Xxgk3Cn6jih23dxELc7Mr9IJV55jmWATfNP9b7O/awwCL7CE5z5PfzFnNk/W+tMaWGw==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + nanostores: ^0.9.0 || ^0.10.0 + preact: '>=10.0.0' + dependencies: + nanostores: 0.10.3 + preact: 10.19.6 + dev: false + /@nodelib/fs.scandir@2.1.5: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -2989,6 +3006,11 @@ packages: hasBin: true dev: false + /nanostores@0.10.3: + resolution: {integrity: sha512-Nii8O1XqmawqSCf9o2aWqVxhKRN01+iue9/VEd1TiJCr9VT5XxgPFbF1Edl1XN6pwJcZRsl8Ki+z01yb/T/C2g==} + engines: {node: ^18.0.0 || >=20.0.0} + dev: false + /napi-build-utils@1.0.2: resolution: {integrity: sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==} requiresBuild: true diff --git a/src/client/JobLogs.jsx b/src/client/JobLogs.jsx index 99a28ce..00de414 100644 --- a/src/client/JobLogs.jsx +++ b/src/client/JobLogs.jsx @@ -1,24 +1,29 @@ +import { useStore } from '@nanostores/preact' import { useEffect, useState } from 'preact/hooks' +import { triggerCounter } from './store.js' + +export const JobLogs = ({ endpoint }) => { + const $triggerCounter = useStore(triggerCounter) -export const JobLogs = ({}) => { const [logLines, setLogLines] = useState([]) useEffect(async () => { - const res = await fetch(location.href + '/logs?format=raw') + const res = await fetch(endpoint + '?format=raw') const rawLogs = (await res.text()).trim() if (rawLogs.length > 0) setLogLines(rawLogs.split('\n')) + else setLogLines([]) // Setup SSE - const es = new EventSource(location.href + '/logs?format=sse') + const es = new EventSource(endpoint + '?format=sse') es.addEventListener('message', ({ data }) => { const event = JSON.parse(data) setLogLines(lines => [...lines, event.content]) }) - }, []) + }, [$triggerCounter]) // prettier-ignore return ( -
{logLines.join('\n')}
+
{logLines.join('\n')}
) } diff --git a/src/client/TriggerButton.jsx b/src/client/TriggerButton.jsx index db2c694..a5e012f 100644 --- a/src/client/TriggerButton.jsx +++ b/src/client/TriggerButton.jsx @@ -1,4 +1,5 @@ import { useState } from 'preact/hooks' +import { triggerCounter } from './store.js' // target is a url to send a POST request to export const TriggerButton = ({ target, children }) => { @@ -6,6 +7,7 @@ export const TriggerButton = ({ target, children }) => { const handleClick = async () => { setLoading(true) + triggerCounter.set(triggerCounter.get() + 1) const response = await fetch(target, { method: 'POST', diff --git a/src/client/store.js b/src/client/store.js new file mode 100644 index 0000000..e00601e --- /dev/null +++ b/src/client/store.js @@ -0,0 +1,3 @@ +import { atom } from 'nanostores' + +export const triggerCounter = atom(0) diff --git a/src/components/DockerDeployInfo.astro b/src/components/DockerDeployInfo.astro new file mode 100644 index 0000000..6035331 --- /dev/null +++ b/src/components/DockerDeployInfo.astro @@ -0,0 +1,24 @@ +--- +import { Value } from '@/client/Inspect' +import Docker from 'dockerode' + +const { name } = Astro.props + +const docker = new Docker({ socketPath: '/var/run/docker.sock' }) +const containers = await docker.listContainers() +const container = containers.find(c => c.Names.includes(`/${name}`)) +--- + +

Containers

+ +{ + container ? ( +
+
+ +
+
+ ) : ( +

No container found

+ ) +} diff --git a/src/events.ts b/src/events.ts new file mode 100644 index 0000000..74f4d59 --- /dev/null +++ b/src/events.ts @@ -0,0 +1,52 @@ +import { EventEmitter } from 'events' +import type { Job } from './jobs' + +const emitter = new EventEmitter() + +export const OnJobAdded = { + emit(job: Job) { + emitter.emit('job:add', job) + }, + addListener(cb: (job: Job) => void) { + emitter.on('job:add', cb) + }, + removeListener(cb: (job: Job) => void) { + emitter.off('job:add', cb) + }, +} + +export const OnJobCompleted = { + emit(job: Job) { + emitter.emit('job:completed', job) + }, + addListener(cb: (job: Job) => void) { + emitter.on('job:completed', cb) + }, + removeListener(cb: (job: Job) => void) { + emitter.off('job:completed', cb) + }, +} + +export const OnJobStarted = { + emit(job: Job) { + emitter.emit('job:started', job) + }, + addListener(cb: (job: Job) => void) { + emitter.on('job:started', cb) + }, + removeListener(cb: (job: Job) => void) { + emitter.off('job:started', cb) + }, +} + +export const OnJobLog = { + emit(uuid: string, line: string) { + emitter.emit(`job:log:${uuid}`, line) + }, + addListener(uuid: string, cb: (line: string) => void) { + emitter.on(`job:log:${uuid}`, cb) + }, + removeListener(uuid: string, cb: (line: string) => void) { + emitter.off(`job:log:${uuid}`, cb) + }, +} diff --git a/src/jobs.ts b/src/jobs.ts index 60cef2c..9fb0785 100644 --- a/src/jobs.ts +++ b/src/jobs.ts @@ -1,5 +1,3 @@ -import { EventEmitter } from 'events' - import { debug } from './logger' import { createJsonDatabase } from './lib/file-db' @@ -11,7 +9,9 @@ import { createWriteStream } from 'fs' import { readFile, mkdir } from 'fs/promises' import { dirname } from 'path' -import type { random } from 'lodash' + +import _ from 'lodash' +import { OnJobAdded, OnJobCompleted, OnJobLog, OnJobStarted } from './events' type JobStatus = 'queued' | 'running' | 'completed' @@ -38,8 +38,6 @@ type QueuedJob = { completed?: () => void } -const emitter = new EventEmitter() - const jobsDB = createJsonDatabase(`${import.meta.env.DATA_PATH}/jobs.json`, []) export function getJobLogFile(job: Job): string { @@ -69,7 +67,7 @@ async function processQueue() { return job }) - emitter.emit('job:started', job) + OnJobStarted.emit(job) const startedAt = new Date().toISOString() let error: string | undefined @@ -83,7 +81,7 @@ async function processQueue() { logsFile.write(line + '\n') // to not block log file creation - setImmediate(() => emitter.emit(`job:log:${uuid}`, line)) + setImmediate(() => OnJobLog.emit(uuid, line)) }) debug(`[Jobs] Starting job "${job.name}"`) @@ -117,7 +115,7 @@ async function processQueue() { }) completed?.() - emitter.emit('job:completed', completedJob) + OnJobCompleted.emit(completedJob) } } working = false @@ -147,7 +145,7 @@ export async function enqueueJob( }) queue.push({ uuid, deployFn, completed }) - emitter.emit('job:add', job) + OnJobAdded.emit(job) // starts concurrently a function to process jobs processQueue() @@ -174,40 +172,11 @@ export async function getJobLogs(uuid: string): Promise { } } -export const OnJobAdded = { - addListener(cb: (job: Job) => void) { - emitter.on('job:add', cb) - }, - removeListener(cb: (job: Job) => void) { - emitter.off('job:add', cb) - }, -} - -export const OnJobCompleted = { - addListener(cb: (job: Job) => void) { - emitter.on('job:completed', cb) - }, - removeListener(cb: (job: Job) => void) { - emitter.off('job:completed', cb) - }, -} - -export const OnJobStarted = { - addListener(cb: (job: Job) => void) { - emitter.on('job:started', cb) - }, - removeListener(cb: (job: Job) => void) { - emitter.off('job:started', cb) - }, -} - -export const OnJobLog = { - addListener(uuid: string, cb: (line: string) => void) { - emitter.on(`job:log:${uuid}`, cb) - }, - removeListener(uuid: string, cb: (line: string) => void) { - emitter.off(`job:log:${uuid}`, cb) - }, +export async function getLatestJobByName(name: string): Promise { + const jobs = await jobsDB.load() + const jobsByName = jobs.filter(job => job.name === name) + const latestJobs = _.sortBy(jobsByName, job => new Date(job.submittedAt)) + return latestJobs.length > 0 ? latestJobs.at(-1) : undefined } // ===================[ Old Version ]=================== diff --git a/src/pages/deploys/[name]/index.astro b/src/pages/deploys/[name]/index.astro index fb981f9..b27e02c 100644 --- a/src/pages/deploys/[name]/index.astro +++ b/src/pages/deploys/[name]/index.astro @@ -1,13 +1,13 @@ --- import { Deploy } from '@client/Deploy' import { Value } from '@client/Inspect' +import { JobLogs } from '@client/JobLogs' import { TriggerButton } from '@client/TriggerButton' import { loadConfig } from '@/config' import Layout from '@layouts/Layout.astro' import { createUrlQuery } from '@/lib/utils' - -import Docker from 'dockerode' +import DockerDeployInfo from '@/components/DockerDeployInfo.astro' const { name } = Astro.params @@ -22,10 +22,6 @@ if (!deploy) { }) ) } - -const docker = new Docker({ socketPath: '/var/run/docker.sock' }) -const containers = await docker.listContainers() -const container = containers.find(c => c.Names.includes(`/${name}`)) --- @@ -34,10 +30,10 @@ const container = containers.find(c => c.Names.includes(`/${name}`))
{name}
- + replay Force re-deploy - + stop Stop
@@ -46,21 +42,15 @@ const container = containers.find(c => c.Names.includes(`/${name}`)) -

Containers

- - { - container ? ( -
-
- -
-
- ) : ( -

No container found

- ) - } + {deploy.type === 'docker-image' && }

Jobs

+

Latest Job

+ + + +

Archive

+ ...
diff --git a/src/pages/deploys/[name]/latest-job-logs.ts b/src/pages/deploys/[name]/latest-job-logs.ts new file mode 100644 index 0000000..a00545a --- /dev/null +++ b/src/pages/deploys/[name]/latest-job-logs.ts @@ -0,0 +1,39 @@ +import { OnJobLog } from '@/events' +import { getJobLogs, getLatestJobByName } from '@/jobs' +import { JsonResponse, JsonStreamResponse, createUrlQuery } from '@/lib/utils' +import { debug } from '@/logger' +import type { APIRoute } from 'astro' + +export const GET: APIRoute = async ({ params: { name }, url, redirect }) => { + const latestJob = await getLatestJobByName(name!) + if (!latestJob) { + return new Response('No jobs yet for this deploy', { status: 404 }) + } + + debug(latestJob) + + const rawLogs = await getJobLogs(latestJob.uuid) + + const format = url.searchParams.get('format') + switch (format) { + case 'raw': + return new Response(rawLogs) + case 'json': + return new JsonResponse(rawLogs.trim().split('\n')) + case 'sse': + return new JsonStreamResponse(sendData => { + const jobLog = (content: string) => sendData({ type: 'log', content }) + + debug('[SSE] Registering job log client') + OnJobLog.addListener(latestJob.uuid, jobLog) + + return () => { + // cancel + OnJobLog.removeListener(latestJob.uuid, jobLog) + debug('[SSE] Un-registered job log client') + } + }) + default: + return redirect(createUrlQuery('/error', { message: `Invalid format "${format}"` })) + } +} diff --git a/src/pages/deploys/[name]/latest-job.ts b/src/pages/deploys/[name]/latest-job.ts new file mode 100644 index 0000000..7e3cef7 --- /dev/null +++ b/src/pages/deploys/[name]/latest-job.ts @@ -0,0 +1,32 @@ +import { getLatestJobByName } from '@/jobs' +import { JsonResponse, JsonStreamResponse, createUrlQuery } from '@/lib/utils' +import type { APIRoute } from 'astro' + +export const GET: APIRoute = async ({ params: { name }, url, redirect }) => { + const latestJob = await getLatestJobByName(name!) + + const format = url.searchParams.get('format') + switch (format) { + case 'json': + return new JsonResponse(latestJob) + // case 'sse': + // return new JsonStreamResponse(sendData => { + // const jobLog = (content: string) => sendData({ type: 'log', content }) + + // debug('[SSE] Registering job log client') + // OnJobLog.addListener(uuid!, jobLog) + + // return () => { + // // cancel + // OnJobLog.removeListener(uuid!, jobLog) + // debug('[SSE] Un-registered job log client') + // } + // }) + default: + return redirect( + createUrlQuery('/error', { + message: `Invalid format "${format}"`, + }) + ) + } +} diff --git a/src/pages/deploys/[name]/stop.ts b/src/pages/deploys/[name]/stop.ts new file mode 100644 index 0000000..3c9e7c9 --- /dev/null +++ b/src/pages/deploys/[name]/stop.ts @@ -0,0 +1,8 @@ +import { loadConfig } from '@/config' +import { createDeployJob } from '@/deploys' +import { enqueueJob } from '@/jobs' +import type { APIRoute } from 'astro' + +export const POST: APIRoute = async ({ params: { name } }) => { + return new Response('ok') +} diff --git a/src/pages/jobs/[uuid]/index.astro b/src/pages/jobs/[uuid]/index.astro index 5e43a08..4aecba3 100644 --- a/src/pages/jobs/[uuid]/index.astro +++ b/src/pages/jobs/[uuid]/index.astro @@ -1,9 +1,8 @@ --- -import { getJob, getJobLogs, OnJobLog } from '@/jobs' +import { getJob, getJobLogs } from '@/jobs' import Layout from '@layouts/Layout.astro' import { JobLogs } from '@client/JobLogs' - -import { JsonResponse, createUrlQuery } from '@/lib/utils' +import { createUrlQuery } from '@/lib/utils' const { uuid } = Astro.params @@ -34,7 +33,7 @@ if (job.status === 'completed') { // prettier-ignore
{logsContent}
) : ( - + ) } diff --git a/src/pages/jobs/[uuid]/logs.ts b/src/pages/jobs/[uuid]/logs.ts index 3ea2f3b..3ccb26b 100644 --- a/src/pages/jobs/[uuid]/logs.ts +++ b/src/pages/jobs/[uuid]/logs.ts @@ -1,4 +1,5 @@ -import { OnJobLog, getJob, getJobLogs } from '@/jobs' +import { OnJobLog } from '@/events' +import { getJob, getJobLogs } from '@/jobs' import { JsonResponse, JsonStreamResponse, createUrlQuery } from '@/lib/utils' import { debug } from '@/logger' import type { APIRoute } from 'astro' diff --git a/src/styles/main.scss b/src/styles/main.scss index 3c598d3..5e3a01b 100644 --- a/src/styles/main.scss +++ b/src/styles/main.scss @@ -466,6 +466,8 @@ body { place-content: center; aspect-ratio: 1; + padding: 0.5rem; + .material-symbols-outlined { font-size: 20px; }