feat: use vanilla js

This commit is contained in:
2025-11-06 14:58:48 +01:00
parent 2fa3ecc5d6
commit 4c8acd68f1
11 changed files with 350 additions and 1363 deletions

View File

@@ -16,26 +16,13 @@ COPY internal/ /app/internal/
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=go-builder /app/ddbb .
COPY --from=js-builder /app/dist ./dist/static/
COPY ./web/index.html /app/dist/index.html
COPY ./web/ /app/
RUN apk update
RUN apk add bash mariadb-client postgresql17-client mongodb-tools

View File

@@ -133,9 +133,9 @@ func SetupRouter() *gin.Engine {
}
// GIN endpoints
r.Static("/static", "./dist/static")
r.Static("/static", "./static")
r.GET("/", func(c *gin.Context) {
c.File("./dist/index.html")
c.File("./index.html")
})
r.GET("/list", listJobs)
r.POST("/dump", triggerDump)

View File

@@ -7,6 +7,7 @@ import (
"os"
"os/exec"
"strings"
"time"
)
var outFileRoot = "/backups/"
@@ -26,8 +27,21 @@ func NewDumpJob(job types.Job) (types.DumpJobResult, error) {
if err != nil {
log.Printf("%v", err)
return types.DumpJobResult{
Id: job.Id,
TimeStart: time.Now(),
TimeEnd: time.Now(),
Result: "Failure",
Error: err,
}, err
}
return types.DumpJobResult{}, nil
return types.DumpJobResult{
Id: job.Id,
TimeStart: time.Now(),
TimeEnd: time.Now(),
Result: "Success",
Error: nil,
}, nil
}
func dumpMariaDB(job types.Job) error {

View File

@@ -7,22 +7,41 @@ import (
"os"
"os/exec"
"strings"
"time"
)
var dumpFileRoot = "/backups/"
func NewRestoreJob(job types.Job) (types.RestoreJobResult, error) {
var err error
switch strings.ToLower(job.Kind) {
case "mariadb":
restoreMariaDB(job)
err = restoreMariaDB(job)
case "postgres":
restorePostgres(job)
err = restorePostgres(job)
case "mongodb":
restoreMongo(job)
err = restoreMongo(job)
default:
log.Printf("unsupported database kind: %s", job.Kind)
}
return types.RestoreJobResult{}, nil
if err != nil {
log.Printf("%v", err)
return types.RestoreJobResult{
Id: job.Id,
TimeStart: time.Now(),
TimeEnd: time.Now(),
Result: "Failure",
Error: err,
}, err
}
return types.RestoreJobResult{
Id: job.Id,
TimeStart: time.Now(),
TimeEnd: time.Now(),
Result: "Success",
Error: nil,
}, nil
}
func restoreMariaDB(job types.Job) error {

View File

@@ -1,65 +1,14 @@
<!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>
<meta charset="UTF-8" />
<title>Docker Database Backup</title>
<script type="module" src="https://cdn.jsdelivr.net/npm/@ionic/core/dist/ionic/ionic.esm.js"></script>
<script nomodule src="https://cdn.jsdelivr.net/npm/@ionic/core/dist/ionic/ionic.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@ionic/core/css/ionic.bundle.css"/>
</head>
<body>
<div id="app"></div>
<script type="module" src="/static/app.js"></script>
<div id="app"></div>
<script src="/static/app.js"></script>
</body>
</html>

1076
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +0,0 @@
{
"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"
}
}

View File

@@ -1,174 +0,0 @@
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();
});

222
web/static/app.js Normal file
View File

