2026-03-30 07:50:15 +02:00
{% extends "shared/frontend/base.html" %}
2026-04-01 21:34:58 +02:00
{% 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;
}
2026-05-02 11:02:29 +02:00
.links-summary .summary-card {
box-shadow: 0 4px 14px rgba(2, 32, 71, 0.06);
}
2026-04-01 21:34:58 +02:00
.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 {
2026-10-08 08:58:02 +02:00
color: var(--theme-success-text, #0f5132);
background: var(--theme-success-bg, #d1e7dd);
2026-04-01 21:34:58 +02:00
}
.status-down {
2026-10-08 08:58:02 +02:00
color: var(--theme-danger-text, #842029);
background: var(--theme-danger-bg, #f8d7da);
2026-04-01 21:34:58 +02:00
}
.status-unknown {
2026-10-08 08:58:02 +02:00
color: var(--theme-warning-text, #664d03);
background: var(--theme-warning-bg, #fff3cd);
2026-04-01 21:34:58 +02:00
}
.links-container {
padding-bottom: 0.75rem;
}
2026-05-02 11:02:29 +02:00
.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;
}
2026-04-01 21:34:58 +02:00
.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);
2026-10-08 08:58:02 +02:00
color: var(--theme-primary-text, #0f4c75);
2026-04-01 21:34:58 +02:00
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;
}
2026-05-02 11:02:29 +02:00
.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;
}
2026-04-01 21:34:58 +02:00
.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;
}
2026-05-02 11:02:29 +02:00
.links-results-meta {
flex-direction: column;
align-items: flex-start;
}
2026-04-01 21:34:58 +02:00
}
< / style >
{% endblock %}
2026-03-30 07:50:15 +02:00
{% block content %}
2026-04-01 21:34:58 +02:00
< 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 >
2026-03-30 07:50:15 +02:00
< / div >
< / div >
2026-04-01 21:34:58 +02:00
< div class = "card border-0 shadow-sm mb-2 links-filter-card" >
2026-03-30 07:50:15 +02:00
< div class = "card-body" >
2026-04-01 21:34:58 +02:00
< div class = "row g-2 align-items-end" >
2026-05-02 11:02:29 +02:00
< div class = "col-lg-5" >
2026-04-01 21:34:58 +02:00
< 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 >
2026-05-02 11:02:29 +02:00
< div class = "col-lg-2 col-md-4" >
2026-04-01 21:34:58 +02:00
< 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 >
2026-05-02 11:02:29 +02:00
< 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 >
2026-04-01 21:34:58 +02:00
< / div >
< / div >
< / div >
2026-05-02 11:02:29 +02:00
< div class = "links-results-meta" >
< div id = "resultsMeta" > Viser 0 links< / div >
< div class = "results-chip" id = "resultsScope" > Scope: Alle< / div >
< / div >
2026-04-01 21:34:58 +02:00
< 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 >
2026-03-30 07:50:15 +02:00
< / div >
< / div >
< / div >
2026-04-01 21:34:58 +02:00
< 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 %}
2026-10-08 08:58:02 +02:00
< script src = "/static/js/link-targets.js?v=20261005-1" > < / script >
2026-04-01 21:34:58 +02:00
< script >
const urlParams = new URLSearchParams(window.location.search);
const customerIdFilter = urlParams.get('customer_id');
const state = {
links: [],
statuses: new Map(),
categories: [],
2026-05-02 11:02:29 +02:00
viewMode: localStorage.getItem('linksViewMode') || 'cards',
2026-04-01 21:34:58 +02:00
};
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}`;
}
2026-10-08 08:58:02 +02:00
function buildOpenHref(link) { return LinkTargets.href(link); }
window.copySshCommand = async id => {
const link=state.links.find(row=>Number(row.id)===Number(id));
const target=link & & LinkTargets.sshTarget(link);
if(!target)return;
try { await navigator.clipboard.writeText(target.command); }
catch { prompt('Kopiér SSH-kommandoen og kør den i din terminal:',target.command); }
};
2026-04-01 21:34:58 +02:00
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;
2026-05-02 11:02:29 +02:00
const categoryFilter = document.getElementById('categoryFilter').value;
2026-04-01 21:34:58 +02:00
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;
}
2026-05-02 11:02:29 +02:00
if (categoryFilter !== 'all') {
const ids = Array.isArray(link.category_ids) ? link.category_ids.map((v) => String(v)) : [];
if (!ids.includes(String(categoryFilter))) {
return false;
}
}
2026-04-01 21:34:58 +02:00
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);
});
2026-05-02 11:02:29 +02:00
renderResultsMeta(rows.length);
2026-04-01 21:34:58 +02:00
renderTable(rows);
}
2026-05-02 11:02:29 +02:00
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}`;
}
2026-04-01 21:34:58 +02:00
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
2026-10-08 08:58:02 +02:00
? `< a href = "${escapeHtml(openHref)}" $ { link . type = == ' ssh ' ? ' title = "Åbn i din SSH-klient (kræver en installeret SSH-linkhandler)" ' : ' target = "_blank" rel = "noopener noreferrer" ' } > ${escapeHtml(link.name || '-')}< / a > `
2026-04-01 21:34:58 +02:00
: `< 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" >
2026-10-08 08:58:02 +02:00
${link.type === 'ssh' & & LinkTargets.sshTarget(link) ? `< button class = "btn btn-sm btn-outline-secondary" onclick = "copySshCommand(${link.id}); event.preventDefault();" title = "Kopiér SSH-kommando" > < i class = "bi bi-copy" > < / i > < / button > ` : ''}
2026-04-01 21:34:58 +02:00
${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('');
}
2026-05-02 11:02:29 +02:00
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
2026-10-08 08:58:02 +02:00
? `< a class = "list-name" href = "${escapeHtml(openHref)}" $ { link . type = == ' ssh ' ? ' title = "Åbn i din SSH-klient (kræver en installeret SSH-linkhandler)" ' : ' target = "_blank" rel = "noopener noreferrer" ' } > ${escapeHtml(link.name || '-')}< / a > `
2026-05-02 11:02:29 +02:00
: `< 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" >
2026-10-08 08:58:02 +02:00
${link.type === 'ssh' & & LinkTargets.sshTarget(link) ? `< button class = "btn btn-sm btn-outline-secondary" onclick = "copySshCommand(${link.id}); event.preventDefault();" title = "Kopiér SSH-kommando" > < i class = "bi bi-copy" > < / i > < / button > ` : ''}
2026-05-02 11:02:29 +02:00
${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 > ';
}
2026-04-01 21:34:58 +02:00
function renderTable(rows) {
2026-05-02 11:02:29 +02:00
if (state.viewMode === 'list') {
renderList(rows);
return;
}
2026-04-01 21:34:58 +02:00
renderColumns(rows);
}
2026-05-02 11:02:29 +02:00
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;
}
}
2026-04-01 21:34:58 +02:00
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('');
2026-05-02 11:02:29 +02:00
populateCategoryFilter();
2026-04-01 21:34:58 +02:00
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) {
2026-08-17 19:27:43 +02:00
alert('Navn er påkrævet.');
2026-04-01 21:34:58 +02:00
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);
2026-05-02 11:02:29 +02:00
document.getElementById('categoryFilter').addEventListener('change', applyFilters);
2026-04-01 21:34:58 +02:00
document.getElementById('clearSearchBtn').addEventListener('click', () => {
document.getElementById('searchInput').value = '';
applyFilters();
document.getElementById('searchInput').focus();
});
2026-05-02 11:02:29 +02:00
document.getElementById('cardsViewBtn').addEventListener('click', () => setViewMode('cards'));
document.getElementById('listViewBtn').addEventListener('click', () => setViewMode('list'));
2026-04-01 21:34:58 +02:00
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}`;
2026-05-02 11:02:29 +02:00
document.getElementById('resultsScope').textContent = `Scope: Kunde #${customerIdFilter}`;
2026-04-01 21:34:58 +02:00
}
2026-05-02 11:02:29 +02:00
setViewMode(state.viewMode);
2026-04-01 21:34:58 +02:00
Promise.all([loadCategories(), loadData()]);
< / script >
2026-03-30 07:50:15 +02:00
{% endblock %}