Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
All notable changes are documented here. Releases follow [SemVer](https://semver.org).
Image tags published to Docker Hub (`krippler52/starr`) and GHCR (`ghcr.io/krippler/starr`).

## [Unreleased]

### Added
- **Rearrangeable dashboard** — the main panels (Connection, Repair Log, Operations, Trends, Backups, Schedules, Notifications) are now drag-to-rearrange cards. Hover a panel header to reveal a grip handle and drag it to reorder or move it between columns. On wide screens the panels flow into 2–3 responsive columns (single column on narrow/mobile, identical to before); each width remembers its own arrangement, saved per-browser in `localStorage` (same mechanism as the existing panel collapse-state). A **Reset layout** button restores the default. The live run status (shutdown warning, progress bar, result tiles) stays in a fixed strip above the grid so it never moves mid-repair. Purely a frontend enhancement — no API or backend changes.

## [1.2.10] — 2026-07-16

### Fixed
Expand Down
221 changes: 213 additions & 8 deletions app/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,33 @@
.phase.failed { border-color: var(--err); }
.phase.failed .phase-num, .phase.failed .phase-label { color: var(--err); }
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 14px; overflow: hidden; }

/* ── Rearrangeable dashboard (drag cards between responsive columns) ─────────── */
.dashboard { display: flex; align-items: flex-start; gap: 14px; }
.dash-col { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; }
/* Cards keep their own styling; the column's flex-gap replaces per-card margin. */
.dash-col > .dash-card { margin-bottom: 14px; }
.dash-card { position: relative; }
/* Grip handle injected into each card header by JS. Hidden until you hover the
card (or while a drag is in progress), Fresharr-style. */
.drag-handle {
cursor: grab; user-select: none; flex-shrink: 0;
font-size: 14px; line-height: 1; color: var(--muted);
opacity: 0; transition: opacity .15s; padding: 0 6px 0 0; margin-right: -2px;
}
.dash-card:hover .drag-handle, .dashboard.dnd-active .drag-handle { opacity: .5; }
.drag-handle:hover { opacity: 1; color: var(--app); }
.drag-handle:active { cursor: grabbing; }
.dash-card.is-dragging { opacity: .35; }
.dash-col.drop-into { outline: 2px dashed var(--app); outline-offset: -2px; border-radius: var(--radius); }
.dash-card.drop-before { box-shadow: 0 -3px 0 -1px var(--app); }
.dash-card.drop-after { box-shadow: 0 3px 0 -1px var(--app); }
#resetLayoutBtn { display: none; }
#layoutTools.show #resetLayoutBtn { display: inline-flex; }
#layoutTools { display: flex; justify-content: flex-end; gap: 8px; margin: -4px 0 10px; }
#runStatus:empty { display: none; }
.layout-hint { font-size: 10px; color: var(--muted); align-self: center; margin-right: auto; opacity: .8; }
@media (max-width: 720px) { .drag-handle { display: none; } .layout-hint { display: none; } }
.panel-head {
display: flex; align-items: center; justify-content: space-between;
padding: 11px 16px; border-bottom: 1px solid var(--border);
Expand Down Expand Up @@ -478,7 +505,17 @@ <h1 id="appTitle">STARR DB REPAIR</h1>
<div class="phase" id="ph6"><div class="phase-num">6</div><div class="phase-label">Online</div></div>
</div>

<div class="panel">
<!-- Layout tools + drag-to-rearrange dashboard. JS lifts the transient run
widgets (warning / progress / results) into #runStatus above, then flows
the .dash-card panels into responsive columns you can rearrange. -->
<div id="layoutTools">
<span class="layout-hint">Drag a panel's ⠿ grip to rearrange your dashboard</span>
<button class="btn btn-ghost btn-sm" id="resetLayoutBtn" onclick="resetLayout()"
title="Restore the default panel arrangement">Reset layout</button>
</div>
<div id="dashboard" class="dashboard">

<div class="panel dash-card" data-card="connection">
<div class="panel-head">
<div class="panel-title">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
Expand Down Expand Up @@ -546,7 +583,7 @@ <h1 id="appTitle">STARR DB REPAIR</h1>
<div class="stat-card"><div class="stat-val acc" id="statTime">—</div><div class="stat-label">Duration</div></div>
</div>

<div class="terminal">
<div class="terminal dash-card" data-card="log">
<div class="terminal-bar">
<div class="term-dots"><span></span><span></span><span></span></div>
<div class="term-title" id="termTitle">REPAIR LOG</div>
Expand All @@ -563,7 +600,7 @@ <h1 id="appTitle">STARR DB REPAIR</h1>
</div>

<!-- ── Repair Operations (collapsible, just above Run Repair) ─────────────── -->
<div class="panel collapsible is-collapsed" data-section="ops">
<div class="panel collapsible is-collapsed dash-card" data-card="ops" data-section="ops">
<div class="panel-head" onclick="toggleSection('ops', event)">
<div class="panel-title">
<svg class="chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="6 9 12 15 18 9"/></svg>
Expand Down Expand Up @@ -608,7 +645,7 @@ <h1 id="appTitle">STARR DB REPAIR</h1>
</div>

<!-- ── Trends ─────────────────────────────────────────────────────────────── -->
<div class="panel collapsible is-collapsed" data-section="trends">
<div class="panel collapsible is-collapsed dash-card" data-card="trends" data-section="trends">
<div class="panel-head" onclick="toggleSection('trends')">
<div class="panel-title">
<svg class="chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="6 9 12 15 18 9"/></svg>
Expand All @@ -622,7 +659,7 @@ <h1 id="appTitle">STARR DB REPAIR</h1>
</div>
</div>

<div class="panel collapsible is-collapsed" data-section="backups">
<div class="panel collapsible is-collapsed dash-card" data-card="backups" data-section="backups">
<div class="panel-head" onclick="toggleSection('backups', event)">
<div class="panel-title">
<svg class="chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="6 9 12 15 18 9"/></svg>
Expand Down Expand Up @@ -684,7 +721,7 @@ <h1 id="appTitle">STARR DB REPAIR</h1>
</div>

<!-- ── Scheduled Repairs ──────────────────────────────────────────────────── -->
<div class="panel collapsible is-collapsed" data-section="schedules">
<div class="panel collapsible is-collapsed dash-card" data-card="schedules" data-section="schedules">
<div class="panel-head" onclick="toggleSection('schedules', event)">
<div class="panel-title">
<svg class="chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="6 9 12 15 18 9"/></svg>
Expand Down Expand Up @@ -765,7 +802,7 @@ <h1 id="appTitle">STARR DB REPAIR</h1>
</div>

<!-- ── Notifications ──────────────────────────────────────────────────────── -->
<div class="panel collapsible is-collapsed" data-section="notifications">
<div class="panel collapsible is-collapsed dash-card" data-card="notifications" data-section="notifications">
<div class="panel-head" onclick="toggleSection('notifications', event)">
<div class="panel-title">
<svg class="chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="6 9 12 15 18 9"/></svg>
Expand Down Expand Up @@ -823,6 +860,8 @@ <h1 id="appTitle">STARR DB REPAIR</h1>
</div>
</div>

</div><!-- /dashboard -->

</div><!-- /mainShell -->
</div><!-- /wrap -->

Expand Down Expand Up @@ -1116,7 +1155,7 @@ <h1 id="appTitle">STARR DB REPAIR</h1>
function toggleSection(name, ev) {
// Don't toggle when the click landed on an interactive child of the header
// (e.g. retention <select>, "Add Schedule" button, Enabled checkbox).
if (ev && ev.target.closest('button, select, input, a, label')) return;
if (ev && ev.target.closest('button, select, input, a, label, .drag-handle')) return;
const p = document.querySelector(`.panel.collapsible[data-section="${name}"]`);
if (!p) return;
const collapsed = !p.classList.contains('is-collapsed');
Expand All @@ -1132,12 +1171,178 @@ <h1 id="appTitle">STARR DB REPAIR</h1>
}
}

