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 = ` +
+

Job Control Panel

+
+ + + + ${columns.map(col => ``).join('')} + + + + ${columns.map(col => ``).join('')} + + + + +
Actions
${col.toUpperCase()}
+
+
+ `; + + // 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; -} -