diff --git a/Dockerfile b/Dockerfile
index 4dd216a..7dc3b51 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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
diff --git a/Justfile b/Justfile
new file mode 100644
index 0000000..a4c6f04
--- /dev/null
+++ b/Justfile
@@ -0,0 +1,9 @@
+build:
+ docker compose build
+
+run: build
+ docker compose up -d
+ docker compose logs -f
+
+clean:
+ docker compose down -v
diff --git a/internal/api/api.go b/internal/api/api.go
index 438f0a9..bbea897 100644
--- a/internal/api/api.go
+++ b/internal/api/api.go
@@ -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
-}
diff --git a/internal/api/public/index.html b/internal/api/public/index.html
new file mode 100644
index 0000000..0fb6973
--- /dev/null
+++ b/internal/api/public/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+ Docker Database Backup
+
+
+
+
+
+
+
+
diff --git a/internal/api/public/static/app.js b/internal/api/public/static/app.js
new file mode 100644
index 0000000..367d67f
--- /dev/null
+++ b/internal/api/public/static/app.js
@@ -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 = `
+
+ `;
+
+ // 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 = `
+
+
+
+
+ `;
+
+ // 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 = `
+ `;
+ 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 = `
+ `;
+ 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();
+});
diff --git a/internal/integrity/integrity.go b/internal/integrity/integrity.go
index 71afe3e..3245340 100644
--- a/internal/integrity/integrity.go
+++ b/internal/integrity/integrity.go
@@ -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 {
diff --git a/web/index.html b/web/index.html
deleted file mode 100644
index 88a6569..0000000
--- a/web/index.html
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
- Docker Database Backup
-
-
-
-
-
-
-
-
-
diff --git a/web/static/app.js b/web/static/app.js
deleted file mode 100644
index 8862503..0000000
--- a/web/static/app.js
+++ /dev/null
@@ -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();
-});
diff --git a/web/static/style.css b/web/static/style.css
deleted file mode 100644
index f309b55..0000000
--- a/web/static/style.css
+++ /dev/null
@@ -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;
-}
-