feat: add ts frontend
This commit is contained in:
23
Dockerfile
23
Dockerfile
@@ -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
|
||||
|
||||
@@ -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
65
web/index.html
Normal 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
1076
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
web/package.json
Normal file
17
web/package.json
Normal 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
174
web/src/app.ts
Normal 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
17
web/tsconfig.json
Normal 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/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user