2026-01-28 08:03:17 +01:00
|
|
|
{% extends "shared/frontend/base.html" %}
|
|
|
|
|
|
|
|
|
|
{% block title %}Database Migrationer - BMC Hub{% endblock %}
|
|
|
|
|
|
|
|
|
|
{% block extra_css %}
|
|
|
|
|
<style>
|
|
|
|
|
.migration-table td {
|
|
|
|
|
vertical-align: middle;
|
|
|
|
|
}
|
|
|
|
|
.command-box {
|
|
|
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
|
|
|
font-size: 0.85rem;
|
|
|
|
|
background: var(--bg-secondary);
|
|
|
|
|
color: var(--text-primary);
|
|
|
|
|
border: 1px solid rgba(0,0,0,0.08);
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
padding: 0.75rem 1rem;
|
|
|
|
|
overflow-x: auto;
|
|
|
|
|
}
|
|
|
|
|
.command-actions .btn {
|
|
|
|
|
min-width: 120px;
|
|
|
|
|
}
|
2026-03-25 22:49:33 +01:00
|
|
|
.migration-status-badge {
|
|
|
|
|
min-width: 72px;
|
|
|
|
|
display: inline-block;
|
|
|
|
|
text-align: center;
|
|
|
|
|
}
|
2026-01-28 08:03:17 +01:00
|
|
|
</style>
|
|
|
|
|
{% endblock %}
|
|
|
|
|
|
|
|
|
|
{% block content %}
|
|
|
|
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
|
|
|
<div>
|
|
|
|
|
<h2 class="fw-bold mb-1">Database Migrationer</h2>
|
|
|
|
|
<p class="text-muted mb-0">Oversigt over SQL migrations og kommandoer til manuel kørsel</p>
|
|
|
|
|
</div>
|
|
|
|
|
<a href="/settings" class="btn btn-outline-secondary">
|
|
|
|
|
<i class="bi bi-arrow-left me-2"></i>Tilbage til indstillinger
|
|
|
|
|
</a>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="alert alert-warning d-flex align-items-start" role="alert">
|
|
|
|
|
<i class="bi bi-exclamation-triangle me-2 mt-1"></i>
|
|
|
|
|
<div>
|
|
|
|
|
<strong>Vigtigt:</strong> Migrationer køres manuelt. Kør kun migrations du er sikker på, og tag altid backup først.
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="row g-4">
|
|
|
|
|
<div class="col-lg-8">
|
|
|
|
|
<div class="card shadow-sm border-0">
|
|
|
|
|
<div class="card-header bg-white">
|
2026-03-25 22:49:33 +01:00
|
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
|
|
|
<h6 class="mb-0 fw-bold"><i class="bi bi-database me-2"></i>Tilgængelige migrationer</h6>
|
|
|
|
|
<button id="checkMigrationStatusBtn" class="btn btn-sm btn-outline-success" onclick="checkMigrationStatuses()">
|
|
|
|
|
<i class="bi bi-check2-circle me-1"></i>Tjek status
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2026-01-28 08:03:17 +01:00
|
|
|
</div>
|
|
|
|
|
<div class="card-body">
|
|
|
|
|
{% if migrations and migrations|length > 0 %}
|
|
|
|
|
<div class="table-responsive">
|
|
|
|
|
<table class="table table-hover migration-table">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr>
|
|
|
|
|
<th>Fil</th>
|
2026-03-25 22:49:33 +01:00
|
|
|
<th>Status</th>
|
2026-01-28 08:03:17 +01:00
|
|
|
<th>Størrelse</th>
|
|
|
|
|
<th>Sidst ændret</th>
|
|
|
|
|
<th class="text-end">Handling</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
{% for migration in migrations %}
|
|
|
|
|
<tr>
|
|
|
|
|
<td>
|
|
|
|
|
<strong>{{ migration.name }}</strong>
|
|
|
|
|
</td>
|
2026-03-25 22:49:33 +01:00
|
|
|
<td>
|
|
|
|
|
<span class="badge bg-secondary migration-status-badge" data-migration="{{ migration.name }}" title="Ikke tjekket endnu">Grå</span>
|
|
|
|
|
</td>
|
2026-01-28 08:03:17 +01:00
|
|
|
<td>{{ migration.size_kb }} KB</td>
|
|
|
|
|
<td>{{ migration.modified }}</td>
|
2026-01-28 10:35:02 +01:00
|
|
|
<td class="text-end d-flex gap-2 justify-content-end">
|
2026-01-28 08:03:17 +01:00
|
|
|
<button class="btn btn-sm btn-outline-primary" onclick="showCommand('{{ migration.name }}')">
|
|
|
|
|
<i class="bi bi-terminal me-1"></i>Vis kommando
|
|
|
|
|
</button>
|
2026-01-28 10:35:02 +01:00
|
|
|
<button class="btn btn-sm btn-success" onclick="runMigration('{{ migration.name }}', this)">
|
|
|
|
|
<i class="bi bi-play-circle me-1"></i>Kør migration
|
|
|
|
|
</button>
|
2026-01-28 08:03:17 +01:00
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
{% endfor %}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
{% else %}
|
|
|
|
|
<div class="text-center py-4">
|
|
|
|
|
<i class="bi bi-info-circle text-muted fs-4"></i>
|
|
|
|
|
<p class="text-muted mb-0 mt-2">Ingen migrations fundet i mappen /migrations.</p>
|
|
|
|
|
</div>
|
|
|
|
|
{% endif %}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="col-lg-4">
|
|
|
|
|
<div class="card shadow-sm border-0 mb-4">
|
|
|
|
|
<div class="card-header bg-white">
|
|
|
|
|
<h6 class="mb-0 fw-bold"><i class="bi bi-lightning-charge me-2"></i>Kommando</h6>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="card-body">
|
|
|
|
|
<p class="text-muted mb-2">Vælg en migration for at se en klar kommando til manuel kørsel.</p>
|
|
|
|
|
<div id="commandBox" class="command-box">Vælg en migration fra listen.</div>
|
|
|
|
|
<div class="command-actions d-flex gap-2 mt-3">
|
|
|
|
|
<button id="copyCommandBtn" class="btn btn-primary" disabled onclick="copyCommand()">
|
|
|
|
|
<i class="bi bi-clipboard me-2"></i>Kopiér
|
|
|
|
|
</button>
|
|
|
|
|
<button id="copyAltBtn" class="btn btn-outline-secondary" disabled onclick="copyAltCommand()">
|
|
|
|
|
<i class="bi bi-clipboard-plus me-2"></i>Kopiér alt
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2026-01-28 10:35:02 +01:00
|
|
|
<div id="migrationFeedback" class="alert d-none mt-3" role="alert"></div>
|
2026-01-28 08:03:17 +01:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="card shadow-sm border-0">
|
|
|
|
|
<div class="card-header bg-white">
|
|
|
|
|
<h6 class="mb-0 fw-bold"><i class="bi bi-info-circle me-2"></i>Standard opsætning</h6>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="card-body">
|
|
|
|
|
<ul class="list-unstyled mb-0">
|
|
|
|
|
<li class="mb-2"><strong>DB bruger:</strong> {{ db_user }}</li>
|
|
|
|
|
<li class="mb-2"><strong>DB navn:</strong> {{ db_name }}</li>
|
2026-01-29 00:47:40 +01:00
|
|
|
<li class="mb-2"><strong>DB container:</strong> {{ db_container }}</li>
|
|
|
|
|
<li class="mb-0"><strong>Container runtime:</strong> {{ 'Podman' if is_production else 'Docker' }}</li>
|
2026-01-28 08:03:17 +01:00
|
|
|
</ul>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<script>
|
|
|
|
|
let currentCommand = "";
|
|
|
|
|
let currentAltCommand = "";
|
|
|
|
|
|
|
|
|
|
function buildCommand(migrationName) {
|
2026-01-29 00:47:40 +01:00
|
|
|
// Use podman for production, docker for local development
|
|
|
|
|
const runtime = {{ 'true' if is_production else 'false' }} ? 'podman' : 'docker';
|
|
|
|
|
|
|
|
|
|
const containerCmd = `${runtime} exec -i {{ db_container }} psql -U {{ db_user }} -d {{ db_name }} < migrations/${migrationName}`;
|
2026-01-28 08:03:17 +01:00
|
|
|
const localCmd = `psql \"$DATABASE_URL\" -f migrations/${migrationName}`;
|
2026-01-29 00:47:40 +01:00
|
|
|
currentCommand = containerCmd;
|
|
|
|
|
currentAltCommand = `${containerCmd}\n${localCmd}`;
|
|
|
|
|
document.getElementById('commandBox').textContent = containerCmd;
|
2026-01-28 08:03:17 +01:00
|
|
|
document.getElementById('copyCommandBtn').disabled = false;
|
|
|
|
|
document.getElementById('copyAltBtn').disabled = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function showCommand(migrationName) {
|
|
|
|
|
buildCommand(migrationName);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function copyCommand() {
|
|
|
|
|
if (!currentCommand) return;
|
|
|
|
|
await navigator.clipboard.writeText(currentCommand);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function copyAltCommand() {
|
|
|
|
|
if (!currentAltCommand) return;
|
|
|
|
|
await navigator.clipboard.writeText(currentAltCommand);
|
|
|
|
|
}
|
2026-01-28 10:35:02 +01:00
|
|
|
|
|
|
|
|
async function runMigration(migrationName, button) {
|
|
|
|
|
const feedback = document.getElementById('migrationFeedback');
|
|
|
|
|
|
|
|
|
|
button.disabled = true;
|
|
|
|
|
feedback.className = 'alert alert-info mt-3';
|
|
|
|
|
feedback.textContent = 'Kører migration...';
|
|
|
|
|
feedback.classList.remove('d-none');
|
|
|
|
|
|
|
|
|
|
try {
|
2026-03-25 22:49:33 +01:00
|
|
|
const urls = buildMigrationActionUrls('execute');
|
|
|
|
|
const attempts = [];
|
|
|
|
|
let data = null;
|
|
|
|
|
let lastError = null;
|
2026-04-01 23:55:20 +02:00
|
|
|
let hardError = null;
|
2026-03-25 22:49:33 +01:00
|
|
|
|
|
|
|
|
for (const url of urls) {
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(url, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({ file_name: migrationName })
|
|
|
|
|
});
|
2026-01-28 10:35:02 +01:00
|
|
|
|
2026-03-25 22:49:33 +01:00
|
|
|
const payload = await response.json().catch(() => ({}));
|
|
|
|
|
attempts.push(`${url} -> ${response.status}`);
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
data = payload;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 23:55:20 +02:00
|
|
|
const errMsg = payload.detail || payload.message || `HTTP ${response.status}`;
|
|
|
|
|
if (response.status !== 404 && response.status !== 405) {
|
|
|
|
|
hardError = errMsg;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
lastError = errMsg;
|
2026-03-25 22:49:33 +01:00
|
|
|
} catch (err) {
|
|
|
|
|
attempts.push(`${url} -> ERR`);
|
|
|
|
|
lastError = err.message || 'Netvaerksfejl';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!data) {
|
2026-04-01 23:55:20 +02:00
|
|
|
throw new Error(`${hardError || lastError || 'Migration fejlede'} (forsøgt: ${attempts.join(' | ')})`);
|
2026-03-25 22:49:33 +01:00
|
|
|
}
|
2026-01-28 10:35:02 +01:00
|
|
|
|
|
|
|
|
feedback.className = 'alert alert-success mt-3';
|
2026-04-01 23:55:20 +02:00
|
|
|
const output = data.output || data.message || 'Migration executed successfully';
|
|
|
|
|
feedback.innerHTML = `<strong>Migration kørt</strong><br><pre class="mb-0">${output}</pre>`;
|
2026-01-28 10:35:02 +01:00
|
|
|
} catch (error) {
|
|
|
|
|
feedback.className = 'alert alert-danger mt-3';
|
|
|
|
|
feedback.innerHTML = `<strong>Fejl</strong><br>${error.message}`;
|
|
|
|
|
} finally {
|
|
|
|
|
button.disabled = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-25 22:49:33 +01:00
|
|
|
|
|
|
|
|
function getStatusBadge(migrationName) {
|
|
|
|
|
const badges = document.querySelectorAll('.migration-status-badge');
|
|
|
|
|
for (const badge of badges) {
|
|
|
|
|
if (badge.dataset.migration === migrationName) {
|
|
|
|
|
return badge;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyMigrationStatus(statusItem) {
|
|
|
|
|
const badge = getStatusBadge(statusItem.name);
|
|
|
|
|
if (!badge) return;
|
|
|
|
|
|
|
|
|
|
badge.classList.remove('bg-secondary', 'bg-success', 'bg-danger');
|
|
|
|
|
|
|
|
|
|
if (statusItem.status === 'green') {
|
|
|
|
|
badge.classList.add('bg-success');
|
|
|
|
|
badge.textContent = 'Grøn';
|
|
|
|
|
} else if (statusItem.status === 'red') {
|
|
|
|
|
badge.classList.add('bg-danger');
|
|
|
|
|
badge.textContent = 'Rød';
|
|
|
|
|
} else {
|
|
|
|
|
badge.classList.add('bg-secondary');
|
|
|
|
|
badge.textContent = 'Grå';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
badge.title = statusItem.summary || 'Ingen detaljer';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function uniqueUrls(urls) {
|
|
|
|
|
const seen = new Set();
|
|
|
|
|
return urls.filter((url) => {
|
|
|
|
|
if (seen.has(url)) return false;
|
|
|
|
|
seen.add(url);
|
|
|
|
|
return true;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildMigrationActionUrls(action) {
|
|
|
|
|
const path = (window.location.pathname || '').replace(/\/+$/, '');
|
|
|
|
|
const dynamicBase = path.endsWith('/migrations') ? path : '/settings/migrations';
|
|
|
|
|
const candidates = [
|
|
|
|
|
`${dynamicBase}/${action}`,
|
|
|
|
|
`/settings/migrations/${action}`,
|
|
|
|
|
`/api/v1/settings/migrations/${action}`
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
if (dynamicBase.startsWith('/api/v1/')) {
|
|
|
|
|
candidates.unshift(`/api/v1/settings/migrations/${action}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return uniqueUrls(candidates);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function checkMigrationStatuses() {
|
|
|
|
|
const button = document.getElementById('checkMigrationStatusBtn');
|
|
|
|
|
const feedback = document.getElementById('migrationFeedback');
|
|
|
|
|
|
|
|
|
|
button.disabled = true;
|
|
|
|
|
feedback.className = 'alert alert-info mt-3';
|
|
|
|
|
feedback.textContent = 'Tjekker migration status...';
|
|
|
|
|
feedback.classList.remove('d-none');
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const urls = buildMigrationActionUrls('status');
|
|
|
|
|
let data = null;
|
|
|
|
|
let lastError = null;
|
2026-04-01 23:55:20 +02:00
|
|
|
let hardError = null;
|
2026-03-25 22:49:33 +01:00
|
|
|
const attempts = [];
|
|
|
|
|
|
|
|
|
|
for (const url of urls) {
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(url, { credentials: 'include' });
|
|
|
|
|
const payload = await response.json().catch(() => ({}));
|
|
|
|
|
attempts.push(`${url} -> ${response.status}`);
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
data = payload;
|
|
|
|
|
break;
|
|
|
|
|
}
|
2026-04-01 23:55:20 +02:00
|
|
|
const errMsg = payload.detail || `HTTP ${response.status}`;
|
|
|
|
|
if (response.status !== 404 && response.status !== 405) {
|
|
|
|
|
hardError = errMsg;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
lastError = errMsg;
|
2026-03-25 22:49:33 +01:00
|
|
|
} catch (err) {
|
|
|
|
|
attempts.push(`${url} -> ERR`);
|
|
|
|
|
lastError = err.message || 'Netvaerksfejl';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!data) {
|
2026-04-01 23:55:20 +02:00
|
|
|
throw new Error(`${hardError || lastError || 'Status check fejlede'} (forsøgt: ${attempts.join(' | ')})`);
|
2026-03-25 22:49:33 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const statuses = data.statuses || [];
|
|
|
|
|
statuses.forEach(applyMigrationStatus);
|
|
|
|
|
|
|
|
|
|
const redCount = statuses.filter(item => item.status === 'red').length;
|
|
|
|
|
const greenCount = statuses.filter(item => item.status === 'green').length;
|
|
|
|
|
const grayCount = statuses.filter(item => item.status === 'gray').length;
|
|
|
|
|
|
|
|
|
|
feedback.className = redCount > 0 ? 'alert alert-warning mt-3' : 'alert alert-success mt-3';
|
|
|
|
|
feedback.textContent = `Status opdateret: ${greenCount} grøn, ${redCount} rød, ${grayCount} grå.`;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
feedback.className = 'alert alert-danger mt-3';
|
|
|
|
|
feedback.textContent = `Fejl ved status check: ${error.message}`;
|
|
|
|
|
} finally {
|
|
|
|
|
button.disabled = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-28 08:03:17 +01:00
|
|
|
</script>
|
|
|
|
|
{% endblock %}
|