bmc_hub/app/modules/links/templates/index.html

1277 lines
42 KiB
HTML
Raw Normal View History

{% extends "shared/frontend/base.html" %}
{% block title %}Links - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.links-hero {
background: linear-gradient(135deg, var(--accent, #0f4c75) 0%, #1a759f 100%);
color: #fff;
border-radius: 16px;
padding: 0.95rem 1rem;
box-shadow: 0 10px 30px rgba(15, 76, 117, 0.22);
}
.links-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.links-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 0.55rem;
}
.links-summary .summary-card {
box-shadow: 0 4px 14px rgba(2, 32, 71, 0.06);
}
.summary-card {
border-radius: 12px;
padding: 0.62rem 0.7rem;
background: var(--bg-card);
border: 1px solid rgba(15, 76, 117, 0.12);
}
.summary-value {
font-size: 1.28rem;
font-weight: 700;
line-height: 1.1;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border-radius: 999px;
padding: 0.22rem 0.68rem;
font-size: 0.79rem;
font-weight: 600;
}
.status-ok {
color: #0f5132;
background: #d1e7dd;
}
.status-down {
color: #842029;
background: #f8d7da;
}
.status-unknown {
color: #664d03;
background: #fff3cd;
}
.links-container {
padding-bottom: 0.75rem;
}
.links-results-meta {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.65rem;
margin: 0.45rem 0 0.7rem;
color: var(--text-secondary);
font-size: 0.84rem;
}
.results-chip {
border: 1px solid rgba(15, 76, 117, 0.2);
border-radius: 999px;
padding: 0.16rem 0.5rem;
color: var(--accent, #0f4c75);
background: rgba(15, 76, 117, 0.06);
font-weight: 600;
}
.flame-category {
margin-bottom: 1.2rem;
}
.flame-category-title {
font-size: 0.98rem;
font-weight: 700;
margin-bottom: 0.62rem;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 0.6rem;
border-bottom: 1px solid rgba(15, 76, 117, 0.1);
padding-bottom: 0.4rem;
}
.flame-category-count {
font-size: 0.75rem;
background: rgba(15, 76, 117, 0.1);
color: var(--accent, #0f4c75);
padding: 0.15rem 0.5rem;
border-radius: 999px;
}
.flame-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(215px, 1fr));
gap: 0.6rem;
}
.flame-card {
background: var(--bg-card, #ffffff);
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 9px;
padding: 0.48rem 0.58rem;
display: flex;
align-items: center;
gap: 0.7rem;
position: relative;
transition: all 0.15s ease-in-out;
box-shadow: 0 2px 4px rgba(0,0,0,0.01);
}
.flame-card:hover {
background: rgba(15, 76, 117, 0.02);
border-color: rgba(15, 76, 117, 0.2);
transform: translateY(-1px);
box-shadow: 0 4px 10px rgba(0,0,0,0.04);
}
.flame-icon-wrap {
width: 34px;
height: 34px;
border-radius: 8px;
background: rgba(15, 76, 117, 0.06);
color: var(--accent, #0f4c75);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.05rem;
flex-shrink: 0;
position: relative;
}
.flame-status-dot {
position: absolute;
bottom: -2px;
right: -2px;
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid var(--bg-card, #fff);
}
.flame-status-dot.ok { background-color: #198754; }
.flame-status-dot.down { background-color: #dc3545; }
.flame-status-dot.unknown { background-color: #ffc107; }
.flame-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
}
.flame-title {
font-size: 0.87rem;
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.flame-title a {
color: var(--text-primary);
text-decoration: none;
}
.flame-title a::before {
content: '';
position: absolute;
inset: 0;
z-index: 1;
}
.flame-title a:hover {
color: var(--accent, #0f4c75);
}
.flame-subtitle {
font-size: 0.66rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.03em;
margin-top: 0.2rem;
display: flex;
align-items: center;
gap: 0.4rem;
}
.flame-actions {
display: flex;
gap: 0.25rem;
opacity: 0;
position: relative;
z-index: 2;
transition: opacity 0.2s;
}
.flame-card:hover .flame-actions {
opacity: 1;
}
.flame-actions .btn {
padding: 0.25rem 0.45rem;
font-size: 0.8rem;
background: var(--bg-card, #fff);
}
.links-table td, .links-table th {
vertical-align: middle;
}
.links-table thead th {
font-size: 0.76rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-secondary);
white-space: nowrap;
padding-top: 0.55rem;
padding-bottom: 0.55rem;
}
.links-table tbody td {
padding-top: 0.4rem;
padding-bottom: 0.4rem;
font-size: 0.86rem;
}
.links-table .btn.btn-sm {
padding: 0.22rem 0.45rem;
font-size: 0.75rem;
}
.link-main {
display: flex;
align-items: center;
gap: 0.45rem;
min-width: 0;
}
.link-name {
font-weight: 600;
font-size: 0.92rem;
line-height: 1.1;
}
.link-env {
border-radius: 999px;
padding: 0.1rem 0.4rem;
background: rgba(15, 76, 117, 0.1);
color: #0f4c75;
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
}
.target-text {
display: inline-block;
max-width: 360px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.search-shell {
border: 1px solid rgba(15, 76, 117, 0.2);
border-radius: 12px;
background: linear-gradient(180deg, rgba(255,255,255,0.95), rgba(255,255,255,0.9));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
}
.search-shell .input-group-text,
.search-shell .btn,
.search-shell .form-control {
border: none;
background: transparent;
}
.search-shell .input-group-text {
color: var(--accent, #0f4c75);
}
.search-shell:focus-within {
box-shadow: 0 0 0 0.2rem rgba(15, 76, 117, 0.14);
border-color: rgba(15, 76, 117, 0.45);
}
.search-shell .btn {
color: var(--text-secondary);
}
.links-filter-card .card-body {
padding: 0.62rem 0.78rem;
}
.links-filter-card .form-label {
margin-bottom: 0.28rem;
}
.links-filter-card .form-select,
.links-filter-card .form-control {
padding-top: 0.38rem;
padding-bottom: 0.38rem;
font-size: 0.9rem;
}
.view-toggle {
display: inline-flex;
border: 1px solid rgba(15, 76, 117, 0.18);
border-radius: 10px;
overflow: hidden;
width: 100%;
background: var(--bg-card);
}
.view-toggle .btn {
border: 0;
border-radius: 0;
flex: 1;
font-size: 0.84rem;
padding: 0.38rem 0.45rem;
color: var(--text-secondary);
background: transparent;
}
.view-toggle .btn.active {
background: rgba(15, 76, 117, 0.12);
color: var(--accent, #0f4c75);
font-weight: 600;
}
.links-list-card {
border: 1px solid rgba(15, 76, 117, 0.12);
border-radius: 12px;
overflow: hidden;
background: var(--bg-card);
box-shadow: 0 8px 22px rgba(2, 32, 71, 0.06);
}
.links-list-table {
margin-bottom: 0;
}
.links-list-table thead th {
font-size: 0.74rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
background: rgba(15, 76, 117, 0.03);
border-bottom: 1px solid rgba(15, 76, 117, 0.12);
padding-top: 0.62rem;
padding-bottom: 0.62rem;
white-space: nowrap;
}
.links-list-table tbody td {
vertical-align: middle;
border-color: rgba(15, 76, 117, 0.08);
padding-top: 0.52rem;
padding-bottom: 0.52rem;
}
.links-list-table tbody tr:hover {
background: rgba(15, 76, 117, 0.04);
}
.list-name {
font-weight: 600;
color: var(--text-primary);
text-decoration: none;
}
.list-name:hover {
color: var(--accent, #0f4c75);
}
.list-actions {
display: flex;
gap: 0.28rem;
justify-content: flex-end;
}
.list-actions .btn {
padding: 0.2rem 0.42rem;
font-size: 0.78rem;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 0.86rem;
}
@media (max-width: 767.98px) {
.links-hero {
border-radius: 12px;
padding: 0.72rem 0.8rem;
}
.target-text {
max-width: 170px;
}
.links-results-meta {
flex-direction: column;
align-items: flex-start;
}
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-3">
<div class="links-hero mb-3">
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3 align-items-lg-center">
<div>
<h1 class="h4 mb-1 text-white">Links / Endpoints</h1>
<p class="mb-0 opacity-75">Operationelt overblik over adgangspunkter med live status-check.</p>
<p class="mb-0 small opacity-75" id="scopeHint"></p>
</div>
<div class="links-actions">
<button id="createBtn" class="btn btn-warning btn-sm"><i class="bi bi-plus-lg"></i> Nyt link</button>
<button id="refreshBtn" class="btn btn-light btn-sm"><i class="bi bi-arrow-clockwise"></i> Opdater</button>
<button id="runHealthBtn" class="btn btn-outline-light btn-sm"><i class="bi bi-heart-pulse"></i> Kør health check</button>
</div>
</div>
</div>
<div class="links-summary mb-2">
<div class="summary-card">
<div class="text-muted small">Links i alt</div>
<div id="totalCount" class="summary-value">0</div>
</div>
<div class="summary-card">
<div class="text-muted small">OK</div>
<div id="okCount" class="summary-value text-success">0</div>
</div>
<div class="summary-card">
<div class="text-muted small">Down</div>
<div id="downCount" class="summary-value text-danger">0</div>
</div>
<div class="summary-card">
<div class="text-muted small">Unknown</div>
<div id="unknownCount" class="summary-value text-warning">0</div>
</div>
</div>
<div class="card border-0 shadow-sm mb-2 links-filter-card">
<div class="card-body">
<div class="row g-2 align-items-end">
<div class="col-lg-5">
<label for="searchInput" class="form-label small text-muted">Søg</label>
<div class="input-group search-shell">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input id="searchInput" class="form-control" placeholder="Find links hurtigt (navn, URL, host, kategori)" />
<button id="clearSearchBtn" class="btn btn-sm" type="button" title="Ryd søgning"><i class="bi bi-x-lg"></i></button>
</div>
</div>
<div class="col-lg-2 col-md-4">
<label for="statusFilter" class="form-label small text-muted">Status</label>
<select id="statusFilter" class="form-select">
<option value="all">Alle</option>
<option value="ok">OK</option>
<option value="down">Down</option>
<option value="unknown">Unknown</option>
</select>
</div>
<div class="col-lg-3 col-md-4">
<label for="categoryFilter" class="form-label small text-muted">Kategori</label>
<select id="categoryFilter" class="form-select">
<option value="all">Alle kategorier</option>
</select>
</div>
<div class="col-lg-2 col-md-4">
<label class="form-label small text-muted">Visning</label>
<div class="view-toggle" role="group" aria-label="Skift visning">
<button class="btn active" id="cardsViewBtn" type="button"><i class="bi bi-grid-3x2-gap me-1"></i>Kort</button>
<button class="btn" id="listViewBtn" type="button"><i class="bi bi-list-ul me-1"></i>Liste</button>
</div>
</div>
</div>
</div>
</div>
<div class="links-results-meta">
<div id="resultsMeta">Viser 0 links</div>
<div class="results-chip" id="resultsScope">Scope: Alle</div>
</div>
<div class="links-container">
<div id="linksColumns">
<div class="text-muted small">Henter links...</div>
</div>
</div>
</div>
<div class="modal fade" id="linkModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="linkModalTitle">Nyt link</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form id="linkForm" class="row g-3">
<input type="hidden" id="linkId" />
<div class="col-md-8">
<label class="form-label">Navn</label>
<input class="form-control" id="fName" required />
</div>
<div class="col-md-4">
<label class="form-label">Type</label>
<select class="form-select" id="fType" required>
<option value="http">HTTP</option>
<option value="ssh">SSH</option>
<option value="rdp">RDP</option>
<option value="command">Command</option>
</select>
</div>
<div class="col-md-8">
<label class="form-label">URL</label>
<input class="form-control" id="fUrl" placeholder="https://..." />
</div>
<div class="col-md-4">
<label class="form-label">Miljø</label>
<select class="form-select" id="fEnvironment">
<option value="prod">Prod</option>
<option value="test">Test</option>
<option value="dev">Dev</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Host</label>
<input class="form-control" id="fHost" />
</div>
<div class="col-md-3">
<label class="form-label">Port</label>
<input class="form-control" id="fPort" type="number" min="1" max="65535" />
</div>
<div class="col-md-3">
<label class="form-label">Brugernavn</label>
<input class="form-control" id="fUsername" />
</div>
<div class="col-md-6">
<label class="form-label">Vault item ID</label>
<input class="form-control" id="fVaultItemId" placeholder="fx 2f8f6c7d-..." />
</div>
<div class="col-md-6">
<label class="form-label">Vault item IDs (ekstra)</label>
<input class="form-control" id="fVaultItemIds" placeholder="id1, id2, id3" />
<div class="form-text">Kommasepareret liste af fallback item IDs.</div>
</div>
<div class="col-md-4">
<label class="form-label">Kunde ID</label>
<input class="form-control" id="fCustomerId" type="number" min="1" />
</div>
<div class="col-md-4">
<label class="form-label">Sag ID</label>
<input class="form-control" id="fCaseId" type="number" min="1" />
</div>
<div class="col-md-4">
<label class="form-label">Hardware ID</label>
<input class="form-control" id="fHardwareId" type="number" min="1" />
</div>
<div class="col-12">
<label class="form-label">Kategorier</label>
<select class="form-select" id="fCategoryIds" multiple size="6"></select>
</div>
<div class="col-12 d-flex gap-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="fIsCritical" />
<label class="form-check-label" for="fIsCritical">Kritisk</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="fIsFavorite" />
<label class="form-check-label" for="fIsFavorite">Favorit</label>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" id="saveBtn">Gem</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="vaultModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Vault credentials</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="vaultState" class="text-muted">Henter vault-oplysninger...</div>
<div id="vaultContent" class="d-none">
<div class="mb-2">
<label class="form-label small text-muted mb-1">Item</label>
<div class="fw-semibold" id="vaultItemName">-</div>
</div>
<div class="mb-2">
<label class="form-label small text-muted mb-1">Username</label>
<div class="input-group input-group-sm">
<input id="vaultUsername" class="form-control" readonly />
<button class="btn btn-outline-secondary" type="button" onclick="copyVaultField('vaultUsername')">Kopi</button>
</div>
</div>
<div class="mb-2">
<label class="form-label small text-muted mb-1">Password</label>
<div class="input-group input-group-sm">
<input id="vaultPassword" class="form-control" readonly />
<button class="btn btn-outline-secondary" type="button" onclick="copyVaultField('vaultPassword')">Kopi</button>
</div>
</div>
<div class="mb-2">
<label class="form-label small text-muted mb-1">TOTP</label>
<div class="input-group input-group-sm">
<input id="vaultTotp" class="form-control" readonly />
<button class="btn btn-outline-secondary" type="button" onclick="copyVaultField('vaultTotp')">Kopi</button>
</div>
</div>
<div>
<label class="form-label small text-muted mb-1">URL</label>
<input id="vaultUrl" class="form-control form-control-sm" readonly />
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Luk</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
const urlParams = new URLSearchParams(window.location.search);
const customerIdFilter = urlParams.get('customer_id');
const state = {
links: [],
statuses: new Map(),
categories: [],
viewMode: localStorage.getItem('linksViewMode') || 'cards',
};
let linkModal;
let vaultModal;
function authHeaders() {
const token = localStorage.getItem('access_token') || sessionStorage.getItem('access_token');
return token ? { Authorization: `Bearer ${token}` } : {};
}
async function apiGet(path) {
const response = await fetch(path, {
headers: { ...authHeaders() },
credentials: 'include'
});
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
throw new Error('Ingen adgang. Log ind igen eller tjek links.read permission.');
}
if (response.status === 404) {
throw new Error('Links-endpoint ikke fundet. Bekraeft at modulet er aktiveret og API er recreatet.');
}
throw new Error(`HTTP ${response.status}`);
}
return response.json();
}
async function apiRequest(path, method, body) {
const response = await fetch(path, {
method,
headers: {
...authHeaders(),
'Content-Type': 'application/json'
},
credentials: 'include',
body: body ? JSON.stringify(body) : undefined
});
if (!response.ok) {
let detail = `HTTP ${response.status}`;
try {
const payload = await response.json();
if (payload && payload.detail) {
detail = typeof payload.detail === 'string' ? payload.detail : JSON.stringify(payload.detail);
}
} catch (err) {
void err;
}
throw new Error(detail);
}
if (response.status === 204) {
return null;
}
return response.json();
}
function escapeHtml(value) {
const span = document.createElement('span');
span.textContent = value ?? '';
return span.innerHTML;
}
function statusDot(status) {
const value = status || 'unknown';
return `<span class="status-dot ${value}" title="Status: ${value}"></span>`;
}
function resolveTarget(link) {
if (link.type === 'http') return link.url || link.host || '-';
if (link.type === 'command') return link.description || link.url || '-';
const host = link.host || '-';
const port = link.port ? `:${link.port}` : '';
return `${host}${port}`;
}
function buildOpenHref(link) {
if (link.url && String(link.url).trim()) {
return String(link.url).trim();
}
if (link.host && String(link.host).trim()) {
const raw = String(link.host).trim();
if (/^https?:\/\//i.test(raw)) {
return raw;
}
if (link.type === 'http') {
return `http://${raw}`;
}
return `//${raw}`;
}
return '';
}
function formatDate(value) {
if (!value) return '-';
const dt = new Date(value);
if (Number.isNaN(dt.getTime())) return '-';
return dt.toLocaleString('da-DK');
}
function applyFilters() {
const q = (document.getElementById('searchInput').value || '').toLowerCase().trim();
const statusFilter = document.getElementById('statusFilter').value;
const categoryFilter = document.getElementById('categoryFilter').value;
const rows = state.links.filter((link) => {
const statusRow = state.statuses.get(link.id);
const status = statusRow ? statusRow.status : 'unknown';
if (statusFilter !== 'all' && status !== statusFilter) {
return false;
}
if (categoryFilter !== 'all') {
const ids = Array.isArray(link.category_ids) ? link.category_ids.map((v) => String(v)) : [];
if (!ids.includes(String(categoryFilter))) {
return false;
}
}
if (!q) return true;
const categoryNames = resolveCategoryNamesForLink(link).join(' ');
const hay = `${link.name || ''} ${link.url || ''} ${link.host || ''} ${link.environment || ''} ${link.type || ''} ${categoryNames}`.toLowerCase();
return hay.includes(q);
});
renderResultsMeta(rows.length);
renderTable(rows);
}
function renderResultsMeta(filteredCount) {
const total = state.links.length;
const statusFilter = document.getElementById('statusFilter').value;
const categorySelect = document.getElementById('categoryFilter');
const categoryLabel = categorySelect?.selectedOptions?.[0]?.textContent || 'Alle kategorier';
document.getElementById('resultsMeta').textContent = `Viser ${filteredCount} af ${total} links`;
const statusText = statusFilter === 'all' ? 'Alle statusser' : statusFilter.toUpperCase();
document.getElementById('resultsScope').textContent = `Status: ${statusText} · ${categoryLabel}`;
}
function categoryNameForLink(link) {
const ids = Array.isArray(link.category_ids) ? link.category_ids : [];
if (!ids.length) {
return 'Ukategoriseret';
}
const first = state.categories.find((cat) => Number(cat.id) === Number(ids[0]));
return first ? first.name : `Kategori ${ids[0]}`;
}
function resolveCategoryNamesForLink(link) {
const ids = Array.isArray(link.category_ids) ? link.category_ids : [];
if (!ids.length) {
return ['Ukategoriseret'];
}
const names = ids.map((id) => {
const match = state.categories.find((cat) => Number(cat.id) === Number(id));
return match ? String(match.name || '') : `Kategori ${id}`;
}).filter(Boolean);
return names.length ? names : ['Ukategoriseret'];
}
function getTypeIcon(type) {
if (type === 'http') return 'bi-globe2';
if (type === 'ssh') return 'bi-terminal';
if (type === 'rdp') return 'bi-pc-display';
return 'bi-link-45deg';
}
function renderColumns(rows) {
const container = document.getElementById('linksColumns');
if (!rows.length) {
container.innerHTML = '<div class="text-muted text-center py-4">Ingen links fundet.</div>';
return;
}
const groups = new Map();
rows.forEach((link) => {
const category = categoryNameForLink(link);
if (!groups.has(category)) {
groups.set(category, []);
}
groups.get(category).push(link);
});
const sortedGroupNames = Array.from(groups.keys()).sort((a, b) => a.localeCompare(b, 'da-DK'));
container.innerHTML = sortedGroupNames.map((groupName) => {
const items = groups.get(groupName).sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''), 'da-DK'));
const cards = items.map((link) => {
const statusRow = state.statuses.get(link.id);
const hasVault = Boolean(link.vault_item_id) || (Array.isArray(link.vault_item_ids) && link.vault_item_ids.length > 0);
const openHref = buildOpenHref(link);
const canOpen = Boolean(openHref);
const st = statusRow ? statusRow.status : 'unknown';
const typeIcon = getTypeIcon(link.type);
return `
<div class="flame-card">
<div class="flame-icon-wrap">
<i class="bi ${typeIcon}"></i>
<div class="flame-status-dot ${st}" title="Status: ${st}"></div>
</div>
<div class="flame-content">
<div class="flame-title">
${canOpen
? `<a href="${escapeHtml(openHref)}" target="_blank" rel="noopener noreferrer" title="${escapeHtml(link.name || '')}">${escapeHtml(link.name || '-')}</a>`
: `<span>${escapeHtml(link.name || '-')}</span>`}
</div>
<div class="flame-subtitle">
<span>${escapeHtml((link.type || 'http').toUpperCase())}</span>
${link.is_critical ? '<span class="text-danger fw-bold ms-1" title="Kritisk"><i class="bi bi-exclamation-triangle-fill"></i></span>' : ''}
${link.is_favorite ? '<span class="text-warning ms-1" title="Favorit"><i class="bi bi-star-fill"></i></span>' : ''}
</div>
</div>
<div class="flame-actions">
${hasVault ? `<button class="btn btn-sm text-primary" onclick="resolveVault(${link.id}); event.preventDefault();" title="Hent Vault Credentials"><i class="bi bi-shield-lock"></i></button>` : ''}
<button class="btn btn-sm text-secondary" onclick="openEditModal(${link.id}); event.preventDefault();" title="Rediger"><i class="bi bi-pencil"></i></button>
<button class="btn btn-sm text-danger" onclick="deleteLink(${link.id}, '${escapeHtml(link.name || '')}'); event.preventDefault();" title="Slet"><i class="bi bi-trash"></i></button>
</div>
</div>
`;
}).join('');
return `
<div class="flame-category">
<h4 class="flame-category-title">
${escapeHtml(groupName)}
<span class="flame-category-count">${items.length}</span>
</h4>
<div class="flame-grid">
${cards}
</div>
</div>
`;
}).join('');
}
function renderList(rows) {
const container = document.getElementById('linksColumns');
if (!rows.length) {
container.innerHTML = '<div class="text-muted text-center py-4">Ingen links fundet.</div>';
return;
}
const ordered = [...rows].sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''), 'da-DK'));
const rowsHtml = ordered.map((link) => {
const statusRow = state.statuses.get(link.id);
const status = statusRow ? statusRow.status : 'unknown';
const names = resolveCategoryNamesForLink(link);
const category = names[0] || 'Ukategoriseret';
const target = resolveTarget(link);
const openHref = buildOpenHref(link);
const canOpen = Boolean(openHref);
const hasVault = Boolean(link.vault_item_id) || (Array.isArray(link.vault_item_ids) && link.vault_item_ids.length > 0);
const env = String(link.environment || 'prod').toUpperCase();
return `
<tr>
<td>
<div class="d-flex align-items-center gap-2">
<span class="flame-status-dot ${status}" title="Status: ${status}"></span>
${canOpen
? `<a class="list-name" href="${escapeHtml(openHref)}" target="_blank" rel="noopener noreferrer">${escapeHtml(link.name || '-')}</a>`
: `<span class="list-name">${escapeHtml(link.name || '-')}</span>`}
</div>
</td>
<td><span class="mono target-text" title="${escapeHtml(target)}">${escapeHtml(target)}</span></td>
<td><span class="link-env">${escapeHtml(env)}</span></td>
<td><span class="small text-muted">${escapeHtml(category)}</span></td>
<td>${statusPill(status)}</td>
<td class="text-end">
<div class="list-actions">
${hasVault ? `<button class="btn btn-sm btn-outline-primary" onclick="resolveVault(${link.id}); event.preventDefault();" title="Vault"><i class="bi bi-shield-lock"></i></button>` : ''}
<button class="btn btn-sm btn-outline-secondary" onclick="openEditModal(${link.id}); event.preventDefault();" title="Rediger"><i class="bi bi-pencil"></i></button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteLink(${link.id}, '${escapeHtml(link.name || '')}'); event.preventDefault();" title="Slet"><i class="bi bi-trash"></i></button>
</div>
</td>
</tr>
`;
}).join('');
container.innerHTML = `
<div class="links-list-card">
<div class="table-responsive">
<table class="table links-list-table">
<thead>
<tr>
<th>Navn</th>
<th>Target</th>
<th>Miljø</th>
<th>Kategori</th>
<th>Status</th>
<th class="text-end">Handlinger</th>
</tr>
</thead>
<tbody>
${rowsHtml}
</tbody>
</table>
</div>
</div>
`;
}
function statusPill(status) {
if (status === 'ok') return '<span class="status-pill status-ok">OK</span>';
if (status === 'down') return '<span class="status-pill status-down">Down</span>';
return '<span class="status-pill status-unknown">Unknown</span>';
}
function renderTable(rows) {
if (state.viewMode === 'list') {
renderList(rows);
return;
}
renderColumns(rows);
}
function setViewMode(mode) {
state.viewMode = mode === 'list' ? 'list' : 'cards';
localStorage.setItem('linksViewMode', state.viewMode);
document.getElementById('cardsViewBtn').classList.toggle('active', state.viewMode === 'cards');
document.getElementById('listViewBtn').classList.toggle('active', state.viewMode === 'list');
applyFilters();
}
function populateCategoryFilter() {
const select = document.getElementById('categoryFilter');
const previousValue = select.value || 'all';
const options = state.categories
.slice()
.sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''), 'da-DK'))
.map((item) => `<option value="${item.id}">${escapeHtml(item.name)}</option>`)
.join('');
select.innerHTML = `<option value="all">Alle kategorier</option>${options}`;
if (previousValue !== 'all' && Array.from(select.options).some((opt) => opt.value === previousValue)) {
select.value = previousValue;
}
}
function renderSummary() {
const counts = { ok: 0, down: 0, unknown: 0 };
state.links.forEach((link) => {
const status = state.statuses.get(link.id)?.status || 'unknown';
if (status === 'ok') counts.ok += 1;
else if (status === 'down') counts.down += 1;
else counts.unknown += 1;
});
document.getElementById('totalCount').textContent = String(state.links.length);
document.getElementById('okCount').textContent = String(counts.ok);
document.getElementById('downCount').textContent = String(counts.down);
document.getElementById('unknownCount').textContent = String(counts.unknown);
}
async function loadData() {
try {
const linksPath = customerIdFilter
? `/api/v1/links?customer_id=${encodeURIComponent(customerIdFilter)}`
: '/api/v1/links';
const [links, latest] = await Promise.all([
apiGet(linksPath),
apiGet('/api/v1/links/status/latest')
]);
state.links = Array.isArray(links) ? links : [];
state.statuses = new Map((Array.isArray(latest) ? latest : []).map((row) => [row.link_id, row]));
renderSummary();
applyFilters();
} catch (error) {
const container = document.getElementById('linksColumns');
container.innerHTML = `<div class="text-danger py-4 text-center">Kunne ikke indlæse links: ${escapeHtml(error.message || 'ukendt fejl')}</div>`;
}
}
async function loadCategories() {
try {
state.categories = await apiGet('/api/v1/links/categories');
} catch (error) {
state.categories = [];
}
const select = document.getElementById('fCategoryIds');
select.innerHTML = state.categories.map((item) => `<option value="${item.id}">${escapeHtml(item.name)}</option>`).join('');
populateCategoryFilter();
if (state.links.length) {
applyFilters();
}
}
function getFormPayload() {
const toOptionalInt = (value) => {
const raw = String(value || '').trim();
if (!raw) return null;
const parsed = Number(raw);
return Number.isFinite(parsed) ? parsed : null;
};
const selectedCategories = Array.from(document.getElementById('fCategoryIds').selectedOptions).map((o) => Number(o.value));
const vaultItemIds = (document.getElementById('fVaultItemIds').value || '')
.split(',')
.map((item) => item.trim())
.filter((item) => item.length > 0);
return {
name: document.getElementById('fName').value.trim(),
type: document.getElementById('fType').value,
url: document.getElementById('fUrl').value.trim() || null,
host: document.getElementById('fHost').value.trim() || null,
port: toOptionalInt(document.getElementById('fPort').value),
username: document.getElementById('fUsername').value.trim() || null,
vault_item_id: document.getElementById('fVaultItemId').value.trim() || null,
vault_item_ids: vaultItemIds,
customer_id: toOptionalInt(document.getElementById('fCustomerId').value),
case_id: toOptionalInt(document.getElementById('fCaseId').value),
hardware_id: toOptionalInt(document.getElementById('fHardwareId').value),
environment: document.getElementById('fEnvironment').value,
category_ids: selectedCategories,
is_critical: document.getElementById('fIsCritical').checked,
is_favorite: document.getElementById('fIsFavorite').checked,
};
}
function resetFormForCreate() {
document.getElementById('linkModalTitle').textContent = 'Nyt link';
document.getElementById('linkId').value = '';
document.getElementById('linkForm').reset();
document.getElementById('fEnvironment').value = 'prod';
if (customerIdFilter) {
document.getElementById('fCustomerId').value = customerIdFilter;
}
Array.from(document.getElementById('fCategoryIds').options).forEach((opt) => {
opt.selected = false;
});
}
function fillFormForEdit(link) {
document.getElementById('linkModalTitle').textContent = 'Rediger link';
document.getElementById('linkId').value = link.id;
document.getElementById('fName').value = link.name || '';
document.getElementById('fType').value = link.type || 'http';
document.getElementById('fUrl').value = link.url || '';
document.getElementById('fHost').value = link.host || '';
document.getElementById('fPort').value = link.port || '';
document.getElementById('fUsername').value = link.username || '';
document.getElementById('fVaultItemId').value = link.vault_item_id || '';
document.getElementById('fVaultItemIds').value = Array.isArray(link.vault_item_ids) ? link.vault_item_ids.join(', ') : '';
document.getElementById('fCustomerId').value = link.customer_id || '';
document.getElementById('fCaseId').value = link.case_id || '';
document.getElementById('fHardwareId').value = link.hardware_id || '';
document.getElementById('fEnvironment').value = link.environment || 'prod';
document.getElementById('fIsCritical').checked = Boolean(link.is_critical);
document.getElementById('fIsFavorite').checked = Boolean(link.is_favorite);
const selected = new Set(link.category_ids || []);
Array.from(document.getElementById('fCategoryIds').options).forEach((opt) => {
opt.selected = selected.has(Number(opt.value));
});
}
async function saveLink() {
const saveBtn = document.getElementById('saveBtn');
const linkId = document.getElementById('linkId').value;
const payload = getFormPayload();
if (!payload.name) {
alert('Navn er paakraevet.');
return;
}
saveBtn.disabled = true;
saveBtn.textContent = 'Gemmer...';
try {
if (linkId) {
await apiRequest(`/api/v1/links/${encodeURIComponent(linkId)}`, 'PATCH', payload);
} else {
await apiRequest('/api/v1/links', 'POST', payload);
}
linkModal.hide();
await loadData();
} catch (error) {
alert(`Kunne ikke gemme link: ${error.message || 'ukendt fejl'}`);
} finally {
saveBtn.disabled = false;
saveBtn.textContent = 'Gem';
}
}
window.openEditModal = function openEditModal(linkId) {
const row = state.links.find((item) => Number(item.id) === Number(linkId));
if (!row) return;
fillFormForEdit(row);
linkModal.show();
};
window.deleteLink = async function deleteLink(linkId, linkName) {
const confirmed = window.confirm(`Slet link "${linkName || ''}"?`);
if (!confirmed) return;
try {
await apiRequest(`/api/v1/links/${encodeURIComponent(linkId)}`, 'DELETE');
await loadData();
} catch (error) {
alert(`Kunne ikke slette link: ${error.message || 'ukendt fejl'}`);
}
};
function setVaultLoading(message) {
document.getElementById('vaultState').classList.remove('d-none');
document.getElementById('vaultState').textContent = message;
document.getElementById('vaultContent').classList.add('d-none');
}
function showVaultCredential(credential) {
document.getElementById('vaultItemName').textContent = credential.item_name || credential.item_id || '-';
document.getElementById('vaultUsername').value = credential.username || '';
document.getElementById('vaultPassword').value = credential.password || '';
document.getElementById('vaultTotp').value = credential.totp || '';
document.getElementById('vaultUrl').value = credential.url || '';
document.getElementById('vaultState').classList.add('d-none');
document.getElementById('vaultContent').classList.remove('d-none');
}
window.copyVaultField = async function copyVaultField(fieldId) {
const field = document.getElementById(fieldId);
if (!field || !field.value) return;
try {
await navigator.clipboard.writeText(field.value);
} catch (error) {
alert('Kunne ikke kopiere feltet.');
}
};
window.resolveVault = async function resolveVault(linkId) {
setVaultLoading('Henter vault-oplysninger...');
vaultModal.show();
try {
const result = await apiRequest(`/api/v1/links/${encodeURIComponent(linkId)}/vault/resolve`, 'POST', {});
if (result && result.credential) {
showVaultCredential(result.credential);
return;
}
setVaultLoading(result?.message || 'Ingen credentials fundet for dette link.');
} catch (error) {
setVaultLoading(`Vault-opslag fejlede: ${error.message || 'ukendt fejl'}`);
}
};
async function runHealthCheck() {
const btn = document.getElementById('runHealthBtn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span> Kører...';
try {
await fetch('/api/v1/links/health/run', {
method: 'POST',
headers: { ...authHeaders() },
credentials: 'include'
}).then(async (response) => {
if (!response.ok) {
const msg = response.status === 403 ? 'Mangler links.diagnose permission.' : `HTTP ${response.status}`;
throw new Error(msg);
}
});
await loadData();
} catch (error) {
alert(`Health check fejlede: ${error.message || 'ukendt fejl'}`);
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-heart-pulse"></i> Kør health check';
}
}
document.getElementById('searchInput').addEventListener('input', applyFilters);
document.getElementById('statusFilter').addEventListener('change', applyFilters);
document.getElementById('categoryFilter').addEventListener('change', applyFilters);
document.getElementById('clearSearchBtn').addEventListener('click', () => {
document.getElementById('searchInput').value = '';
applyFilters();
document.getElementById('searchInput').focus();
});
document.getElementById('cardsViewBtn').addEventListener('click', () => setViewMode('cards'));
document.getElementById('listViewBtn').addEventListener('click', () => setViewMode('list'));
document.getElementById('refreshBtn').addEventListener('click', loadData);
document.getElementById('runHealthBtn').addEventListener('click', runHealthCheck);
document.getElementById('createBtn').addEventListener('click', () => {
resetFormForCreate();
linkModal.show();
});
document.getElementById('saveBtn').addEventListener('click', saveLink);
linkModal = new bootstrap.Modal(document.getElementById('linkModal'));
vaultModal = new bootstrap.Modal(document.getElementById('vaultModal'));
if (customerIdFilter) {
document.getElementById('scopeHint').textContent = `Filter: Kunde #${customerIdFilter}`;
document.getElementById('resultsScope').textContent = `Scope: Kunde #${customerIdFilter}`;
}
setViewMode(state.viewMode);
Promise.all([loadCategories(), loadData()]);
</script>
{% endblock %}