From 07ab1fb40db946b67d0c5b3a6b7a2d11c5361b0a Mon Sep 17 00:00:00 2001 From: Cory Sanin Date: Sun, 12 Jan 2025 01:14:50 -0500 Subject: [PATCH] interface with db --- .gitignore | 4 +- Dockerfile | 1 + docker-compose.yml | 15 +++- package-lock.json | 1 + package.json | 1 + src/DB.ts | 196 ++++++++++++++++++++++++++++++++++++++++++ src/Web.ts | 69 ++++++++++++--- src/index.ts | 22 ++++- styles/01-styles.scss | 27 +++++- views/build-new.ejs | 25 ++++++ views/build.ejs | 23 ++--- views/index.ejs | 9 ++ views/navigation.ejs | 2 +- 13 files changed, 366 insertions(+), 29 deletions(-) create mode 100644 src/DB.ts create mode 100644 views/build-new.ejs diff --git a/.gitignore b/.gitignore index bfba57a..d8b3854 100644 --- a/.gitignore +++ b/.gitignore @@ -108,4 +108,6 @@ dist assets/css/ assets/js/ assets/webp/ -config/config.json \ No newline at end of file +config/config.json +config/postgres/ +.env diff --git a/Dockerfile b/Dockerfile index 50d7400..fc6e753 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,7 @@ COPY ./package*json ./ RUN npm ci COPY . . RUN node --experimental-strip-types build.ts && \ + npm exec tsc && \ npm ci --only=production --omit=dev FROM base as deploy diff --git a/docker-compose.yml b/docker-compose.yml index 77ea440..344657e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,4 +11,17 @@ services: - /var/run/docker.sock:/var/run/docker.sock restart: "no" ports: - - 8080:8080 \ No newline at end of file + - 8080:8080 + depends_on: + - postgres + + postgres: + container_name: archy-postgres + image: postgres:17-alpine + environment: + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_USER: archery + POSTGRES_DB: archery + volumes: + - ./config/postgres:/var/lib/postgresql/data + restart: "no" diff --git a/package-lock.json b/package-lock.json index 356a15e..e4fc28c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "license": "MIT", "dependencies": { + "body-parser": "^1.20.3", "ejs": "3.1.10", "express": "^4.21.2", "pg": "^8.13.1", diff --git a/package.json b/package.json index c6992e8..2b82fbf 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "type": "module", "main": "index.ts", "dependencies": { + "body-parser": "^1.20.3", "ejs": "3.1.10", "express": "^4.21.2", "pg": "^8.13.1", diff --git a/src/DB.ts b/src/DB.ts new file mode 100644 index 0000000..2eabeec --- /dev/null +++ b/src/DB.ts @@ -0,0 +1,196 @@ +import { Sequelize, DataTypes, Op, } from 'sequelize'; +import type { ModelStatic, Filterable } from 'sequelize'; + +interface DBConfig { + db?: string; + user?: string; + password?: string; + host?: string; + port?: number; +} + +const MONTH = 1000 * 60 * 60 * 24 * 24; +const FRESH = { + [Op.or]: [ + { startTime: { [Op.gt]: new Date(Date.now() - MONTH) } }, + { startTime: { [Op.is]: null } } + ] +} +const SELECT = ['id', 'repo', 'commit', 'distro', 'startTime', 'endTime', 'status']; + +class DB { + private build: ModelStatic; + private sequelize: Sequelize; + + constructor(config: DBConfig = {}) { + this.sequelize = new Sequelize(config.db || 'archery', config.user || 'archery', config.password || '', { + host: config.host || 'localhost', + port: config.port || 5432, + dialect: 'postgres' + }); + this.build = this.sequelize.define('builds', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + repo: { + type: DataTypes.STRING, + allowNull: false + }, + commit: { + type: DataTypes.STRING, + allowNull: true + }, + patch: { + type: DataTypes.TEXT, + allowNull: true + }, + distro: { + type: DataTypes.STRING, + allowNull: false + }, + startTime: { + type: DataTypes.DATE, + allowNull: true + }, + endTime: { + type: DataTypes.DATE, + allowNull: true + }, + status: { + type: DataTypes.ENUM('queued', 'running', 'cancelled', 'success', 'error'), + allowNull: false, + defaultValue: 'queued' + }, + pid: { + type: DataTypes.INTEGER, + allowNull: true + }, + log: { + type: DataTypes.TEXT, + allowNull: true + } + }); + + this.build.sync(); + } + + public async createBuild(repo: string, commit: string, patch: string, distro: string): Promise { + const buildRec = await this.build.create({ + repo, + commit: commit || null, + patch: patch || null, + distro + }); + return buildRec.id; + } + + public async startBuild(id: number, pid: number): Promise { + await this.build.update({ + startTime: new Date(), + status: 'running', + pid, + log: '' + }, { + where: { + id + } + }); + } + + public async finishBuild(id: number, status: string): Promise { + await this.build.update({ + endTime: new Date(), + status + }, { + where: { + id + } + }); + } + + public async appendLog(id: number, log: string): Promise { + await this.build.update({ + log: Sequelize.literal(`log || '${log}'`) + }, { + where: { + id + } + }); + } + + public async getBuild(id: number): Promise { + return await this.build.findByPk(id); + } + + public async getBuilds(): Promise { + return await this.build.findAll({ + attributes: SELECT, + order: [['id', 'DESC']], + where: FRESH, + }); + } + + public async getBuildsByStatus(status: string): Promise { + return await this.build.findAll({ + attributes: SELECT, + order: [['id', 'DESC']], + where: { + ...FRESH, + status + } + }); + } + + public async getBuildsByDistro(distro: string): Promise { + return await this.build.findAll({ + attributes: SELECT, + order: [['id', 'DESC']], + where: { + ...FRESH, + distro + } + }); + } + + public async getBuildsBy(filterable: Filterable): Promise { + return await this.build.findAll({ + attributes: SELECT, + order: [['id', 'DESC']], + where: { + ...FRESH, + ...filterable + } + }); + } + + public async searchBuilds(query: string): Promise { + return await this.build.findAll({ + attributes: SELECT, + order: [['id', 'DESC']], + where: { + [Op.or]: [ + { repo: { [Op.iLike]: `%${query}%` } } + ] + }, + limit: 100 + }); + } + + public async cleanup(): Promise { + await this.build.destroy({ + where: { + startTime: { [Op.lt]: new Date(Date.now() - MONTH * 6) } + } + }); + } + + public async close(): Promise { + await this.sequelize.close(); + } +} + +export default DB; +export { DB }; +export type { DBConfig }; diff --git a/src/Web.ts b/src/Web.ts index 694737f..01707f2 100644 --- a/src/Web.ts +++ b/src/Web.ts @@ -2,6 +2,8 @@ import * as http from "http"; import crypto from 'crypto'; import type { Express } from "express"; import express, { application } from 'express'; +import bodyParser from "body-parser"; +import type { DB } from "./DB.ts"; interface WebConfig { port?: number; @@ -16,6 +18,7 @@ function notStupidParseInt(v: string | undefined): number { class Web { private _webserver: http.Server | null = null; + private db: DB; constructor(options: WebConfig = {}) { const app: Express = express(); @@ -25,6 +28,8 @@ class Web { app.set('view engine', 'ejs'); app.set('view options', { outputFunctionName: 'echo' }); app.use('/assets', express.static('assets', { maxAge: '30 days' })); + app.use(bodyParser.json()); + app.use(bodyParser.urlencoded({ extended: true })); app.use((_req, res, next) => { crypto.randomBytes(32, (err, randomBytes) => { if (err) { @@ -37,18 +42,26 @@ class Web { }); }); - app.get('/', (_, res) => { - res.render('index', { - page: { - title: 'Archery', - titlesuffix: 'Dashboard', - description: 'PKGBUILD central' - } - }); + app.get('/', async (req, res) => { + try { + const builds = await this.db.getBuildsBy(req.query); + res.render('index', { + page: { + title: 'Archery', + titlesuffix: 'Dashboard', + description: 'PKGBUILD central' + }, + builds + }); + } + catch (err) { + console.error(err); + res.sendStatus(400); + } }); app.get('/build/?', (_, res) => { - res.render('build', { + res.render('build-new', { page: { title: 'Archery', titlesuffix: 'New Build', @@ -57,6 +70,36 @@ class Web { }); }); + app.post('/build/?', async (req, res) => { + const build = await this.db.createBuild(req.body.repo, req.body.commit || null, req.body.patch || null, req.body.distro); + res.redirect(`/build/${build}`); + }); + + app.get('/build/:num/?', async (req, res) => { + const build = await this.db.getBuild(parseInt(req.params.num)); + if (!build) { + res.sendStatus(404); + return; + } + res.render('build', { + page: { + title: 'Archery', + titlesuffix: `Build #${req.params.num}`, + description: `Building ${build.repo} on ${build.distro}` + }, + build + }); + }); + + app.get('/build/:num/logs/?', async (req, res) => { + const build = await this.db.getBuild(parseInt(req.params.num)); + if (!build) { + res.sendStatus(404); + return; + } + res.set('Content-Type', 'text/plain').send(build.log); + }); + app.get('/healthcheck', (_, res) => { res.send('Healthy'); }); @@ -69,6 +112,12 @@ class Web { this._webserver.close(); } } + + setDB = (db: DB) => { + this.db = db; + } } -export default Web; \ No newline at end of file +export default Web; +export { Web }; +export type { WebConfig }; diff --git a/src/index.ts b/src/index.ts index 710901d..4d5d59e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,23 @@ import fs from 'fs'; import path from 'path'; -import Web from './Web.ts'; +import { Web } from './Web.ts'; +import { DB } from './DB.ts'; +import type { WebConfig } from './Web.ts'; +import type { DBConfig } from './DB.ts'; -const config = JSON.parse(await fs.promises.readFile(process.env.config || path.join('config', 'config.json'), 'utf-8')); +interface compositeConfig { + web?: WebConfig, + db?: DBConfig +} -const web = new Web(config); +const config: compositeConfig = JSON.parse(await fs.promises.readFile(process.env.config || path.join('config', 'config.json'), 'utf-8')); -process.on('SIGTERM', web.close); +const web = new Web(config.web); +await new Promise((resolve) => setTimeout(resolve, 1500)); +const db = new DB(config.db); +web.setDB(db); + +process.on('SIGTERM', () => { + web.close(); + db.close(); +}); diff --git a/styles/01-styles.scss b/styles/01-styles.scss index 666efd9..b883859 100644 --- a/styles/01-styles.scss +++ b/styles/01-styles.scss @@ -35,6 +35,21 @@ h1 { padding: 0; } +h2 { + font-size: 1.5em; + margin: 0; + padding: 0; +} + +a { + color: var(--primary); + text-decoration: underline; + + &:hover { + color: var(--primary-light); + } +} + table { background-color: #2a2a2a; border-collapse: collapse; @@ -69,6 +84,16 @@ th a { form { width: 40em; max-width: 100%; + + textarea { + width: 100%; + max-width: 100%; + min-width: 100%; + height: 10em; + max-height: 30em; + min-height: 5em; + resize: vertical; + } } button, @@ -146,7 +171,7 @@ input[type="submit"] { .grid-2col { display: grid; grid-template-columns: auto 1fr; - gap: 1em; + gap: .65em; .span-2 { grid-column: span 2; diff --git a/views/build-new.ejs b/views/build-new.ejs new file mode 100644 index 0000000..44d57b1 --- /dev/null +++ b/views/build-new.ejs @@ -0,0 +1,25 @@ + + + + + <%- include("head", locals) %> + + + + <%- include("navigation", locals) %> +
+

Start a build

+
+ + + + + +
+
+
+ + diff --git a/views/build.ejs b/views/build.ejs index 4ba0662..3e05c47 100644 --- a/views/build.ejs +++ b/views/build.ejs @@ -8,17 +8,18 @@ <%- include("navigation", locals) %>
-

Start a build

-
- - - - -
-
+

Build #<%= build.id %>

+

<%= build.status %>

+
+ <%= build.repo %> + <% if (build.commit) { %><%= build.commit %><% } else { %>latest<% } %> + <% if (build.patch) { %>patch file<% } else { %>none<% } %> + <%= build.distro %> + <%= build.startTime %> +
+

Full logs

+
+
diff --git a/views/index.ejs b/views/index.ejs index 59d28a1..4b9395e 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -17,6 +17,15 @@ Build Duration Build Status + <% builds.forEach(build => { %> + + <%= build.repo %> + <%= build.distro %> + <%= build.startTime %> + TODO + <%= build.status %> + + <% }) %> diff --git a/views/navigation.ejs b/views/navigation.ejs index 057de8c..cb7f340 100644 --- a/views/navigation.ejs +++ b/views/navigation.ejs @@ -11,7 +11,7 @@