feat: add integrity checks and Justfile

This commit is contained in:
2025-11-06 17:19:07 +01:00
parent 4c8acd68f1
commit 4b69c8b411
9 changed files with 394 additions and 345 deletions

View File

@@ -22,9 +22,7 @@ FROM alpine:latest
WORKDIR /app
COPY --from=go-builder /app/ddbb .
COPY ./web/ /app/
RUN apk update
RUN apk add bash mariadb-client postgresql17-client mongodb-tools
EXPOSE 3000

9
Justfile Normal file
View File

@@ -0,0 +1,9 @@
build:
docker compose build
run: build
docker compose up -d
docker compose logs -f
clean:
docker compose down -v

View File

@@ -6,17 +6,66 @@ import (
"ddbb/internal/integrity"
"ddbb/internal/restore"
"ddbb/internal/types"
"io/fs"
"log"
"net/http"
"slices"
"embed"
"github.com/gin-gonic/gin"
)
//go:embed public
var embeddedFiles embed.FS
type RequestBody struct {
Id string `json:"id"`
}
// API entrypoint
func SetupRouter() *gin.Engine {
// GIN General setup
gin.SetMode(gin.ReleaseMode)
r := gin.Default()
err := r.SetTrustedProxies([]string{"127.0.0.1"})
if err != nil {
log.Printf("%v", err)
}
// API group: isolate endpoints under /api
apiGroup := r.Group("/api")
{
apiGroup.GET("/list", listJobs)
apiGroup.POST("/dump", triggerDump)
apiGroup.POST("/restore", triggerRestore)
apiGroup.POST("/integrity", triggerIntegrityCheck)
}
// Serve static assets under /static to avoid conflict
staticFS, _ := fs.Sub(embeddedFiles, "public/static")
r.StaticFS("/static", http.FS(staticFS))
// Serve SPA index.html at root or fallback route for client-side routing
r.GET("/", func(c *gin.Context) {
indexFile, err := embeddedFiles.Open("public/index.html")
if err != nil {
c.String(http.StatusInternalServerError, "index.html not found")
return
}
defer indexFile.Close()
stat, err := indexFile.Stat()
if err != nil {
c.String(http.StatusInternalServerError, "could not stat index.html")
return
}
c.DataFromReader(http.StatusOK, stat.Size(), "text/html; charset=utf-8", indexFile, nil)
})
return r
}
func selectJob(id string) types.Job {
var chosenJob *types.Job
jobs, err := docker.GetConfigsFromContainers()
@@ -121,25 +170,3 @@ func triggerIntegrityCheck(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "Invalid job id"})
}
}
// API entrypoint
func SetupRouter() *gin.Engine {
// GIN General setup
gin.SetMode(gin.ReleaseMode)
r := gin.Default()
err := r.SetTrustedProxies([]string{"127.0.0.1"})
if err != nil {
log.Printf("%v", err)
}
// GIN endpoints
r.Static("/static", "./static")
r.GET("/", func(c *gin.Context) {
c.File("./index.html")
})
r.GET("/list", listJobs)
r.POST("/dump", triggerDump)
r.POST("/integrity", triggerIntegrityCheck)
r.POST("/restore", triggerRestore)
return r
}

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Docker Database Backup</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</head>
<body>
<div id="app"></div>
<script src="/static/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,278 @@
document.addEventListener('DOMContentLoaded', () => {
const app = document.getElementById('app');
if (!app) return;
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'];
// Build layout with centered title and responsive table
function buildLayout() {
app.innerHTML = `
<div class="container my-4">
<h2 class="text-center mb-4">Job Control Panel</h2>
<div class="table-responsive">
<table class="table table-striped table-bordered table-hover mx-auto" style="max-width: 900px;">
<thead class="thead-light">
<tr>
${columns.map(col => `<th><input type="text" class="form-control form-control-sm filter-input" data-col="${col}" placeholder="Filter ${col}"></th>`).join('')}
<th class="text-center">Actions</th>
</tr>
<tr>
${columns.map(col => `<th class="sortable text-center" data-col="${col}" style="cursor:pointer;">${col.toUpperCase()}<span class="sort-arrow"></span></th>`).join('')}
<th></th>
</tr>
</thead>
<tbody id="jobs-tbody"></tbody>
</table>
</div>
</div>
`;
// Filter listeners
app.querySelectorAll('.filter-input').forEach(input => {
input.addEventListener('input', e => {
const col = e.target.getAttribute('data-col');
const val = e.target.value.trim().toLowerCase();
if (val) filters[col] = val;
else delete filters[col];
applyFiltersAndSort();
});
});
// Sorting listeners
app.querySelectorAll('th.sortable').forEach(th => {
th.addEventListener('click', () => {
const col = th.getAttribute('data-col');
if (sortField === col) {
sortAsc = !sortAsc;
} else {
sortField = col;
sortAsc = true;
}
applyFiltersAndSort();
updateSortArrows();
});
});
}
// Update sorting arrows
function updateSortArrows() {
app.querySelectorAll('th.sortable span.sort-arrow').forEach(span => {
span.textContent = '';
});
if (sortField) {
const th = app.querySelector(`th.sortable[data-col="${sortField}"] span.sort-arrow`);
if (th) th.textContent = sortAsc ? ' ▲' : ' ▼';
}
}
// Filter and sort jobs based on user input
function applyFiltersAndSort() {
filteredJobs = jobs.filter(job =>
Object.entries(filters).every(([col, val]) =>
(job[col] || '').toLowerCase().includes(val)
)
);
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;
});
}
renderRows();
}
// Render table rows with dropdown action menu
function renderRows() {
const tbody = app.querySelector('#jobs-tbody');
tbody.innerHTML = '';
filteredJobs.forEach(job => {
const tr = document.createElement('tr');
columns.forEach(col => {
const td = document.createElement('td');
td.textContent = job[col] || '';
td.className = 'text-center align-middle';
tr.appendChild(td);
});
// Actions dropdown
const actionsTd = document.createElement('td');
actionsTd.className = 'text-center align-middle';
actionsTd.innerHTML = `
<div class="dropdown">
<button class="btn btn-secondary btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
Actions
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="#" data-action="dump">Dump</a></li>
<li><a class="dropdown-item" href="#" data-action="integrity">Integrity check</a></li>
<li><a class="dropdown-item" href="#" data-action="restore">Restore</a></li>
</ul>
</div>
`;
// Attach event listeners for dropdown actions
actionsTd.querySelectorAll('a.dropdown-item').forEach(a => {
a.addEventListener('click', async (e) => {
e.preventDefault();
const action = a.getAttribute('data-action');
if (action === 'restore') {
const confirmed = await showModalConfirm(`Confirm restore job ${job.id}?`);
if (!confirmed) return;
}
await triggerAction(job.id, action);
});
});
tr.appendChild(actionsTd);
tbody.appendChild(tr);
});
}
// Modal confirm dialog with Bootstrap
function showModalConfirm(message) {
return new Promise(resolve => {
const modalId = 'confirmModal';
let modalEl = document.getElementById(modalId);
if (!modalEl) {
modalEl = document.createElement('div');
modalEl.id = modalId;
modalEl.className = 'modal fade';
modalEl.tabIndex = -1;
modalEl.setAttribute('aria-hidden', 'true');
modalEl.innerHTML = `
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Please Confirm</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body"><p>${message}</p></div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="confirmBtn">OK</button>
</div>
</div>
</div>`;
document.body.appendChild(modalEl);
} else {
modalEl.querySelector('.modal-body p').textContent = message;
}
const bsModal = new bootstrap.Modal(modalEl, { backdrop: 'static' });
modalEl.querySelector('#confirmBtn').onclick = () => {
bsModal.hide();
resolve(true);
};
modalEl.querySelector('.btn-secondary').onclick = () => {
bsModal.hide();
resolve(false);
};
modalEl.querySelector('.btn-close').onclick = () => {
bsModal.hide();
resolve(false);
};
modalEl.addEventListener('hidden.bs.modal', () => {
resolve(false);
}, { once: true });
bsModal.show();
});
}
// Perform backend action POST call
async function triggerAction(id, action) {
try {
const res = await fetch(`/api/${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
});
const json = await res.json();
await showModalAlert(`${json.Result || 'Unknown'}`);
await loadJobs();
} catch {
await showModalAlert(`Error performing ${action}`);
}
}
// Modal alert dialog
function showModalAlert(message) {
return new Promise(resolve => {
const modalId = 'alertModal';
let modalEl = document.getElementById(modalId);
if (!modalEl) {
modalEl = document.createElement('div');
modalEl.id = modalId;
modalEl.className = 'modal fade';
modalEl.tabIndex = -1;
modalEl.setAttribute('aria-hidden', 'true');
modalEl.innerHTML = `
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Result</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body"><p>${message}</p></div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">OK</button>
</div>
</div>
</div>`;
document.body.appendChild(modalEl);
} else {
modalEl.querySelector('.modal-body p').textContent = message;
}
const bsModal = new bootstrap.Modal(modalEl, { backdrop: 'static' });
let resolveFunc;
const promise = new Promise(res => { resolveFunc = res; });
modalEl.querySelector('.btn-primary').onclick = () => {
bsModal.hide();
resolveFunc();
};
modalEl.querySelector('.btn-close').onclick = () => {
bsModal.hide();
resolveFunc();
};
modalEl.addEventListener('hidden.bs.modal', () => {
resolveFunc();
}, { once: true });
bsModal.show();
return promise;
});
}
// Load jobs from API and refresh UI
async function loadJobs() {
try {
const res = await fetch('/api/list');
jobs = await res.json();
applyFiltersAndSort();
} catch {
await showModalAlert('Failed to load jobs');
}
}
buildLayout();
loadJobs();
});

View File

@@ -1,16 +1,22 @@
package integrity
import (
"bytes"
"ddbb/internal/types"
"log"
"os"
"os/exec"
"strings"
"time"
)
func NewIntegrityCheckJob(job types.Job) (types.DumpJobResult, error) {
var err error
var result string
switch strings.ToLower(job.Kind) {
case "mariadb":
err = checkMariaDB(job)
result, err = checkMariaDB(job)
case "postgres":
err = checkPostgres(job)
case "mongodb":
@@ -21,13 +27,47 @@ func NewIntegrityCheckJob(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: result,
Error: err,
}, nil
}
return types.DumpJobResult{}, nil
return types.DumpJobResult{
Id: job.Id,
TimeStart: time.Now(),
TimeEnd: time.Now(),
Result: result,
Error: nil,
}, nil
}
func checkMariaDB(job types.Job) error {
log.Println("Not yet implemented")
return nil
func checkMariaDB(job types.Job) (string, error) {
var out bytes.Buffer
var err error
log.Printf("Starting integrity check of mariadb database %s on %s", job.Database, job.Host)
cmd := exec.Command(
"mariadb-check",
"--ssl-verify-server-cert=FALSE",
"-h", job.Host,
"-u", job.User,
"-p"+job.Password,
"-P", job.Port,
job.Database,
)
cmd.Stdout = &out
cmd.Stdout = &out
cmd.Stderr = os.Stderr
if err = cmd.Run(); err != nil {
log.Fatalf("Mariadb integrity check failed: %v", err)
} else {
log.Printf("Integrity check with id %s successfully complete", job.Id)
}
return out.String(), err
}
func checkPostgres(job types.Job) error {

View File

@@ -1,14 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<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 src="/static/app.js"></script>
</body>
</html>

View File

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

View File

@@ -1,80 +0,0 @@
/* 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;
}