Skip to content

Commit 6c2ea44

Browse files
feat(web): add global deploy input for SSH in health check UI
- Introduced a new input field for global SSH deployment in the health check interface, allowing users to specify a server for all examples. - Updated the command-line interface to include a `--deploy` flag for specifying the SSH URL. - Enhanced the summary display to show results for each example, improving user experience during health checks. These changes streamline the deployment process and provide clearer options for users running health checks across multiple examples.
1 parent e675682 commit 6c2ea44

3 files changed

Lines changed: 127 additions & 42 deletions

File tree

cmd/main.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ var (
3838
flagQuiet bool
3939
flagJSON bool
4040
flagCore string
41+
flagDeploy string
4142
)
4243

4344
// jsonCheck is the CI-friendly serialization of a single health check.
@@ -135,6 +136,7 @@ func runCmd() *cobra.Command {
135136
},
136137
}
137138
c.Flags().BoolVar(&flagJSON, "json", false, "emit machine-readable JSON report (for CI)")
139+
c.Flags().StringVar(&flagDeploy, "deploy", "", "deploy server to this SSH URL for all examples (ssh://user:pass@host:22)")
138140
return c
139141
}
140142

@@ -151,7 +153,7 @@ func runOne(ctx context.Context, dir string, db *store.DB) ([]jsonResult, bool)
151153
}
152154