// ── Rearrangeable dashboard (drag cards between responsive columns) ────────────
// The .dash-card panels inside #dashboard flow into N responsive columns. Drag a
// card's grip handle to reorder it or move it to another column. The layout is
// saved PER column-count in localStorage (like Fresharr's per-width layouts), so
// your 3-column, 2-column and 1-column arrangements are remembered separately.
// Progressive enhancement: with JS off (or on a narrow phone) it's just the
// original single stacked column in source order.
const LAYOUT_KEY = 'starr_card_layout';
const DEFAULT_CARD_ORDER = ['connection', 'log', 'ops', 'trends', 'backups', 'schedules', 'notifications'];
let _dashInited = false;
let _dragCard = null;

function _columnCount() {
const w = window.innerWidth;
if (w >= 1500) return 3;
if (w >= 980) return 2;
return 1;
}
function _loadLayouts() {
try { return JSON.parse(localStorage.getItem(LAYOUT_KEY) || '{}') || {}; }
catch (_) { return {}; }
}
function _saveLayouts(all) {
try { localStorage.setItem(LAYOUT_KEY, JSON.stringify(all)); } catch (_) {}
}
function _cardMap() {
const m = {};
document.querySelectorAll('#dashboard .dash-card').forEach(c => { m[c.dataset.card] = c; });
return m;
}
function _shortestCol(cols) {
let best = cols[0];
for (const c of cols) if (c.length < best.length) best = c;
return best;
}

