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 {
|
|
|
|
|
color: #0f5132;
|
|
|
|
|
background: #d1e7dd;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.status-down {
|
|
|
|
|
color: #842029;
|
|
|
|
|
background: #f8d7da;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.status-unknown {
|
|
|
|
|
color: #664d03;
|
|
|
|
|
background: #fff3cd;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.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);
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
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 %}
|
|
|
|
|
<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}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
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
|
|
|
|
|
? `<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('');
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
|
|
? `<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>';
|
|
|
|
|
}
|
|
|
|
|
|
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) {
|
|
|
|
|
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);
|
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 %}
|