update seznamu sluzeb

This commit is contained in:
JiriUhlir
2026-05-29 12:07:13 +02:00
parent ed194093e6
commit 1ade865df4
3 changed files with 373 additions and 58 deletions
+80 -58
View File
@@ -45,6 +45,28 @@ def render_health_status(status: str | None) -> str:
return f'<span class="{class_name}">{html.escape(labels.get(normalized, value or "neznámá"))}</span>' return f'<span class="{class_name}">{html.escape(labels.get(normalized, value or "neznámá"))}</span>'
def render_health_dot(status: str | None, checked_at: str | None) -> str:
normalized = (status or "").lower()
labels = {
"healthy": "zdravá",
"unhealthy": "nezdravá",
"unreachable": "nedostupná",
}
class_name = "health-dot health-dot-muted"
if normalized == "healthy":
class_name = "health-dot health-dot-success"
elif normalized == "unhealthy":
class_name = "health-dot health-dot-warning"
elif normalized == "unreachable":
class_name = "health-dot health-dot-danger"
label = labels.get(normalized, "zdraví neznámé")
title = label
if checked_at:
title = f"{label}, poslední kontrola: {checked_at}"
return f'<span class="{class_name}" title="{html.escape(title)}" aria-label="{html.escape(title)}"></span>'
@router.get("/") @router.get("/")
def portal_home(user=Depends(require_user)): def portal_home(user=Depends(require_user)):
return RedirectResponse(url="/portal/operations", status_code=303) return RedirectResponse(url="/portal/operations", status_code=303)
@@ -94,67 +116,63 @@ def apps_page(
rows = "" rows = ""
for item in apps: for index, item in enumerate(apps, start=1):
app_id = html.escape(item.get("id", "")) app_id = html.escape(item.get("id", ""))
app_url_id = quote(item.get("id", ""), safe="") app_url_id = quote(item.get("id", ""), safe="")
resource_modal_id = f"resources-{page_number}-{index}"
status = html.escape(item.get("status", "")) status = html.escape(item.get("status", ""))
health = latest_health.get(item.get("id", "")) or {} health = latest_health.get(item.get("id", "")) or {}
health_status = render_health_status(health.get("status"))
health_checked_at = html.escape(health.get("checked_at", "") or "") health_checked_at = html.escape(health.get("checked_at", "") or "")
health_dot = render_health_dot(health.get("status"), health.get("checked_at"))
docs = html.escape(item.get("docs", f"/apps/{app_id}/docs")) docs = html.escape(item.get("docs", f"/apps/{app_id}/docs"))
memory = item.get("memory", "") memory = html.escape(item.get("memory", "") or "")
cpus = item.get("cpus", "") cpus = html.escape(item.get("cpus", "") or "")
memory_label = memory or "výchozí"
cpus_label = cpus or "výchozí"
http_clone = html.escape(f"git clone {gitea_url}/{gitea_org}/{app_id}.git") http_clone = html.escape(f"git clone {gitea_url}/{gitea_org}/{app_id}.git")
ssh_clone = html.escape(f"git clone ssh://git@{host}:2222/{gitea_org}/{app_id}.git") ssh_clone = html.escape(f"git clone ssh://git@{host}:2222/{gitea_org}/{app_id}.git")
memory_options = ""
for value, label in [
("", "Výchozí"),
("256m", "256 MB - malá služba"),
("512m", "512 MB - běžná služba"),
("1g", "1 GB - větší služba"),
("2g", "2 GB - náročná služba"),
]:
selected = "selected" if memory == value else ""
memory_options += f'<option value="{value}" {selected}>{label}</option>'
cpu_options = ""
for value, label in [
("", "Výchozí"),
("0.25", "0,25 CPU - velmi malá služba"),
("0.50", "0,50 CPU - běžná služba"),
("1.00", "1 CPU - celé jádro"),
("2.00", "2 CPU - náročná služba"),
]:
selected = "selected" if cpus == value else ""
cpu_options += f'<option value="{value}" {selected}>{label}</option>'
rows += f""" rows += f"""
<tr> <tr class="service-row">
<td> <td class="service-name-cell">
<strong>{app_id}</strong><br> <div class="service-title">
<span class="muted">/apps/{app_id}</span> {health_dot}
<div>
<strong>{app_id}</strong><br>
<span class="muted">/apps/{app_id}</span>
</div>
</div>
</td> </td>
<td><span class="pill">{status}</span></td> <td><span class="pill">{status}</span></td>
<td>{health_status}<br><span class="muted">{health_checked_at}</span></td> <td><a href="{docs}" target="_blank" rel="noopener">Swagger</a></td>
<td><a href="{docs}">Swagger</a></td>
<td> <td>
<form method="post" action="/portal/update-resources"> <div class="resource-summary">
<input type="hidden" name="app_id" value="{app_id}"> <span>Paměť: <strong>{memory_label}</strong></span>
<div class="inline-form"> <span>CPU: <strong>{cpus_label}</strong></span>
<select name="memory">{memory_options}</select> <button type="button" class="btn btn-compact btn-secondary" onclick="openResourceModal('{resource_modal_id}')">Upravit</button>
<select name="cpus">{cpu_options}</select> </div>
<button type="submit">Použít</button> <dialog class="resource-modal" id="{resource_modal_id}">
</div> <form method="post" action="/portal/update-resources">
<div class="resource-help"> <input type="hidden" name="app_id" value="{app_id}">
Paměť je limit RAM. CPU určuje maximální podíl výpočetního výkonu. <div class="modal-header">
Příklad: 0,50 = polovina jádra, 1,00 = celé jádro. <h3>Prostředky služby</h3>
</div> <button type="button" class="icon-button" onclick="closeResourceModal('{resource_modal_id}')" aria-label="Zavřít">×</button>
</form> </div>
<p class="muted">Paměť je limit RAM. CPU určuje maximální podíl výpočetního výkonu. Příklad: 0,50 = polovina jádra, 1,00 = celé jádro.</p>
<label>Paměť</label>
<input name="memory" value="{memory}" placeholder="např. 512m, 1g nebo prázdné pro výchozí">
<label>CPU</label>
<input name="cpus" value="{cpus}" placeholder="např. 0.50, 1.00 nebo prázdné pro výchozí">
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeResourceModal('{resource_modal_id}')">Zrušit</button>
<button type="submit">Použít</button>
</div>
</form>
</dialog>
</td> </td>
<td> <td>
<details> <details class="clone-details">
<summary>Příkazy pro klonování</summary> <summary>Příkazy pro klonování</summary>
<label class="muted">HTTP</label> <label class="muted">HTTP</label>
<div class="cmd-row"> <div class="cmd-row">
@@ -169,22 +187,27 @@ def apps_page(
</div> </div>
</details> </details>
</td> </td>
<td> <td class="actions-cell service-actions">
<p><a class="btn" href="/portal/apps/{app_url_id}">Detail</a></p> <a class="btn" href="/portal/apps/{app_url_id}">Detail</a>
<p><a class="btn btn-secondary" href="/portal/deployments?app_id={app_url_id}">Nasazení</a></p> <a class="btn btn-secondary" href="/portal/deployments?app_id={app_url_id}">Nasazení</a>
<form method="post" action="/portal/apps/{app_url_id}/redeploy" onsubmit="return confirm('Spustit nové nasazení služby {app_id}?');"> <details class="action-menu">
<button type="submit" class="btn-secondary">Nasadit znovu</button> <summary aria-label="Další akce">...</summary>
</form> <div class="action-menu-panel">
<form method="post" action="/portal/delete-app" onsubmit="return confirm('Smazat {app_id}? Tím se odstraní kontejner, image, workspace, záznam v katalogu a Gitea repozitář.');"> <form method="post" action="/portal/apps/{app_url_id}/redeploy" onsubmit="return confirm('Spustit nové nasazení služby {app_id}?');">
<input type="hidden" name="app_id" value="{app_id}"> <button type="submit" class="menu-action">Nasadit znovu</button>
<button type="submit" class="danger">Smazat</button> </form>
</form> <form method="post" action="/portal/delete-app" onsubmit="return confirm('Smazat {app_id}? Tím se odstraní kontejner, image, workspace, záznam v katalogu a Gitea repozitář.');">
<input type="hidden" name="app_id" value="{app_id}">
<button type="submit" class="menu-action menu-action-danger">Smazat</button>
</form>
</div>
</details>
</td> </td>
</tr> </tr>
""" """
if not rows: if not rows:
rows = '<tr><td colspan="7">Zatím nejsou nasazené žádné služby.</td></tr>' rows = '<tr><td colspan="6">Zatím nejsou nasazené žádné služby.</td></tr>'
status_options = ['<option value="">Všechny stavy</option>'] status_options = ['<option value="">Všechny stavy</option>']
for value in status_values: for value in status_values:
selected = " selected" if selected_status == value else "" selected = " selected" if selected_status == value else ""
@@ -265,9 +288,8 @@ def apps_page(
<tr> <tr>
<th>Služba</th> <th>Služba</th>
<th>Status</th> <th>Status</th>
<th>Zdraví</th>
<th>Dokumentace</th> <th>Dokumentace</th>
<th>Prostředky</th> <th>Prostředky <span class="info-dot" title="Paměť je limit RAM. CPU určuje maximální podíl výpočetního výkonu. Příklad: 0,50 = polovina jádra, 1,00 = celé jádro.">i</span></th>
<th>Git</th> <th>Git</th>
<th>Akce</th> <th>Akce</th>
</tr> </tr>
+35
View File
@@ -82,3 +82,38 @@ function copyText(button) {
.catch(() => showCopied(button, false)); .catch(() => showCopied(button, false));
return false; return false;
} }
function openResourceModal(id) {
const modal = document.getElementById(id);
if (!modal) {
return false;
}
if (typeof modal.showModal === "function") {
modal.showModal();
} else {
modal.setAttribute("open", "");
}
return false;
}
function closeResourceModal(id) {
const modal = document.getElementById(id);
if (!modal) {
return false;
}
if (typeof modal.close === "function") {
modal.close();
} else {
modal.removeAttribute("open");
}
return false;
}
document.addEventListener("click", (event) => {
const modal = event.target;
if (typeof HTMLDialogElement !== "undefined" && modal instanceof HTMLDialogElement && modal.classList.contains("resource-modal")) {
modal.close();
}
});
+258
View File
@@ -397,6 +397,51 @@ button:hover,
color: var(--muted); color: var(--muted);
} }
.health-dot {
display: inline-block;
width: 11px;
height: 11px;
border-radius: 999px;
background: var(--muted);
box-shadow: 0 0 0 4px rgba(98, 121, 135, 0.14);
flex: 0 0 auto;
}
.health-dot-success {
background: var(--success);
box-shadow: 0 0 0 4px var(--success-bg);
}
.health-dot-warning {
background: var(--warning);
box-shadow: 0 0 0 4px var(--warning-bg);
}
.health-dot-danger {
background: var(--danger);
box-shadow: 0 0 0 4px var(--danger-bg);
}
.health-dot-muted {
background: var(--muted);
}
.info-dot {
display: inline-flex;
width: 18px;
height: 18px;
margin-left: 4px;
align-items: center;
justify-content: center;
border: 1px solid var(--muted);
border-radius: 999px;
color: var(--muted);
font-size: 12px;
font-weight: 800;
line-height: 1;
text-transform: none;
}
input, input,
select { select {
padding: 8px; padding: 8px;
@@ -447,6 +492,192 @@ input[readonly] {
margin: 0 0 8px; margin: 0 0 8px;
} }
.service-row td {
vertical-align: middle;
}
.service-name-cell {
min-width: 260px;
}
.service-title {
display: flex;
align-items: center;
gap: 12px;
}
.resource-summary {
display: grid;
grid-template-columns: 1fr;
gap: 5px;
min-width: 170px;
}
.resource-summary span {
color: var(--muted);
font-size: 13px;
}
.resource-summary strong {
color: var(--text);
}
.btn-compact {
width: fit-content;
min-height: 30px;
padding: 6px 10px;
font-size: 13px;
}
.resource-modal {
width: min(520px, calc(100vw - 32px));
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
color: var(--text);
box-shadow: 0 18px 48px rgba(10, 31, 42, 0.26);
}
.resource-modal::backdrop {
background: rgba(10, 31, 42, 0.34);
}
.resource-modal form {
display: grid;
gap: 10px;
margin: 0;
}
.resource-modal h3 {
margin: 0;
color: var(--secondary);
}
.resource-modal label {
font-weight: 800;
color: var(--secondary);
}
.resource-modal input {
width: 100%;
}
.modal-header,
.modal-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.modal-actions {
justify-content: flex-end;
margin-top: 8px;
}
.icon-button {
width: 34px;
min-height: 34px;
padding: 0;
border: 1px solid var(--border);
background: white;
color: var(--secondary);
font-size: 22px;
line-height: 1;
}
.icon-button:hover {
background: #eef6f9;
color: var(--secondary-dark);
}
.clone-details {
min-width: 170px;
}
.clone-details summary {
color: var(--text);
}
.service-actions {
display: flex;
align-items: center;
gap: 8px;
width: auto;
min-width: 250px;
}
.service-actions .btn {
margin: 0;
}
.action-menu {
position: relative;
}
.action-menu summary {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
min-height: 34px;
border-radius: 8px;
color: var(--secondary);
font-weight: 900;
list-style: none;
cursor: pointer;
}
.action-menu summary::-webkit-details-marker {
display: none;
}
.action-menu summary:hover,
.action-menu[open] summary {
background: #eef6f9;
text-decoration: none;
}
.action-menu-panel {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 12;
width: 210px;
padding: 6px;
border: 1px solid var(--border);
border-radius: 8px;
background: white;
box-shadow: 0 16px 34px rgba(10, 31, 42, 0.18);
}
.action-menu-panel form {
margin: 0;
}
.menu-action {
width: 100%;
padding: 10px 12px;
background: white;
color: var(--text);
text-align: left;
font-weight: 700;
}
.menu-action:hover {
background: #eef6f9;
color: var(--secondary-dark);
}
.menu-action-danger {
color: var(--danger);
}
.menu-action-danger:hover {
background: var(--danger-bg);
color: var(--danger-dark);
}
.running-row td { .running-row td {
background: var(--warning-bg); background: var(--warning-bg);
} }
@@ -640,6 +871,33 @@ pre {
width: 100%; width: 100%;
} }
.service-title {
align-items: flex-start;
}
.service-actions {
align-items: stretch;
flex-direction: column;
min-width: 180px;
}
.service-actions .btn,
.service-actions .action-menu,
.action-menu summary {
width: 100%;
}
.action-menu-panel {
position: static;
width: 100%;
margin-top: 6px;
}
.modal-actions {
align-items: stretch;
flex-direction: column-reverse;
}
.pagination { .pagination {
align-items: flex-start; align-items: flex-start;
flex-direction: column; flex-direction: column;