// Lift the transient run widgets (shutdown warning / progress / results) out of
// the card flow into a fixed strip above the grid, so live repair status stays
// put while the cards get rearranged.
function _extractNonCards() {
const dash = document.getElementById('dashboard');
if (!dash) return;
let strip = document.getElementById('runStatus');
if (!strip) {
strip = document.createElement('div');
strip.id = 'runStatus';
dash.parentNode.insertBefore(strip, dash);
}
Array.from(dash.children).forEach(el => {
if (!el.classList.contains('dash-card') && !el.classList.contains('dash-col')) {
strip.appendChild(el);
}
});
}

function renderMasonry() {
const dash = document.getElementById('dashboard');
if (!dash) return;
const n = _columnCount();
const cards = _cardMap(); // id -> element (references held)
const layouts = _loadLayouts();
const saved = layouts[n];
let cols;
if (Array.isArray(saved)) {
cols = saved.map(ids => ids.filter(id => cards[id])); // drop unknown ids
const placed = new Set(cols.flat());
DEFAULT_CARD_ORDER.concat(Object.keys(cards)).forEach(id => { // place any newcomers
if (cards[id] && !placed.has(id)) { placed.add(id); _shortestCol(cols.length ? cols : [[]]).push(id); }
});
while (cols.length < n) cols.push([]);
if (cols.length > n) { // fold extra columns into the last kept one
const overflow = cols.slice(n).flat();
cols = cols.slice(0, n);
overflow.forEach(id => _shortestCol(cols).push(id));
}
} else {
cols = Array.from({ length: n }, () => []);
DEFAULT_CARD_ORDER.filter(id => cards[id]).forEach((id, i) => cols[i % n].push(id));
}
dash.querySelectorAll('.dash-col').forEach(c => c.remove()); // cards are held in `cards`
cols.forEach(ids => {
const col = document.createElement('div');
col.className = 'dash-col';
ids.forEach(id => col.appendChild(cards[id]));
_wireColumn(col);
dash.appendChild(col);
});
_injectHandles();
document.getElementById('layoutTools').classList.toggle('show', Array.isArray(saved));
}

function _injectHandles() {
document.querySelectorAll('#dashboard .dash-card').forEach(card => {
if (card._handleWired) return;
const head = card.querySelector('.panel-head, .terminal-bar');
if (!head) return;
const h = document.createElement('span');
h.className = 'drag-handle';
h.title = 'Drag to rearrange';
h.textContent = '⠳'; // ⠳ braille grip
h.setAttribute('draggable', 'true');
head.insertBefore(h, head.firstChild);
h.addEventListener('dragstart', e => {
_dragCard = card;
card.classList.add('is-dragging');
document.getElementById('dashboard').classList.add('dnd-active');
e.dataTransfer.effectAllowed = 'move';
try { e.dataTransfer.setData('text/plain', card.dataset.card); } catch (_) {}
});
h.addEventListener('dragend', () => {
card.classList.remove('is-dragging');
document.getElementById('dashboard').classList.remove('dnd-active');
_dragCard = null;
_persistLayout();
});
card._handleWired = true;
});
}

function _cardAfterPoint(col, y) {
const cards = Array.from(col.querySelectorAll(':scope > .dash-card:not(.is-dragging)'));
for (const c of cards) {
const r = c.getBoundingClientRect();
if (y < r.top + r.height / 2) return c;
}
return null;
}

function _wireColumn(col) {
col.addEventListener('dragover', e => {
if (!_dragCard) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const after = _cardAfterPoint(col, e.clientY); // live-move into place
if (after == null) { if (_dragCard.parentNode !== col || col.lastElementChild !== _dragCard) col.appendChild(_dragCard); }
else if (after !== _dragCard) col.insertBefore(_dragCard, after);
});
col.addEventListener('drop', e => e.preventDefault());
}

function _persistLayout() {
const n = _columnCount();
const cols = Array.from(document.querySelectorAll('#dashboard .dash-col')).map(col =>
Array.from(col.querySelectorAll(':scope > .dash-card')).map(c => c.dataset.card));
const all = _loadLayouts();
all[n] = cols;
_saveLayouts(all);
document.getElementById('layoutTools').classList.add('show');
}

function resetLayout() {
_saveLayouts({});
renderMasonry();
}

function initDashboardLayout() {
if (_dashInited) return;
if (!document.getElementById('dashboard')) return;
_dashInited = true;
_extractNonCards();
renderMasonry();
let t;
window.addEventListener('resize', () => { clearTimeout(t); t = setTimeout(renderMasonry, 150); });
}

function unlockUI() {
if (_unlocked) return;
_unlocked = true;
document.getElementById('authGate').style.display = 'none';
document.getElementById('mainShell').style.display = '';
applySectionState();
initDashboardLayout();
// Only load expanded sections; collapsed ones load on first expand to keep
// the initial dashboard snappy.
const state = _sectionState();
Expand Down
Loading