@@ -0,0 +1,222 @@
document.addEventListener('DOMContentLoaded', () => {
const app = document.getElementById('app');
if (!app) return;
const ionApp = document.createElement('ion-app');
const ionContent = document.createElement('ion-content');
ionContent.classList.add('ion-padding');
const mainDiv = document.createElement('div');
ionContent.appendChild(mainDiv);
ionApp.appendChild(ionContent);
app.appendChild(ionApp);
let jobs = [];
let filteredJobs = [];
let filters = {};
let sortField = null;
let sortAsc = true;
const columns = ['id', 'kind', 'host', 'database', 'user'];
const displayNames = ['ID', 'Kind', 'Host', 'Database', 'User'];
const headerGrid = document.createElement('ion-grid');
const filterRow = document.createElement('ion-row');
const headerRow = document.createElement('ion-row');
filterRow.style.alignItems = 'center';
headerRow.style.alignItems = 'center';
columns.forEach((col, idx) => {
// Filters row with ion-searchbar filter
const filterCol = document.createElement('ion-col');
filterCol.size = '2';
const filterInput = document.createElement('ion-searchbar');
filterInput.placeholder = `Filter ${displayNames[idx]}`;
filterInput.style.setProperty('--min-height', '32px');
filterInput.value = filters[col] || '';
filterInput.addEventListener('ionInput', e => {
const val = e.target.value.trim().toLowerCase();
if (val) filters[col] = val;
else delete filters[col];
applyFilterSortRender();
});
filterCol.appendChild(filterInput);
filterRow.appendChild(filterCol);
// Header row labels with sorting
const headerCol = document.createElement('ion-col');
headerCol.size = '2';
headerCol.style.fontWeight = 'bold';
headerCol.style.cursor = 'pointer';
headerCol.textContent = displayNames[idx];
headerCol.addEventListener('click', () => {
if (sortField === col) {
sortAsc = !sortAsc;
} else {
sortField = col;
sortAsc = true;
}
applyFilterSortRender();
});
if (sortField === col) {
headerCol.textContent += sortAsc ? ' ▲' : ' ▼';
}
headerCol.classList.add('ion-text-center');
headerRow.appendChild(headerCol);
});
// Actions column header and filter column (empty)
const actionsFilterCol = document.createElement('ion-col');
actionsFilterCol.size = '2';
filterRow.appendChild(actionsFilterCol);
const actionsHeaderCol = document.createElement('ion-col');
actionsHeaderCol.size = '2';
actionsHeaderCol.style.fontWeight = 'bold';
actionsHeaderCol.textContent = 'Actions';
actionsHeaderCol.classList.add('ion-text-center'); // Center the header text
headerRow.appendChild(actionsHeaderCol);
headerGrid.appendChild(filterRow);
headerGrid.appendChild(headerRow);
const ionList = document.createElement('ion-list');
mainDiv.appendChild(headerGrid);
mainDiv.appendChild(ionList);
async function loadJobs() {
try {
const res = await fetch('/list');
jobs = await res.json();
applyFilterSortRender();
} catch {
await presentAlert('Failed to load jobs.');
}
}
function applyFilterSortRender() {
filteredJobs = jobs.filter(job =>
Object.entries(filters).every(([k, v]) =>
(job[k] || '').toLowerCase().includes(v)
)
);
if (sortField) {
filteredJobs.sort((a, b) => {
if (a[sortField] < b[sortField]) return sortAsc ? -1 : 1;
if (a[sortField] > b[sortField]) return sortAsc ? 1 : -1;
return 0;
});
}
renderJobList();
}
// Show an Ionic alert with OK button
async function presentAlert(message) {
return new Promise(async (resolve) => {
const alert = document.createElement('ion-alert');
alert.header = 'Result';
alert.message = message;
alert.buttons = ['OK'];
document.body.appendChild(alert);
await alert.present();
alert.addEventListener('didDismiss', () => {
alert.remove();
resolve();
});
});
}
// Show Ionic confirmation alert, resolves boolean
async function presentConfirm(message) {
return new Promise(async (resolve) => {
const alert = document.createElement('ion-alert');
alert.header = 'Confirm';
alert.message = message;
alert.buttons = [
{
text: 'Cancel',
role: 'cancel',
handler: () => resolve(false),
},
{
text: 'OK',
handler: () => resolve(true),
},
];
document.body.appendChild(alert);
await alert.present();
alert.addEventListener('didDismiss', () => {
alert.remove();
});
});
}
function renderJobList() {
ionList.innerHTML = '';
filteredJobs.forEach(job => {
const ionItem = document.createElement('ion-item');
const grid = document.createElement('ion-grid');
const row = document.createElement('ion-row');
columns.forEach(col => {
const colElem = document.createElement('ion-col');
colElem.size = '2';
colElem.textContent = job[col] || '';
colElem.classList.add('ion-text-center'); // Center column text
row.appendChild(colElem);
});
const actionCol = document.createElement('ion-col');
actionCol.size = '2';
actionCol.classList.add('ion-text-center'); // Center alignment
const dumpBtn = document.createElement('ion-button');
dumpBtn.color = 'primary';
dumpBtn.size = 'small';
dumpBtn.textContent = 'Dump';
dumpBtn.addEventListener('click', () => triggerAction(job.id, 'dump'));
const restoreBtn = document.createElement('ion-button');
restoreBtn.color = 'danger';
restoreBtn.size = 'small';
restoreBtn.textContent = 'Restore';
restoreBtn.addEventListener('click', async () => {
const confirmed = await presentConfirm(`Confirm restore job ${job.id}?`);
if (confirmed) {
triggerAction(job.id, 'restore');
}
});
actionCol.appendChild(dumpBtn);
actionCol.appendChild(restoreBtn);
row.appendChild(actionCol);
grid.appendChild(row);
ionItem.appendChild(grid);
ionList.appendChild(ionItem);
});
}
async function triggerAction(jobId, action) {
try {
const res = await fetch(`/${action}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({id: jobId}),
});
const result = await res.json();
await presentAlert(`${action.toUpperCase()} result: ${result.result || 'Unknown'}`);
loadJobs();
} catch {
await presentAlert(`Error running ${action}`);
}
}
loadJobs();
});

80
web/static/style.css Normal file
View File

@@ -0,0 +1,80 @@
/* Minimal iOS-like clean theme */
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
background: #f9f9fa;
color: #1c1c1e;
margin: 0;
padding: 20px;
}
table {
border-collapse: separate;
border-spacing: 0 12px;
width: 100%;
background: transparent;
}
thead th {
color: #6e6e73;
text-transform: uppercase;
font-weight: 600;
cursor: pointer;
padding: 10px 15px;
user-select: none;
}
thead th.sort-asc::after {
content: " ▲";
font-size: 12px;
}
thead th.sort-desc::after {
content: " ▼";
font-size: 12px;
}
tbody td {
background: white;
padding: 12px 15px;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(60, 60, 67, 0.08);
vertical-align: middle;
}
tbody tr:hover td {
background: #f3f3f7;
}
input.filter {
width: 100%;
padding: 7px 10px;
border-radius: 12px;
border: 1px solid #ccc;
font-size: 14px;
color: #1c1c1e;
box-sizing: border-box;
user-select: text;
}
.btn {
background-color: #007aff;
border: none;
color: white;
padding: 8px 14px;
border-radius: 14px;
font-weight: 600;
cursor: pointer;
margin-right: 6px;
transition: background-color 0.2s ease;
}
.btn:hover {
background-color: #005bb5;
}
.btn:active {
background-color: #004494;
}

View File

@@ -1,17 +0,0 @@
{
"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/**/*"]
}