Two-column layout: org+instances left, services right (collapsible)

This commit is contained in:
2026-07-23 12:00:52 +04:00
parent 2723f922d4
commit 4890777227
2 changed files with 135 additions and 93 deletions
+9 -4
View File
@@ -1,7 +1,7 @@
from flask import Blueprint, current_app, render_template, request, make_response, jsonify from flask import Blueprint, current_app, render_template, request, make_response, jsonify
from api.http_client import HttpClient from api.http_client import HttpClient
from operations.get_instances import get_organization from operations.get_instances import get_organization, get_instances
from operations.get_services import get_services, get_service_detail from operations.get_services import get_services, get_service_detail
bp = Blueprint("main", __name__) bp = Blueprint("main", __name__)
@@ -42,6 +42,7 @@ def index():
return resp return resp
services = [] services = []
instances = []
if active_token: if active_token:
try: try:
client = HttpClient( client = HttpClient(
@@ -49,8 +50,11 @@ def index():
active_token, active_token,
) )
org = get_organization(client) org = get_organization(client)
raw = get_services(client) raw_svc = get_services(client)
services = sorted(raw, key=lambda s: (s.get("svcId", 0), s.get("svc", ""))) services = sorted(raw_svc, key=lambda s: (s.get("svcId", 0), s.get("svc", "")))
instances = get_instances(client)
# org first, then rest
instances.sort(key=lambda i: (0 if i.get("serviceId") == 19 else 1, i.get("displayName", "")))
except Exception as e: except Exception as e:
error = str(e) error = str(e)
@@ -58,7 +62,8 @@ def index():
organization=org, error=error, organization=org, error=error,
env_token_masked=_mask(env_token), env_token_masked=_mask(env_token),
has_user_token=bool(user_token), has_user_token=bool(user_token),
services=services) services=services,
instances=instances)
@bp.route("/api/operations/<int:svc_id>") @bp.route("/api/operations/<int:svc_id>")
+126 -89
View File
@@ -6,107 +6,144 @@
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}" /> <link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}" />
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" /> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" />
<script src="https://unpkg.com/lucide@latest"></script> <script src="https://unpkg.com/lucide@latest"></script>
<style>
.layout { display:flex; gap:16px; max-width:1200px; margin:16px auto; padding:0 16px; }
.left { width:380px; flex-shrink:0; }
.right { flex:1; min-width:0; }
.svc-row { cursor:pointer; }
.inst-row { cursor:default; }
</style>
</head> </head>
<body> <body>
<div class="card" style="max-width: 780px; margin: 32px auto;">
<div class="card-header" style="justify-content: space-between;">
<span>Организация</span>
<form method="post" style="display:flex;align-items:center;gap:6px;">
<input type="password" name="token" placeholder="env: {{ env_token_masked }}" value="{{ '' if not has_user_token else '••••••••' }}" style="height:28px;width:200px;font-size:12px;border:1px solid var(--brand-gray);border-radius:4px;padding:0 6px;" />
<button type="submit" name="action" value="save" class="btn" style="height:28px;font-size:12px;padding:0 8px;">OK</button>
{% if has_user_token %}
<button type="submit" name="action" value="clear" class="btn btn-danger" style="height:28px;font-size:12px;padding:0 8px;">Выйти</button>
{% endif %}
</form>
</div>
<div class="card-body">
{% if error %}
<p style="color: var(--destructive);">{{ error }}</p>
{% elif organization %}
<p>{{ organization.displayName }} — {{ organization.explainedStatus or "OK" }}</p>
{% else %}
<p style="color: var(--muted);">Введите токен</p>
{% endif %}
</div>
</div>
{% if services %} <form method="post" style="max-width:1200px;margin:16px auto 0;padding:0 16px;display:flex;align-items:center;gap:8px;">
<div class="card" style="max-width: 780px; margin: 16px auto;"> <input type="password" name="token" placeholder="env: {{ env_token_masked }}" value="{{ '' if not has_user_token else '••••••••' }}" style="height:28px;width:200px;font-size:12px;border:1px solid var(--brand-gray);border-radius:4px;padding:0 6px;" />
<div class="card-header">Сервисы ({{ services|length }})</div> <button type="submit" name="action" value="save" class="btn" style="height:28px;font-size:12px;padding:0 8px;">OK</button>
<div class="card-body" style="padding: 0;"> {% if has_user_token %}
<table> <button type="submit" name="action" value="clear" class="btn btn-danger" style="height:28px;font-size:12px;padding:0 8px;">Выйти</button>
<thead> {% endif %}
<tr> {% if error %}<span style="color:var(--destructive);font-size:12px;">{{ error }}</span>{% endif %}
<th style="width:50px;">#</th> </form>
<th>Сервис</th>
<th style="width:80px;">Операции</th> <div class="layout">
</tr>
</thead> <!-- ЛЕВАЯ КОЛОНКА -->
<tbody> <div class="left">
{% for s in services %} {% if organization %}
<tr class="svc-row" data-svc-id="{{ s.svcId }}" style="cursor:pointer;"> <div class="card">
<td>{{ s.svcId }}</td> <div class="card-header">Организация</div>
<td>{{ s.svc }}{% if s.svcExtendedName %} — {{ s.svcExtendedName }}{% endif %}</td> <div class="card-body" style="font-size:12px;">
<td style="text-align:center;"> <table>
<span class="ops-count" id="ops-{{ s.svcId }}">?</span> <tr><td style="color:var(--muted);">Имя</td><td>{{ organization.displayName }}</td></tr>
</td> <tr><td style="color:var(--muted);">UID</td><td style="font-family:monospace;font-size:11px;">{{ organization.instanceUid }}</td></tr>
</tr> <tr><td style="color:var(--muted);">Статус</td><td><span class="badge badge-success">{{ organization.explainedStatus or "OK" }}</span></td></tr>
<tr class="ops-detail" id="detail-{{ s.svcId }}" style="display:none;"> </table>
<td colspan="3" style="padding:4px 10px;background:var(--brand-grey-light);"> </div>
<span style="color:var(--muted);font-size:12px;">Загрузка...</span> </div>
</td> {% endif %}
{% if instances %}
<div class="card" style="margin-top:8px;">
<div class="card-header" style="cursor:pointer;" onclick="this.nextElementSibling.style.display=this.nextElementSibling.style.display==='none'?'block':'none'">
Инстансы ({{ instances|length }})
</div>
<div class="card-body" style="padding:0;max-height:300px;overflow-y:auto;">
<table>
{% for i in instances %}
<tr class="inst-row">
<td style="font-size:12px;">{{ i.displayName }}</td>
<td style="font-size:11px;color:var(--muted);">{{ i.svc }}</td>
<td><span class="badge badge-success" style="font-size:10px;">{{ i.explainedStatus or "?" }}</span></td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </table>
</table> </div>
</div> </div>
{% endif %}
</div> </div>
<script> <!-- ПРАВАЯ КОЛОНКА -->
const opsCache = {}; <div class="right">
document.querySelectorAll('.svc-row').forEach(row => { {% if services %}
row.addEventListener('click', async () => { <div class="card">
const svcId = row.dataset.svcId; <div class="card-header" style="cursor:pointer;" onclick="document.getElementById('svc-table').style.display=document.getElementById('svc-table').style.display==='none'?'':'none'">
const detail = document.getElementById('detail-' + svcId); Сервисы ({{ services|length }})
if (detail.style.display === 'table-row') { </div>
detail.style.display = 'none'; <div id="svc-table" style="display:none;">
row.style.background = ''; <div class="card-body" style="padding:0;max-height:60vh;overflow-y:auto;">
return; <table>
} <thead>
document.querySelectorAll('.ops-detail').forEach(d => d.style.display = 'none'); <tr>
document.querySelectorAll('.svc-row').forEach(r => r.style.background = ''); <th style="width:50px;">#</th>
row.style.background = 'var(--brand-grey-light)'; <th>Сервис</th>
detail.style.display = 'table-row'; <th style="width:70px;">Опер.</th>
if (opsCache[svcId]) { </tr>
fillDetail(svcId, opsCache[svcId]); </thead>
return; <tbody>
} {% for s in services %}
try { <tr class="svc-row" data-svc-id="{{ s.svcId }}">
const r = await fetch('/api/operations/' + svcId); <td>{{ s.svcId }}</td>
const d = await r.json(); <td style="font-size:12px;">{{ s.svc }}{% if s.svcExtendedName %} <span style="color:var(--muted);">— {{ s.svcExtendedName }}</span>{% endif %}</td>
opsCache[svcId] = d; <td style="text-align:center;"><span class="ops-count" id="ops-{{ s.svcId }}">?</span></td>
fillDetail(svcId, d); </tr>
} catch(e) { <tr class="ops-detail" id="detail-{{ s.svcId }}" style="display:none;">
detail.cells[0].innerHTML = '<span style=\"color:var(--destructive);font-size:12px;\">Ошибка загрузки</span>'; <td colspan="3" style="padding:4px 10px;background:var(--brand-grey-light);"></td>
} </tr>
}); {% endfor %}
}); </tbody>
</table>
</div>
</div>
</div>
{% endif %}
</div>
function fillDetail(svcId, data) { </div>
<script>
const opsCache = {};
document.querySelectorAll('.svc-row').forEach(row => {
row.addEventListener('click', async () => {
const svcId = row.dataset.svcId;
const detail = document.getElementById('detail-' + svcId); const detail = document.getElementById('detail-' + svcId);
if (data.error) { if (detail.style.display === 'table-row') {
detail.cells[0].innerHTML = '<span style=\"color:var(--destructive);font-size:12px;\">' + data.error + '</span>'; detail.style.display = 'none';
row.style.background = '';
return; return;
} }
const ops = data.operations || []; document.querySelectorAll('.ops-detail').forEach(d => d.style.display = 'none');
document.getElementById('ops-' + svcId).textContent = ops.length; document.querySelectorAll('.svc-row').forEach(r => r.style.background = '');
detail.cells[0].innerHTML = ops.map(o => row.style.background = 'var(--brand-grey-light)';
'<span class=\"badge\" style=\"margin:2px;\">' + o.operation + '</span>' detail.style.display = 'table-row';
).join('') || '<span style=\"color:var(--muted);font-size:12px;\">Нет операций</span>'; if (opsCache[svcId]) {
} fillDetail(svcId, opsCache[svcId]);
</script> return;
{% endif %} }
detail.cells[0].innerHTML = '<span style=\"color:var(--muted);font-size:12px;\">Загрузка...</span>';
try {
const r = await fetch('/api/operations/' + svcId);
const d = await r.json();
opsCache[svcId] = d;
fillDetail(svcId, d);
} catch(e) {
detail.cells[0].innerHTML = '<span style=\"color:var(--destructive);font-size:12px;\">Ошибка</span>';
}
});
});
function fillDetail(svcId, data) {
const detail = document.getElementById('detail-' + svcId);
if (data.error) {
detail.cells[0].innerHTML = '<span style=\"color:var(--destructive);font-size:12px;\">' + data.error + '</span>';
return;
}
const ops = data.operations || [];
document.getElementById('ops-' + svcId).textContent = ops.length;
detail.cells[0].innerHTML = ops.map(o =>
'<span class=\"badge\" style=\"margin:2px;font-size:11px;\">' + o.operation + '</span>'
).join('') || '<span style=\"color:var(--muted);font-size:12px;\">Нет операций</span>';
}
</script>
<script>lucide.createIcons();</script>
</body> </body>
</html> </html>