feat: add integrity checks and Justfile
This commit is contained in:
@@ -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
9
Justfile
Normal file
@@ -0,0 +1,9 @@
|
||||
build:
|
||||
docker compose build
|
||||
|
||||
run: build
|
||||
docker compose up -d
|
||||
docker compose logs -f
|
||||
|
||||
clean:
|
||||
docker compose down -v
|
||||
@@ -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
|
||||
}
|
||||
|
||||
13
internal/api/public/index.html
Normal file
13
internal/api/public/index.html
Normal 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>
|
||||
278
internal/api/public/static/app.js
Normal file
278
internal/api/public/static/app.js
Normal 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();
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user