Clarify operator UI workflow

This commit is contained in:
Emil
2026-07-23 22:41:14 +03:00
parent 5be87ad762
commit ac9b921401
6 changed files with 316 additions and 5 deletions
@@ -140,7 +140,7 @@ func TestUIRequiresDistinctCredentialAndRendersDashboard(t *testing.T) {
t.Fatalf("UI status: %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "SciMesh operator UI") {
if !strings.Contains(string(body), "Панель оператора SciMesh") {
t.Errorf("dashboard body missing title")
}
}
@@ -1 +1,57 @@
{{define "dashboard.html"}}<!doctype html><html><head><meta charset="utf-8"><title>SciMesh</title><style>body{font:16px system-ui;margin:2rem;max-width:1100px}table{border-collapse:collapse;width:100%;margin:1rem 0}td,th{padding:.5rem;border-bottom:1px solid #ddd;text-align:left}.badge{padding:.2rem .5rem;background:#eef;border-radius:.4rem}a.button{display:inline-block;padding:.6rem .9rem;background:#173b66;color:#fff;border-radius:.3rem;text-decoration:none}</style></head><body><h1>SciMesh operator UI</h1><p><span class="badge">Pipeline diagnostics</span> Partial artifacts are not final scientific results until a reducer is implemented.</p><a class="button" href="/ui/jobs/new">New similarity-search check</a><h2>Recent jobs</h2><table><tr><th>ID</th><th>Workload</th><th>Status</th><th>Progress</th><th>Created</th></tr>{{range .Jobs}}<tr><td><a href="/ui/jobs/{{.ID}}">{{.ID}}</a></td><td>{{.Workload}}</td><td>{{.Status}}</td><td>{{.Completed}} / {{.Total}}</td><td>{{time .CreatedAt}}</td></tr>{{else}}<tr><td colspan="5">No jobs yet.</td></tr>{{end}}</table><h2>Workers</h2><table><tr><th>Name</th><th>Status</th><th>Capabilities</th><th>Last heartbeat</th></tr>{{range .Workers}}<tr><td>{{.Name}}</td><td>{{.Status}}</td><td>{{range .Capabilities}}<code>{{.}}</code> {{end}}</td><td>{{time .LastHeartbeatAt}}</td></tr>{{else}}<tr><td colspan="4">No workers registered.</td></tr>{{end}}</table></body></html>{{end}}
{{define "dashboard.html"}}
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SciMesh — панель оператора</title>
<style>
:root{color:#172033;background:#f6f8fc;font:16px/1.5 system-ui,sans-serif}body{margin:0}.page{max-width:1180px;margin:auto;padding:32px 20px 56px}.top{display:flex;justify-content:space-between;gap:24px;align-items:start}.eyebrow{margin:0;color:#50617d;font-size:.86rem;font-weight:700;text-transform:uppercase;letter-spacing:.08em}h1{margin:.2rem 0;font-size:2rem}h2{margin:32px 0 12px;font-size:1.28rem}.lead{margin:0;color:#56657c}.button{display:inline-block;border:0;border-radius:8px;padding:11px 15px;background:#1f5eff;color:#fff;font-weight:700;text-decoration:none;white-space:nowrap}.notice{margin-top:24px;padding:16px 18px;border:1px solid #f2cb72;border-radius:10px;background:#fff8e6}.notice strong{display:block}.steps{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-top:14px}.step,.card{padding:16px;border:1px solid #dfe5f0;border-radius:10px;background:#fff}.step b{display:block;color:#1f5eff}.table-wrap{overflow-x:auto;background:#fff;border:1px solid #dfe5f0;border-radius:10px}table{width:100%;border-collapse:collapse}td,th{padding:13px 14px;border-bottom:1px solid #e8ecf4;text-align:left;vertical-align:top}th{color:#50617d;font-size:.78rem;text-transform:uppercase;letter-spacing:.06em}tr:last-child td{border:0}a{color:#174ecf}small,.muted{color:#68758b}.status{display:inline-block;border-radius:999px;padding:3px 9px;font-size:.84rem;font-weight:700}.status-success{background:#dff6e9;color:#126b3d}.status-danger{background:#ffe4e6;color:#a31135}.status-active{background:#e4edff;color:#174ecf}.status-waiting{background:#edf0f5;color:#50617d}.bar{height:7px;min-width:120px;margin-top:7px;overflow:hidden;border-radius:999px;background:#e6eaf1}.bar>span{display:block;height:100%;background:#1f5eff}.kicker{font-variant-numeric:tabular-nums}.empty{padding:28px;text-align:center;color:#68758b}.worker{display:grid;grid-template-columns:1.3fr .8fr 2fr 1fr;gap:12px;align-items:center}.worker+.worker{border-top:1px solid #e8ecf4;padding-top:12px;margin-top:12px}@media(max-width:760px){.top,.steps{display:block}.button{margin-top:12px}.step{margin-top:10px}.worker{grid-template-columns:1fr}.hide-mobile{display:none}}
</style>
</head>
<body>
<main class="page">
<header class="top">
<div>
<p class="eyebrow">Локальный coordinator</p>
<h1>Панель оператора SciMesh</h1>
<p class="lead">Здесь видно, где находится вычисление и что нужно сделать дальше.</p>
</div>
<a class="button" href="/ui/jobs/new">Запустить проверку</a>
</header>
<section class="notice" aria-label="Ограничение текущего pipeline">
<strong>Текущий этап — диагностика shard-задач.</strong>
<span>Worker загружает частичные CSV в coordinator. Пока reducer не реализован, эти файлы не являются единым финальным научным результатом.</span>
<div class="steps"><div class="step"><b>1. Загрузить TSV</b>Coordinator делит файл на shard-задачи.</div><div class="step"><b>2. Дождаться worker</b>Worker берёт shard, вычисляет сходство и возвращает CSV.</div><div class="step"><b>3. Проверить артефакты</b>Скачайте частичный результат из карточки задачи.</div></div>
</section>
<h2>Последние задания</h2>
<div class="table-wrap">
<table>
<tr><th>Вычисление</th><th>Состояние</th><th>Прогресс</th><th class="hide-mobile">Создано</th></tr>
{{range .Jobs}}
<tr>
<td><a href="/ui/jobs/{{.ID}}"><strong>{{workloadLabel .Workload}}</strong></a><br><small>Открыть детали задачи</small></td>
<td><span class="status status-{{statusClass .Status}}">{{statusLabel .Status}}</span><br><small>{{statusHint .Status}}</small></td>
<td class="kicker"><strong>{{.Completed}} / {{.Total}}</strong> завершено{{if gt .Failed 0}} · <span style="color:#a31135">ошибок: {{.Failed}}</span>{{end}}<div class="bar"><span style="width:{{progressPercent .Completed .Failed .Total}}%"></span></div></td>
<td class="hide-mobile"><small>{{time .CreatedAt}}</small></td>
</tr>
{{else}}
<tr><td colspan="4" class="empty"><strong>Заданий пока нет.</strong><br>Нажмите «Запустить проверку», загрузите небольшой TSV и оставьте worker запущенным.</td></tr>
{{end}}
</table>
</div>
<h2>Workers</h2>
<section class="card">
{{range .Workers}}
<div class="worker"><div><strong>{{.Name}}</strong><br><small>{{.ID}}</small></div><div><span class="status status-{{if eq .Status "online"}}success{{else}}waiting{{end}}">{{workerStatusLabel .Status}}</span></div><div>{{range .Capabilities}}<code>{{.}}</code> {{end}}</div><div class="muted">Последний сигнал<br>{{time .LastHeartbeatAt}}</div></div>
{{else}}
<div class="empty"><strong>Worker ещё не зарегистрирован.</strong><br>Запустите <code>scimesh-worker</code> с URL coordinator и worker token.</div>
{{end}}
</section>
</main>
</body>
</html>
{{end}}
@@ -1 +1,42 @@
{{define "job.html"}}<!doctype html><html><head><meta charset="utf-8"><title>SciMesh job</title><style>body{font:16px system-ui;margin:2rem;max-width:1100px}table{border-collapse:collapse;width:100%;margin:1rem 0}td,th{padding:.5rem;border-bottom:1px solid #ddd;text-align:left}.warn{background:#fff4d6;padding:.7rem}</style></head><body><p><a href="/ui">← Dashboard</a></p><h1>Job {{.ID}}</h1><p class="warn">Pipeline check: downloaded <code>partial_result</code> files are diagnostic, not final reduced outputs.</p><dl><dt>Workload</dt><dd>{{.Workload}}</dd><dt>Status</dt><dd id="status">{{.Status}}</dd><dt>Progress</dt><dd id="progress">{{.Completed}} / {{.Total}} completed; {{.Failed}} failed</dd></dl><h2>Tasks</h2><table><tr><th>Chunk</th><th>Status</th><th>Attempt</th><th>Lease</th><th>Error</th></tr>{{range .Tasks}}<tr><td>{{.ChunkIndex}}</td><td>{{.Status}}</td><td>{{.Attempt}} / {{.MaxAttempts}}</td><td>{{.LeaseOwner}} {{if .LeaseExpiresAt}}until {{time .LeaseExpiresAt}}{{end}}</td><td>{{.ErrorCode}} {{.ErrorMessage}}</td></tr>{{end}}</table><h2>Coordinator artifacts</h2><table><tr><th>Kind</th><th>File</th><th>Size</th><th>Checksum</th><th></th></tr>{{range .Artifacts}}<tr><td>{{.Kind}}{{if .Diagnostic}} (diagnostic){{end}}</td><td>{{.Filename}}</td><td>{{.SizeBytes}}</td><td><code>{{.SHA256}}</code></td><td>{{if .Downloadable}}<a href="/ui/jobs/{{$.ID}}/artifacts/{{.ID}}">Download</a>{{end}}</td></tr>{{end}}</table><script>const id={{printf "%q" .ID}};setInterval(async()=>{try{const r=await fetch('/ui/api/jobs/'+id);if(!r.ok)return;const d=await r.json();document.querySelector('#status').textContent=d.status;document.querySelector('#progress').textContent=d.completed+' / '+d.total+' completed; '+d.failed+' failed'}catch(_){ }},2000)</script></body></html>{{end}}
{{define "job.html"}}
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Задача SciMesh</title>
<style>
:root{color:#172033;background:#f6f8fc;font:16px/1.5 system-ui,sans-serif}body{margin:0}.page{max-width:1180px;margin:auto;padding:32px 20px 56px}a{color:#174ecf}.back{text-decoration:none}.eyebrow{margin:28px 0 4px;color:#50617d;font-size:.86rem;font-weight:700;text-transform:uppercase;letter-spacing:.08em}h1{margin:0;font-size:2rem}h2{margin:32px 0 12px;font-size:1.3rem}.summary,.card{padding:20px;border:1px solid #dfe5f0;border-radius:12px;background:#fff}.summary-head{display:flex;justify-content:space-between;gap:16px;align-items:start}.status{display:inline-block;border-radius:999px;padding:4px 10px;font-size:.9rem;font-weight:700}.status-success{background:#dff6e9;color:#126b3d}.status-danger{background:#ffe4e6;color:#a31135}.status-active{background:#e4edff;color:#174ecf}.status-waiting{background:#edf0f5;color:#50617d}.hint{margin:8px 0 0;color:#56657c}.bar{height:10px;margin:20px 0 8px;overflow:hidden;border-radius:999px;background:#e6eaf1}.bar>span{display:block;height:100%;background:#1f5eff;transition:width .3s}.numbers{display:grid;grid-template-columns:repeat(5,1fr);gap:10px}.number{padding:12px;border-radius:8px;background:#f6f8fc}.number b{display:block;font-size:1.35rem}.notice{margin:20px 0;padding:15px 17px;border:1px solid #f2cb72;border-radius:10px;background:#fff8e6}.table-wrap{overflow-x:auto;border:1px solid #dfe5f0;border-radius:10px;background:#fff}table{width:100%;border-collapse:collapse}td,th{padding:12px 13px;border-bottom:1px solid #e8ecf4;text-align:left;vertical-align:top}th{color:#50617d;font-size:.78rem;text-transform:uppercase;letter-spacing:.06em}tr:last-child td{border:0}small,.muted{color:#68758b}.error{color:#a31135;max-width:360px;word-break:break-word}.download{display:inline-block;padding:7px 10px;border:1px solid #b9c9ee;border-radius:7px;text-decoration:none}.empty{padding:24px;text-align:center;color:#68758b}details{margin-top:18px;color:#56657c}code{word-break:break-all}@media(max-width:700px){.summary-head{display:block}.numbers{grid-template-columns:repeat(2,1fr)}}
</style>
</head>
<body>
<main class="page">
<a class="back" href="/ui">К списку заданий</a>
<p class="eyebrow">{{workloadLabel .Workload}}</p>
<h1>Ход выполнения</h1>
<section class="summary">
<div class="summary-head"><div><span id="status" class="status status-{{statusClass .Status}}">{{statusLabel .Status}}</span><p id="hint" class="hint">{{statusHint .Status}}</p></div><small>Сводка обновляется автоматически каждые 2 секунды.</small></div>
<div class="bar" aria-label="Прогресс"><span id="progress-bar" style="width:{{progressPercent .Completed .Failed .Total}}%"></span></div>
<p id="progress" class="muted">{{.Completed}} из {{.Total}} задач завершено</p>
<div class="numbers"><div class="number"><b id="total">{{.Total}}</b><small>всего shard</small></div><div class="number"><b id="completed">{{.Completed}}</b><small>завершено</small></div><div class="number"><b id="pending">{{.Pending}}</b><small>ожидает</small></div><div class="number"><b id="active">{{add .Leased .Running}}</b><small>у worker</small></div><div class="number"><b id="failed">{{.Failed}}</b><small>ошибок</small></div></div>
<details><summary>Технические сведения</summary><p>Job ID: <code>{{.ID}}</code><br>Вычисление: <code>{{.Workload}}</code><br>Создано: {{time .CreatedAt}}</p></details>
</section>
<section class="notice"><strong>Что можно скачать сейчас?</strong><br>Файлы <code>partial_result</code> — результат отдельных shards. Они полезны для проверки pipeline, но ещё не являются объединённым финальным CSV: reducer пока не реализован.</section>
<h2>Shard-задачи</h2>
<p class="muted">Если есть ошибки, в этой таблице видно код и сообщение. Подробные строки обновляются после обычного обновления страницы.</p>
<div class="table-wrap"><table><tr><th>Shard</th><th>Состояние</th><th>Попытка</th><th>Worker / lease</th><th>Ошибка</th></tr>{{range .Tasks}}<tr><td>#{{.ChunkIndex}}</td><td><span class="status status-{{statusClass .Status}}">{{statusLabel .Status}}</span></td><td>{{.Attempt}} / {{.MaxAttempts}}</td><td>{{if .LeaseOwner}}<code>{{.LeaseOwner}}</code>{{if .LeaseExpiresAt}}<br><small>до {{time .LeaseExpiresAt}}</small>{{end}}{{else}}<span class="muted"></span>{{end}}</td><td class="error">{{if .ErrorCode}}<code>{{.ErrorCode}}</code><br>{{.ErrorMessage}}{{else}}<span class="muted"></span>{{end}}</td></tr>{{else}}<tr><td colspan="5" class="empty">Задачи пока не появились.</td></tr>{{end}}</table></div>
<h2>Артефакты coordinator</h2>
<div class="table-wrap"><table><tr><th>Тип</th><th>Файл</th><th>Размер</th><th>Проверка целостности</th><th></th></tr>{{range .Artifacts}}<tr><td>{{if .Diagnostic}}<strong>Частичный результат</strong><br><small>диагностический</small>{{else}}{{.Kind}}{{end}}</td><td>{{.Filename}}</td><td>{{bytes .SizeBytes}}</td><td><code>{{.SHA256}}</code></td><td>{{if .Downloadable}}<a class="download" href="/ui/jobs/{{$.ID}}/artifacts/{{.ID}}">Скачать CSV</a>{{else}}<span class="muted">Недоступно</span>{{end}}</td></tr>{{else}}<tr><td colspan="5" class="empty">Артефактов ещё нет: worker загрузит CSV после выполнения shard.</td></tr>{{end}}</table></div>
</main>
<script>
const id={{printf "%q" .ID}};
const state={pending:['Ожидает worker','waiting','Ожидает свободный worker с нужной возможностью.'],leased:['Выдана worker','active','Worker уже забрал задачу и должен скоро начать вычисление.'],running:['Выполняется','active','Worker читает shard, считает fingerprints и загружает результат через coordinator.'],completed:['Задачи завершены','success','Все shard-задачи завершены. Доступные ниже файлы пока являются частичными результатами.'],failed:['Требует внимания','danger','Одна или несколько shard-задач завершились ошибкой. Откройте список задач ниже.']};
setInterval(async()=>{try{const response=await fetch('/ui/api/jobs/'+id);if(!response.ok)return;const job=await response.json(),info=state[job.status]||[job.status,'waiting','Статус получен от coordinator.'],done=job.completed+job.failed,percent=job.total?Math.min(100,Math.floor(done*100/job.total)):0;const badge=document.querySelector('#status');badge.textContent=info[0];badge.className='status status-'+info[1];document.querySelector('#hint').textContent=info[2];document.querySelector('#progress').textContent=job.completed+' из '+job.total+' задач завершено'+(job.failed?' · ошибок: '+job.failed:'');document.querySelector('#progress-bar').style.width=percent+'%';for(const key of ['total','completed','pending','failed'])document.querySelector('#'+key).textContent=job[key];document.querySelector('#active').textContent=job.leased+job.running}catch(_){}} ,2000);
</script>
</body>
</html>
{{end}}
@@ -1 +1,70 @@
{{define "new-job.html"}}<!doctype html><html><head><meta charset="utf-8"><title>New SciMesh run</title><style>body{font:16px system-ui;margin:2rem;max-width:760px}label{display:block;margin:.8rem 0}input{width:100%;padding:.45rem}button{padding:.6rem 1rem}#error{color:#a00}</style></head><body><p><a href="/ui">← Dashboard</a></p><h1>New similarity-search pipeline check</h1><p>This creates a diagnostic shard job. Use <code>query_smiles</code>; global multi-shard reduction is not available yet.</p><form id="run"><label>ChEMBL TSV <input type="file" name="file" required accept=".tsv,.txt,text/tab-separated-values"></label><label>Query SMILES <input name="query_smiles" required maxlength="200" value="CCO"></label><label>Top k <input name="top_k" type="number" min="1" max="100000" value="20" required></label><label>Rows per shard <input name="chunk_rows" type="number" min="1" max="100000" value="1000" required></label><button>Upload and create job</button></form><p id="error" role="alert"></p><script>document.querySelector('#run').addEventListener('submit',async e=>{e.preventDefault();const fields=new FormData(e.target),file=fields.get('file'),p={query_smiles:fields.get('query_smiles'),top_k:Number(fields.get('top_k')),progress_every:0},upload=new FormData();upload.append('workload','similarity-search');upload.append('parameters',JSON.stringify(p));upload.append('chunk_rows',fields.get('chunk_rows'));upload.append('file',file,file.name);try{const r=await fetch('/ui/api/jobs/upload',{method:'POST',body:upload});const d=await r.json();if(!r.ok)throw Error(d.error||'Upload failed');location.href='/ui/jobs/'+d.job_id}catch(err){document.querySelector('#error').textContent=err.message}})</script></body></html>{{end}}
{{define "new-job.html"}}
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Создать проверку — SciMesh</title>
<style>
:root{color:#172033;background:#f6f8fc;font:16px/1.5 system-ui,sans-serif}body{margin:0}.page{max-width:760px;margin:auto;padding:32px 20px 56px}a{color:#174ecf}.back{text-decoration:none}.eyebrow{margin:28px 0 4px;color:#50617d;font-size:.86rem;font-weight:700;text-transform:uppercase;letter-spacing:.08em}h1{margin:0;font-size:2rem}.lead{color:#56657c}.notice{margin:22px 0;padding:16px 18px;border:1px solid #f2cb72;border-radius:10px;background:#fff8e6}.notice strong{display:block}.card{padding:22px;border:1px solid #dfe5f0;border-radius:12px;background:#fff}label{display:block;margin:18px 0 4px;font-weight:700}input{box-sizing:border-box;width:100%;padding:10px;border:1px solid #bac5d8;border-radius:7px;font:inherit}input[type=file]{padding:8px;background:#f8faff}.hint{margin:4px 0;color:#68758b;font-size:.9rem}.button{margin-top:22px;border:0;border-radius:8px;padding:11px 16px;background:#1f5eff;color:#fff;font:inherit;font-weight:700;cursor:pointer}.button:disabled{opacity:.6;cursor:wait}.error{margin-top:16px;color:#a31135}.working{margin-top:16px;color:#174ecf}.checklist{margin:8px 0;padding-left:20px;color:#56657c}.checklist li{margin:5px 0}
</style>
</head>
<body>
<main class="page">
<a class="back" href="/ui">К списку заданий</a>
<p class="eyebrow">Пошаговый запуск</p>
<h1>Поиск похожих молекул</h1>
<p class="lead">Создаёт диагностическую задачу <code>similarity-search</code>: worker найдёт top-k молекул, похожих на заданный SMILES.</p>
<section class="notice">
<strong>Перед запуском</strong>
<ul class="checklist"><li>Оставьте хотя бы один <code>scimesh-worker</code> запущенным.</li><li>Для ручной проверки используйте небольшой TSV.</li><li><b>«Строк в shard» не ограничивает размер файла.</b> Он делит весь загруженный файл на задачи: большой ChEMBL TSV при 1000 строках создаст тысячи задач.</li></ul>
</section>
<form id="run" class="card">
<label for="file">ChEMBL TSV</label>
<input id="file" type="file" name="file" required accept=".tsv,.txt,text/tab-separated-values">
<p class="hint">Ожидаются столбцы <code>chembl_id</code> и <code>canonical_smiles</code>.</p>
<label for="query-smiles">Целевая молекула (SMILES)</label>
<input id="query-smiles" name="query_smiles" required maxlength="200" value="CCO" autocomplete="off">
<p class="hint">Пример <code>CCO</code> — этанол. Для гефитиниба используйте его SMILES или локальный CLI с <code>--query-id</code>.</p>
<label for="top-k">Сколько совпадений вернуть</label>
<input id="top-k" name="top_k" type="number" min="1" max="100000" value="20" required>
<p class="hint">Top-k для каждого shard. Это пока не глобальный top-k всего датасета.</p>
<label for="chunk-rows">Строк в одном shard</label>
<input id="chunk-rows" name="chunk_rows" type="number" min="1" max="100000" value="1000" required>
<p class="hint">Меньше строк — больше задач и более наглядный прогресс; больше строк — меньше задач, но дольше каждая.</p>
<button class="button" id="submit" type="submit">Загрузить файл и создать задачу</button>
<p id="working" class="working" hidden aria-live="polite">Загружаем файл и создаём shard-задачи… Не закрывайте страницу.</p>
<p id="error" class="error" role="alert"></p>
</form>
</main>
<script>
const form=document.querySelector('#run'),button=document.querySelector('#submit'),working=document.querySelector('#working'),error=document.querySelector('#error');
form.addEventListener('submit',async event=>{
event.preventDefault();error.textContent='';
const fields=new FormData(form),file=fields.get('file');
if(!(file instanceof File)||file.size===0){error.textContent='Выберите непустой TSV-файл.';return}
const parameters={query_smiles:fields.get('query_smiles'),top_k:Number(fields.get('top_k')),progress_every:0};
const upload=new FormData();
// The coordinator streams the file, therefore metadata must precede it.
upload.append('workload','similarity-search');upload.append('parameters',JSON.stringify(parameters));upload.append('chunk_rows',fields.get('chunk_rows'));upload.append('file',file,file.name);
button.disabled=true;working.hidden=false;
try{
const response=await fetch('/ui/api/jobs/upload',{method:'POST',body:upload});
const data=await response.json();
if(!response.ok)throw Error(data.error||'Не удалось создать задачу.');
location.href='/ui/jobs/'+data.job_id;
}catch(err){
error.textContent=err.message==='invalid input'?'Проверьте TSV и заполненные поля: coordinator не смог принять запрос.':err.message;
button.disabled=false;working.hidden=true;
}
});
</script>
</body>
</html>
{{end}}
+111 -1
View File
@@ -2,6 +2,7 @@ package http
import (
"embed"
"fmt"
"html/template"
"io"
"mime"
@@ -18,9 +19,118 @@ import (
var uiFiles embed.FS
var uiTemplates = template.Must(template.New("ui").Funcs(template.FuncMap{
"time": func(t time.Time) string { return t.UTC().Format(time.RFC3339) },
"time": formatUITime,
"statusLabel": uiStatusLabel,
"statusHint": uiStatusHint,
"statusClass": uiStatusClass,
"workerStatusLabel": uiWorkerStatusLabel,
"workloadLabel": uiWorkloadLabel,
"progressPercent": uiProgressPercent,
"bytes": uiBytes,
"add": func(a, b int) int { return a + b },
}).ParseFS(uiFiles, "templates/*.html"))
func formatUITime(t time.Time) string {
if t.IsZero() {
return "—"
}
return t.UTC().Format("02.01.2006 15:04 UTC")
}
func uiStatusLabel(status string) string {
switch status {
case "pending":
return "Ожидает worker"
case "leased":
return "Выдана worker"
case "running":
return "Выполняется"
case "completed":
return "Задачи завершены"
case "failed":
return "Требует внимания"
default:
return status
}
}
func uiStatusHint(status string) string {
switch status {
case "pending":
return "Ожидает свободный worker с нужной возможностью."
case "leased":
return "Worker уже забрал задачу и должен скоро начать вычисление."
case "running":
return "Worker читает shard, считает fingerprints и загружает результат через coordinator."
case "completed":
return "Все shard-задачи завершены. Доступные ниже файлы пока являются частичными результатами."
case "failed":
return "Одна или несколько shard-задач завершились ошибкой. Откройте список задач ниже."
default:
return "Статус получен от coordinator."
}
}
func uiStatusClass(status string) string {
switch status {
case "completed":
return "success"
case "failed":
return "danger"
case "running", "leased":
return "active"
default:
return "waiting"
}
}
func uiWorkerStatusLabel(status string) string {
switch status {
case "online":
return "Доступен"
case "offline":
return "Нет связи"
default:
return status
}
}
func uiWorkloadLabel(workload string) string {
switch workload {
case "similarity-search", "similarity_search":
return "Поиск похожих молекул"
case "similarity-graph", "similarity_graph":
return "Граф молекулярного сходства"
default:
return workload
}
}
func uiProgressPercent(completed, failed, total int) int {
if total <= 0 {
return 0
}
percent := (completed + failed) * 100 / total
if percent > 100 {
return 100
}
return percent
}
func uiBytes(n int64) string {
const kib = 1024
if n < kib {
return fmt.Sprintf("%d Б", n)
}
if n < kib*kib {
return fmt.Sprintf("%.1f КиБ", float64(n)/kib)
}
if n < kib*kib*kib {
return fmt.Sprintf("%.1f МиБ", float64(n)/(kib*kib))
}
return fmt.Sprintf("%.1f ГиБ", float64(n)/(kib*kib*kib))
}
func (s *Server) renderUI(w http.ResponseWriter, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
@@ -0,0 +1,35 @@
package http
import "testing"
func TestUIStatusPresentation(t *testing.T) {
tests := []struct {
status string
label string
class string
}{
{"pending", "Ожидает worker", "waiting"},
{"running", "Выполняется", "active"},
{"completed", "Задачи завершены", "success"},
{"failed", "Требует внимания", "danger"},
}
for _, test := range tests {
t.Run(test.status, func(t *testing.T) {
if got := uiStatusLabel(test.status); got != test.label {
t.Errorf("label = %q, want %q", got, test.label)
}
if got := uiStatusClass(test.status); got != test.class {
t.Errorf("class = %q, want %q", got, test.class)
}
})
}
}
func TestUIProgressPercent(t *testing.T) {
if got := uiProgressPercent(3, 1, 8); got != 50 {
t.Errorf("progress = %d, want 50", got)
}
if got := uiProgressPercent(1, 1, 0); got != 0 {
t.Errorf("empty progress = %d, want 0", got)
}
}