153155
core := coreOf(dir)
154-
results, err := runner.Run(ctx, dir, logOut)
156+
results, err := runner.RunWithOverrides(ctx, dir, logOut, runner.Overrides{DeployToServer: flagDeploy})
155157
if err != nil && len(results) == 0 {
156158
// Hard failure before any variant produced a result.
157159
if !flagJSON {
@@ -285,6 +287,7 @@ func runAllCmd() *cobra.Command {
285287
}
286288
c.Flags().BoolVar(&flagJSON, "json", false, "emit machine-readable JSON report (for CI)")
287289
c.Flags().StringVar(&flagCore, "core", "", "only run examples for this core (e.g. sing-box, xray)")
290+
c.Flags().StringVar(&flagDeploy, "deploy", "", "deploy server to this SSH URL for ALL examples (ssh://user:pass@host:22)")
288291
return c
289292
}
290293

hiddify-health

32 Bytes
Binary file not shown.

internal/web/static/index.html

Lines changed: 123 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@
6363
#btn-run-all:hover{background:#047857}
6464
#btn-run-all:disabled{background:#374151;color:#6b7280;cursor:not-allowed}
6565
#core-filter{margin:0 16px 8px;padding:6px 8px;background:#0d1117;border:1px solid #2d3748;border-radius:6px;color:#e2e8f0;font-size:.8rem;width:calc(100% - 32px)}
66+
#global-deploy{margin:0 16px 8px;padding:6px 8px;background:#0d1117;border:1px solid #2d3748;border-radius:6px;color:#e2e8f0;font-size:.72rem;font-family:monospace;width:calc(100% - 32px)}
67+
#global-deploy::placeholder{color:#4a5568}
6668
#summary-table{width:100%;border-collapse:collapse;font-size:.82rem}
6769
#summary-table th,#summary-table td{padding:7px 10px;text-align:left;border-bottom:1px solid #1a202c}
6870
#summary-table th{color:#718096;font-size:.72rem;text-transform:uppercase;position:sticky;top:0;background:#0d1117}
@@ -75,6 +77,7 @@
7577
<div id="sidebar">
7678
<h1>🛡 Hiddify Health</h1>
7779
<button id="btn-run-all">▶ Run All</button>
80+
<input id="global-deploy" type="text" placeholder="Global SSH (ssh://user:pass@host:22) — applies to Run All" title="Deploy server to this host for every example in Run All">
7881
<select id="core-filter" title="Filter by core"><option value="">All cores</option></select>
7982
<div id="examples"><div class="welcome">Loading…</div></div>
8083
</div>
@@ -360,10 +363,17 @@ <h2 id="title">Select an example</h2>
360363
document.getElementById('btn-run-all').onclick = runAll;
361364
document.getElementById('core-filter').addEventListener('change', renderExamples);
362365

366+
// summaryRows holds the collected Run All results; the table is re-rendered
367+
// from this array whenever sort/filter changes.
368+
let summaryRows = [];
369+
let sortKey = 'name', sortDir = 1;
370+
let statusFilter = 'all'; // all | pass | fail
371+
363372
async function runAll() {
364373
const toRun = visibleExamples();
365374
if (runAllActive || !toRun.length) return;
366375
runAllActive = true;
376+
summaryRows = [];
367377
const btnAll = document.getElementById('btn-run-all');
368378
btnAll.disabled = true;
369379
btnAll.textContent = '⏳ Running all…';
@@ -373,20 +383,20 @@ <h2 id="title">Select an example</h2>
373383
document.getElementById('mode-badge').style.display = 'none';
374384
document.querySelectorAll('.example').forEach(e => e.classList.remove('active'));
375385
selectedDir = null;
376-
377-
const log = document.getElementById('log-panel');
378-
log.innerHTML = `<table id="summary-table"><thead><tr>
379-
<th>Example</th><th>Variant</th>${CHECKS_ORDER.map(c=>`<th>${c}</th>`).join('')}
380-
<th>Censor</th><th>Time</th><th>Status</th>
381-
</tr></thead><tbody></tbody></table>`;
382386
document.getElementById('checks-list').innerHTML = '<h3>Results</h3>';
383-
const tbody = log.querySelector('tbody');
384387

385388
for (const item of toRun) {
386-
const phRow = document.createElement('tr');
387-
phRow.innerHTML = `<td>${item.name}</td><td colspan="${CHECKS_ORDER.length+4}"><span class="badge running">RUNNING</span></td>`;
388-
tbody.appendChild(phRow);
389-
await runOneCollect(item, tbody, phRow);
389+
summaryRows.push({ name: item.name, variant: '', item, running: true });
390+
renderSummary();
391+
const got = await runOneCollect(item);
392+
// Remove the placeholder for this item and append real rows.
393+
summaryRows = summaryRows.filter(r => !(r.item === item && r.running));
394+
if (!got.length) {
395+
summaryRows.push({ name: item.name, variant: '', item, error: true, pass: false });
396+
} else {
397+
got.forEach(r => summaryRows.push(rowFromResult(r, item)));
398+
}
399+
renderSummary();
390400
}
391401

392402
runAllActive = false;
@@ -395,44 +405,116 @@ <h2 id="title">Select an example</h2>
395405
loadExamples(); // refresh sidebar badges
396406
}
397407

398-
function runOneCollect(item, tbody, phRow) {
408+
function speedOf(checks, names) {
409+
for (const c of (checks||[])) if (names.includes(c.Name) && c.Throughput) return c.Throughput;
410+
return 0;
411+
}
412+
413+
function rowFromResult(r, item) {
414+
const byName = {};
415+
(r.Checks||[]).forEach(c => byName[c.Name] = c);
416+
return {
417+
name: r.Name || item.name,
418+
variant: (r.Variant && r.Variant !== r.Name) ? r.Variant : '',
419+
item, pass: !!r.Pass,
420+
censor: r.Fingerprint ? (r.Fingerprint.Verdict||'') : '',
421+
ms: Math.round((r.Duration||0)/1e6),
422+
dl: speedOf(r.Checks, ['download','speedtest']),
423+
ul: speedOf(r.Checks, ['upload']),
424+
checks: byName,
425+
};
426+
}
427+
428+
const SUMMARY_COLS = [
429+
{key:'name', label:'Example'},
430+
{key:'variant', label:'Variant'},
431+
...CHECKS_ORDER.map(c => ({key:'chk_'+c, label:c, check:c})),
432+
{key:'dl', label:'↓ DL'},
433+
{key:'ul', label:'↑ UL'},
434+
{key:'censor', label:'Censor'},
435+
{key:'ms', label:'Time'},
436+
{key:'pass', label:'Status'},
437+
];
438+
439+
function sortVal(row, key) {
440+
if (key.startsWith('chk_')) { const c = row.checks && row.checks[key.slice(4)]; return c ? (c.OK?2:(c.Optional?1:0)) : -1; }
441+
if (key === 'pass') return row.pass ? 1 : 0;
442+
return row[key] !== undefined ? row[key] : '';
443+
}
444+
445+
function renderSummary() {
446+
const log = document.getElementById('log-panel');
447+
let rows = summaryRows.slice();
448+
if (statusFilter !== 'all') rows = rows.filter(r => r.running || (statusFilter==='pass'?r.pass:!r.pass));
449+
// Stable sort (placeholders/running rows keep insertion order at bottom).
450+
rows.sort((a,b) => {
451+
if (a.running !== b.running) return a.running ? 1 : -1;
452+
const va = sortVal(a, sortKey), vb = sortVal(b, sortKey);
453+
if (va < vb) return -1*sortDir; if (va > vb) return 1*sortDir; return 0;
454+
});
455+
456+
const arrow = k => k===sortKey ? (sortDir>0?' ▲':' ▼') : '';
457+
const head = SUMMARY_COLS.map(c => `<th data-key="${c.key}" style="cursor:pointer">${c.label}${arrow(c.key)}</th>`).join('');
458+
459+
const body = rows.map(r => {
460+
if (r.running) return `<tr><td>${r.name}</td><td colspan="${SUMMARY_COLS.length-1}"><span class="badge running">RUNNING</span></td></tr>`;
461+
if (r.error) return `<tr data-dir="${r.item.dir}"><td>${r.name}</td><td colspan="${SUMMARY_COLS.length-1}"><span class="badge fail">ERROR — no result (open example for log)</span></td></tr>`;
462+
const cells = CHECKS_ORDER.map(name => {
463+
const c = r.checks[name];
464+
if (!c) return '<td style="color:#4a5568">—</td>';
465+
const cls = c.OK ? 'check-ok' : (c.Optional ? 'check-warn' : 'check-fail');
466+
return `<td class="${cls}">${c.OK?'✓':(c.Optional?'!':'✗')}</td>`;
467+
}).join('');
468+
return `<tr data-dir="${r.item.dir}">
469+
<td>${r.name}</td><td style="color:#a78bfa">${r.variant}</td>${cells}
470+
<td style="color:#718096">${r.dl?fmtSpeed(r.dl):'—'}</td>
471+
<td style="color:#718096">${r.ul?fmtSpeed(r.ul):'—'}</td>
472+
<td>${r.censor}</td><td style="color:#718096">${r.ms}ms</td>
473+
<td><span class="badge ${r.pass?'pass':'fail'}">${r.pass?'PASS':'FAIL'}</span></td>
474+
</tr>`;
475+
}).join('');
476+
477+
const counts = summaryRows.filter(r=>!r.running);
478+
const np = counts.filter(r=>r.pass).length, nf = counts.length - np;
479+
log.innerHTML = `
480+
<div style="margin-bottom:8px;font-size:.8rem;color:#a0aec0">
481+
<span class="check-ok">${np} pass</span> · <span class="check-fail">${nf} fail</span>
482+
&nbsp;|&nbsp; Filter:
483+
<select id="status-filter" style="background:#0d1117;border:1px solid #2d3748;border-radius:5px;color:#e2e8f0;font-size:.78rem;padding:2px 5px">
484+
<option value="all"${statusFilter==='all'?' selected':''}>All</option>
485+
<option value="pass"${statusFilter==='pass'?' selected':''}>Pass only</option>
486+
<option value="fail"${statusFilter==='fail'?' selected':''}>Fail only</option>
487+
</select>
488+
</div>
489+
<table id="summary-table"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
490+
491+
log.querySelectorAll('th[data-key]').forEach(th => th.onclick = () => {
492+
const k = th.dataset.key;
493+
if (k === sortKey) sortDir = -sortDir; else { sortKey = k; sortDir = 1; }
494+
renderSummary();
495+
});
496+
log.querySelectorAll('tbody tr[data-dir]').forEach(tr => tr.onclick = () => {
497+
const it = examplesList.find(e => e.dir === tr.dataset.dir);
498+
if (it) selectExample(it);
499+
});
500+
const sf = document.getElementById('status-filter');
501+
if (sf) sf.onchange = () => { statusFilter = sf.value; renderSummary(); };
502+
}
503+
504+
function runOneCollect(item) {
399505
return new Promise(resolve => {
400-
const src = new EventSource(`/api/run?dir=${encodeURIComponent(item.dir)}`);
506+
let runURL = `/api/run?dir=${encodeURIComponent(item.dir)}`;
507+
const gd = document.getElementById('global-deploy').value.trim();
508+
if (gd) runURL += `&deploy=${encodeURIComponent(gd)}`;
509+
const src = new EventSource(runURL);
401510
const results = [];
402511
let finished = false;
403512
src.addEventListener('result', e => { try { results.push(JSON.parse(e.data)); } catch(_){} });
404513
const finish = () => {
405514
if (finished) return;
406515
finished = true;
407516
src.close();
408-
phRow.remove();
409-
if (!results.length) {
410-
const tr = document.createElement('tr');
411-
tr.innerHTML = `<td>${item.name}</td><td colspan="${CHECKS_ORDER.length+4}"><span class="badge fail">ERROR — no result (see example page for log)</span></td>`;
412-
tr.onclick = () => selectExample(item);
413-
tbody.appendChild(tr);
414-
}
415-
results.forEach(r => {
416-
const byName = {};
417-
(r.Checks||[]).forEach(c => byName[c.Name] = c);
418-
const cells = CHECKS_ORDER.map(name => {
419-
const c = byName[name];
420-
if (c === undefined) return '<td style="color:#4a5568">—</td>';
421-
const cls = c.OK ? 'check-ok' : (c.Optional ? 'check-warn' : 'check-fail');
422-
const sym = c.OK ? '✓' : (c.Optional ? '!' : '✗');
423-
return `<td class="${cls}">${sym}</td>`;
424-
}).join('');
425-
const variant = r.Variant && r.Variant !== r.Name ? r.Variant : '';
426-
const ms = Math.round((r.Duration||0)/1e6);
427-
const censor = r.Fingerprint ? (r.Fingerprint.Verdict||'') : '';
428-
const tr = document.createElement('tr');
429-
tr.innerHTML = `<td>${r.Name||item.name}</td><td style="color:#a78bfa">${variant}</td>${cells}
430-
<td>${censor}</td><td style="color:#718096">${ms}ms</td>
431-
<td><span class="badge ${r.Pass?'pass':'fail'}">${r.Pass?'PASS':'FAIL'}</span></td>`;
432-
tr.onclick = () => selectExample(item);
433-
tbody.appendChild(tr);
434-
});
435-
resolve();
517+
resolve(results);
436518
};
437519
src.addEventListener('done', finish);
438520
src.onerror = () => finish(); // network-level error — move on to next example

0 commit comments

Comments
 (0)