feat: add ts frontend

This commit is contained in:
2025-11-06 13:42:33 +01:00
parent 05b975979b
commit 2fa3ecc5d6
7 changed files with 1407 additions and 24 deletions

View File

@@ -1,5 +1,6 @@
# Step 1: Build the Go binary
FROM golang:alpine AS builder
###############
# Build backend
FROM golang:alpine AS go-builder
WORKDIR /app
@@ -14,11 +15,27 @@ COPY internal/ /app/internal/
# Build the binary
RUN go build -o ddbb ./cmd/ddbb/main.go
################
# Build frontend
FROM node:24-alpine as js-builder
WORKDIR /app
COPY ./web/package*.json ./web/tsconfig.json ./
RUN npm install
COPY web/src/ ./src/
RUN npx tsc
######################
# Production container
FROM alpine:latest
WORKDIR /app
COPY --from=builder /app/ddbb .
COPY --from=go-builder /app/ddbb .
COPY --from=js-builder /app/dist ./dist/static/
COPY ./web/index.html /app/dist/index.html
RUN apk update
RUN apk add bash mariadb-client postgresql17-client mongodb-tools

View File

@@ -13,6 +13,10 @@ import (
"github.com/gin-gonic/gin"
)
type RequestBody struct {
Id string `json:"id"`
}
func selectJob(id string) types.Job {
var chosenJob *types.Job
jobs, err := docker.GetConfigsFromContainers()
@@ -51,61 +55,70 @@ func listJobs(c *gin.Context) {
// Dump any database based on Id
func triggerDump(c *gin.Context) {
jobId := c.PostForm("id")
if jobId == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing backup job id"})
// Read Id from JSON submitted data
var req RequestBody
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
if isValidJobId(jobId) {
result, err := dump.NewDumpJob(selectJob(jobId))
if req.Id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing backup job id"})
}
if isValidJobId(req.Id) {
result, err := dump.NewDumpJob(selectJob(req.Id))
if err != nil {
log.Printf("%v", err)
}
c.JSON(200, result)
return
} else {
c.JSON(http.StatusNotFound, gin.H{"error": "Invalid job id"})
return
}
}
// Restore any database based on Id
func triggerRestore(c *gin.Context) {
jobId := c.PostForm("id")
if jobId == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing backup job id"})
// Read Id from JSON submitted data
var req RequestBody
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
if isValidJobId(jobId) {
result, err := restore.NewRestoreJob(selectJob(jobId))
if req.Id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing backup job id"})
}
if isValidJobId(req.Id) {
result, err := restore.NewRestoreJob(selectJob(req.Id))
if err != nil {
log.Printf("%v", err)
}
c.JSON(200, result)
return
} else {
c.JSON(http.StatusNotFound, gin.H{"error": "Invalid job id"})
return
}
}
// Check any database based on Id
func triggerIntegrityCheck(c *gin.Context) {
jobId := c.PostForm("id")
if jobId == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing backup job id"})
// Read Id from JSON submitted data
var req RequestBody
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
if isValidJobId(jobId) {
result, err := integrity.NewIntegrityCheckJob(selectJob(jobId))
if req.Id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing backup job id"})
}
if isValidJobId(req.Id) {
result, err := integrity.NewIntegrityCheckJob(selectJob(req.Id))
if err != nil {
log.Printf("%v", err)
}
c.JSON(200, result)
return
} else {
c.JSON(http.StatusNotFound, gin.H{"error": "Invalid job id"})
return
}
}
@@ -120,6 +133,10 @@ func SetupRouter() *gin.Engine {
}
// GIN endpoints
r.Static("/static", "./dist/static")
r.GET("/", func(c *gin.Context) {
c.File("./dist/index.html")
})
r.GET("/list", listJobs)
r.POST("/dump", triggerDump)
r.POST("/integrity", triggerIntegrityCheck)

65
web/index.html Normal file
View File

@@ -0,0 +1,65 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Jobs Dashboard</title>
<style>
body {
font-family: 'Fira Mono', monospace;
background-color: #1e1e2f;
color: #c5c8c6;
margin: 20px;
}
table {
border-collapse: collapse;
width: 100%;
margin-top: 15px;
}
th, td {
border: 1px solid #283046;
padding: 8px 12px;
cursor: pointer;
}
th {
background: #2e2e42;
position: sticky;
top: 0;
user-select: none;
}
th.sort-asc::after {
content: ' ▲';
font-size: 0.8em;
}
th.sort-desc::after {
content: ' ▼';
font-size: 0.8em;
}
input.filter {
box-sizing: border-box;
width: 100%;
background: #2e2e42;
border: none;
color: #c5c8c6;
padding: 4px 8px;
margin-bottom: 4px;
}
button {
font-family: 'Fira Mono', monospace;
background-color: #444c67;
border: none;
color: #c5c8c6;
padding: 6px 10px;
margin-right: 10px;
cursor: pointer;
}
button:hover {
background-color: #66729e;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/static/app.js"></script>
</body>
</html>

1076
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

17
web/package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "ddbb-frontend",
"version": "1.0.0",
"description": "Frontend for ddbb app",
"scripts": {
"build": "tsc",
"start": "serve ./dist"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"devDependencies": {
"serve": "^14.2.5",
"typescript": "^5.9.3"
}
}

174
web/src/app.ts Normal file
View File

@@ -0,0 +1,174 @@
interface Job {
id: string;
kind: string;
host: string;
port: number;
database: string;
user: string;
dumpFile: string;
integrityStatus: string;
}
class JobsTable {
private jobs: Job[] = [];
private filteredJobs: Job[] = [];
private sortCol: keyof Job | null = null;
private sortAsc: boolean = true;
private filters: Partial<Record<keyof Job, string>> = {};
constructor(private container: HTMLElement) {}
async loadData() {
const response = await fetch('/list');
this.jobs = await response.json();
this.filteredJobs = [...this.jobs];
this.render();
}
private applyFilters() {
this.filteredJobs = this.jobs.filter(job => {
return Object.entries(this.filters).every(([key, filterStr]) =>
job[key as keyof Job]
.toString()
.toLowerCase()
.includes(filterStr.toLowerCase())
);
});
}
private applySort() {
if (!this.sortCol) return;
this.filteredJobs.sort((a, b) => {
const va = a[this.sortCol!];
const vb = b[this.sortCol!];
if (va < vb) return this.sortAsc ? -1 : 1;
if (va > vb) return this.sortAsc ? 1 : -1;
return 0;
});
}
private renderFilters(headers: (keyof Job)[]) {
const filterRow = document.createElement('tr');
headers.forEach(col => {
const th = document.createElement('th');
const input = document.createElement('input');
input.type = 'text';
input.className = 'filter';
input.placeholder = 'Filter...';
input.oninput = (e) => {
const target = e.target as HTMLInputElement;
const val = target.value;
if (val) this.filters[col] = val;
else delete this.filters[col];
this.applyFilters();
this.applySort();
this.renderTableBody();
};
th.appendChild(input);
filterRow.appendChild(th);
});
return filterRow;
}
private renderTableBody() {
const tbody = this.container.querySelector('tbody')!;
tbody.innerHTML = '';
this.filteredJobs.forEach(job => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${job.id}</td>
<td>${job.kind}</td>
<td>${job.host}</td>
<td>${job.port}</td>
<td>${job.database}</td>
<td>${job.user}</td>
<td>${job.dumpFile}</td>
<td>${job.integrityStatus}</td>
<td>
<button data-action="dump" data-id="${job.id}">Dump</button>
<button data-action="restore" data-id="${job.id}">Restore</button>
</td>
`;
// Ping buttons with click handlers
tr.querySelector('button[data-action="dump"]')!
.addEventListener('click', () => this.triggerAction(job.id, 'dump'));
tr.querySelector('button[data-action="restore"]')!
.addEventListener('click', () => this.triggerAction(job.id, 'restore'));
tbody.appendChild(tr);
});
}
private triggerAction(jobId: string, action: 'dump' | 'restore') {
fetch(`/${action}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({id: jobId})
})
.then(r => r.json())
.then(res => {
alert(`${action.toUpperCase()} Result: ${res.result ?? 'Unknown'}`);
this.loadData();
})
.catch(() => alert('Error running ' + action));
}
private render() {
const headers: (keyof Job)[] = [
'id', 'kind', 'host', 'port', 'database', 'user', 'dumpFile', 'integrityStatus'
];
this.container.innerHTML = '';
const table = document.createElement('table');
// Filter inputs row
table.appendChild(this.renderFilters(headers));
// Header row
const tr = document.createElement('tr');
headers.forEach(col => {
const th = document.createElement('th');
th.textContent = col.charAt(0).toUpperCase() + col.slice(1);
th.style.userSelect = 'none';
if (this.sortCol === col) {
th.classList.add(this.sortAsc ? 'sort-asc' : 'sort-desc');
}
th.addEventListener('click', () => {
if (this.sortCol === col) {
this.sortAsc = !this.sortAsc;
} else {
this.sortCol = col;
this.sortAsc = true;
}
this.applySort();
this.renderTableBody();
this.render(); // To update headers with arrows
});
tr.appendChild(th);
});
// Extra empty header for action buttons
tr.appendChild(document.createElement('th'));
table.appendChild(tr);
// Body
const tbody = document.createElement('tbody');
table.appendChild(tbody);
this.container.appendChild(table);
this.renderTableBody();
}
}
document.addEventListener('DOMContentLoaded', () => {
const app = document.getElementById('app');
if (!app) return;
const jobsTable = new JobsTable(app);
jobsTable.loadData();
});

17
web/tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "es6",
"lib": ["esnext", "dom"],
"strict": true,
"noImplicitAny": false,
"module": "esnext",
"moduleResolution": "node",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}