diff --git a/src/client/TriggerButton.jsx b/src/client/TriggerButton.jsx new file mode 100644 index 0000000..db2c694 --- /dev/null +++ b/src/client/TriggerButton.jsx @@ -0,0 +1,33 @@ +import { useState } from 'preact/hooks' + +// target is a url to send a POST request to +export const TriggerButton = ({ target, children }) => { + const [loading, setLoading] = useState(false) + + const handleClick = async () => { + setLoading(true) + + const response = await fetch(target, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }) + + if (response.ok) { + console.log('Success') + } else { + console.log('Failure') + } + + setLoading(false) + } + + return loading ? ( + + ) : ( + + ) +} diff --git a/src/config.ts b/src/config.ts index 857da68..994c8ce 100644 --- a/src/config.ts +++ b/src/config.ts @@ -23,7 +23,7 @@ export const GitDeploy = z.object({ }) const DockerRunOptions = z.object({ - name: z.string(), + name: z.string().optional(), volumes: z.tuple([z.string(), z.string()]).array(), ports: z.tuple([z.string(), z.string()]).array(), env: z.record(z.string()), diff --git a/src/deploys.ts b/src/deploys.ts index 95d7f92..789171a 100644 --- a/src/deploys.ts +++ b/src/deploys.ts @@ -1,4 +1,4 @@ -import type { Deploy, GitDeploy, GitRef, ShellDeploy } from '@/config' +import type { Deploy, DockerDeploy, GitDeploy, GitRef, ShellDeploy } from '@/config' import type { DeployFunction, Job, JobBase } from '@/jobs' import path from 'path' @@ -9,6 +9,11 @@ import { type Runner } from '@/runners' import { debug } from '@/logger' +import Docker from 'dockerode' +import _ from 'lodash' + +const docker = new Docker({ socketPath: '/var/run/docker.sock' }) + const toSafePath = (target: string) => { return '.' + path.posix.normalize('/' + target) } @@ -72,6 +77,43 @@ export async function shellRunner(runner: Runner, deploy: ShellDeploy) { ) } +export async function dockerImageRunner(runner: Runner, deploy: DockerDeploy) { + const containerName = deploy.options.name ?? deploy.name + + const containers = await docker.listContainers({ all: true }) + const prevContainerId = containers.find(c => c.Names.includes(`/${containerName}`))?.Id + + if (prevContainerId) { + debug('[Deploy] [DockerImage] Removing previous container') + + const oldContainer = docker.getContainer(prevContainerId) + await oldContainer.remove({ force: true }) + } + + debug('[Deploy] [DockerImage] Creating new container') + + // create container and start + const container = await docker.createContainer({ + Image: deploy.options.image, + name: containerName, + ExposedPorts: _.fromPairs(deploy.options.ports.map(([_, container]) => [container, {}])), + HostConfig: { + Binds: deploy.options.volumes.map(([host, container]) => `${host}:${container}`), + PortBindings: _.fromPairs( + deploy.options.ports.map(([host, container]) => [container, [{ HostPort: host }]]) + ), + }, + Env: _.map(deploy.options.env, (value, key) => `${key}=${value}`), + }) + + const stream = await container.attach({ stream: true, stdout: true, stderr: true }) + + runner.dumpStream(stream) + + debug('[Deploy] [DockerImage] Starting the new container') + await container.start() +} + export function createDeployJob(deploy: Deploy, submitter: any): [JobBase, DeployFunction] { return [ { @@ -80,11 +122,15 @@ export function createDeployJob(deploy: Deploy, submitter: any): [JobBase, Deplo }, async runner => { debug('[Runner]', `Deploying "${deploy.name}"`) + + // TODO: Remove this sleep, it's just for testing await sleep(1000) // TODO: Add other deploy types if (deploy.type === 'shell') { await shellRunner(runner, deploy) + } else if (deploy.type === 'docker-image') { + await dockerImageRunner(runner, deploy) } else { throw new Error(`deploy type "${deploy.type}" not yet implemented`) } diff --git a/src/jobs.ts b/src/jobs.ts index f2810b6..60cef2c 100644 --- a/src/jobs.ts +++ b/src/jobs.ts @@ -35,6 +35,7 @@ export type DeployFunction = (runner: Runner) => Promise type QueuedJob = { uuid: string deployFn: DeployFunction + completed?: () => void } const emitter = new EventEmitter() @@ -59,7 +60,7 @@ async function processQueue() { working = true { while (queue.length > 0) { - const { uuid, deployFn } = queue.shift()! + const { uuid, deployFn, completed } = queue.shift()! const job = await jobsDB.update(async jobs => { const job = jobs.find(job => job.uuid === uuid)! @@ -91,6 +92,12 @@ async function processQueue() { } catch (e) { error = e!.toString() } + + if (error) { + debug(`[Jobs] Error in job "${job.name}"`) + debug(`[Jobs] ${error}`) + } + debug(`[Jobs] Finished job`) const completedAt = new Date().toISOString() @@ -109,6 +116,7 @@ async function processQueue() { return job }) + completed?.() emitter.emit('job:completed', completedJob) } } @@ -121,7 +129,11 @@ export type JobBase = { } // Use this function to add new jobs to the work queue -export async function enqueueJob(jobBase: JobBase, deployFn: DeployFunction) { +export async function enqueueJob( + jobBase: JobBase, + deployFn: DeployFunction, + completed?: () => void +) { const uuid = randomUUID() const job: Job = { ...jobBase, @@ -134,7 +146,7 @@ export async function enqueueJob(jobBase: JobBase, deployFn: DeployFunction) { jobs.push(job) }) - queue.push({ uuid, deployFn }) + queue.push({ uuid, deployFn, completed }) emitter.emit('job:add', job) // starts concurrently a function to process jobs diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index 3a720c7..38ad6cd 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -23,8 +23,8 @@ const { title } = Astro.props
-
+
diff --git a/src/pages/deploys/[name]/force-deploy.ts b/src/pages/deploys/[name]/force-deploy.ts new file mode 100644 index 0000000..3e3541e --- /dev/null +++ b/src/pages/deploys/[name]/force-deploy.ts @@ -0,0 +1,27 @@ +import { loadConfig } from '@/config' +import { createDeployJob } from '@/deploys' +import { enqueueJob } from '@/jobs' +import { sleep } from '@/lib/utils' +import type { APIRoute } from 'astro' + +export const POST: APIRoute = async ({ params: { name } }) => { + const { deploys } = await loadConfig() + const deploy = deploys.find(d => d.name === name) + + if (!deploy) { + return new Response('Deploy not found', { status: 404 }) + } + + const [jobBase, deployFn] = createDeployJob(deploy, { event: 'manual' }) + + let onComplete + const completed = new Promise(resolve => { + onComplete = resolve + }) + + await enqueueJob(jobBase, deployFn, onComplete) + + await completed + + return new Response('ok') +} diff --git a/src/pages/deploys/[name]/index.astro b/src/pages/deploys/[name]/index.astro index 6b0afd2..fb981f9 100644 --- a/src/pages/deploys/[name]/index.astro +++ b/src/pages/deploys/[name]/index.astro @@ -1,11 +1,14 @@ --- import { Deploy } from '@client/Deploy' import { Value } from '@client/Inspect' +import { TriggerButton } from '@client/TriggerButton' import { loadConfig } from '@/config' import Layout from '@layouts/Layout.astro' import { createUrlQuery } from '@/lib/utils' +import Docker from 'dockerode' + const { name } = Astro.params const { deploys } = await loadConfig() @@ -19,6 +22,10 @@ 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}`)) --- @@ -26,6 +33,34 @@ if (!deploy) {
Deploy
{name}
+
+ + replay Force re-deploy + + + stop Stop + +
+ +

Settings

+ +

Containers

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

No container found

+ ) + } + +

Jobs

+ + ...
diff --git a/src/pages/deploys/index.astro b/src/pages/deploys/index.astro index b99c0b4..5f63251 100644 --- a/src/pages/deploys/index.astro +++ b/src/pages/deploys/index.astro @@ -8,9 +8,11 @@ const { deploys } = await loadConfig()

Deploys

- - add New Deploy - +
{deploys.toReversed().map(deploy => )}
diff --git a/src/runners.ts b/src/runners.ts index 66fd28e..7175fab 100644 --- a/src/runners.ts +++ b/src/runners.ts @@ -9,6 +9,7 @@ type RunnerLogOptions = { export type Runner = { script: (source: string, options?: RunnerLogOptions) => Promise command: (source: string, options?: RunnerLogOptions) => Promise + dumpStream: (stream: any) => void } function onLine(stream: any, cb: (line: string) => void) { @@ -69,5 +70,10 @@ export function createSimpleRunner(logLn: (s: string) => void): Runner { await runShell(source, silent ? undefined : logLn) }, + dumpStream(stream: any) { + onLine(stream, (line: string) => { + debug('[Runner]', ' >', line) + }) + }, } } diff --git a/src/styles/main.scss b/src/styles/main.scss index 3b05f4f..15a756a 100644 --- a/src/styles/main.scss +++ b/src/styles/main.scss @@ -23,6 +23,19 @@ img { display: block; } +// +// Animations +// + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + // // Typography // @@ -98,6 +111,8 @@ code { button, .button { + border: none; + text-decoration: none; display: inline-block; @@ -106,7 +121,7 @@ button, font-size: 18px; font-weight: 500; - padding: 0.5rem 0.75rem; + padding: 0.5rem 1rem 0.5rem 0.65rem; border-radius: 2rem; cursor: pointer; transition: background-color 150ms ease-in-out; @@ -116,10 +131,16 @@ button, display: inline-grid; place-items: center; grid-auto-flow: column; - gap: 0.25rem; + gap: 0.35rem; + + .material-symbols-outlined { + font-size: 22px; + } - & > .material-symbols-outlined { - font-size: 18px; + &.loading { + .material-symbols-outlined { + animation: spin 1s linear infinite; + } } &:hover { @@ -258,6 +279,8 @@ form { align-items: center; border-left: 1px solid #ddd; + + word-break: break-word; } & > .key, @@ -386,6 +409,17 @@ form { // border-radius: 1rem; } +.card { + width: 100%; + + border: 1px solid #ddd; + + padding: 1rem; + border-radius: 1rem; + + display: grid; +} + // // Structure // @@ -506,12 +540,9 @@ body { & > .scroll-container { grid-area: main; display: grid; - justify-items: center; overflow-y: auto; - position: relative; - - & > .logo { + .phc-logo { position: absolute; top: 1rem; left: 1rem; @@ -520,23 +551,29 @@ body { font-weight: 500; line-height: 1; color: #666; + + -webkit-text-stroke: 4px #fff; + paint-order: stroke fill; } main { + position: relative; + overflow-x: auto; - padding: 5rem 1rem 2rem; + padding: 4rem 1rem 2rem; - display: flex; - flex-direction: column; - align-items: flex-start; + display: grid; + grid-template-columns: minmax(auto, 80ch); + justify-content: center; + align-content: start; gap: 2rem; justify-self: center; width: 100%; - max-width: 80ch; + // max-width: 80ch; // // Components @@ -551,8 +588,12 @@ body { font-weight: 400; color: #666; } + } - padding-bottom: 1rem; + .buttons { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; } .list {