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;
}