Skip to content

Commit 92950f4

Browse files
feat(json-report): enhance health check reporting with JSON output and core filtering
- Introduced JSON serialization for health check results, allowing machine-readable output for CI integration. - Added a `--json` flag to the command line interface to enable JSON reporting. - Implemented core filtering in the UI, allowing users to filter examples based on the core configuration. - Enhanced the web interface to display additional metadata from the last run, including throughput and verdicts. These changes improve the usability and integration of health checks in CI environments, providing clearer insights into test results.
1 parent 3a9530e commit 92950f4

4 files changed

Lines changed: 205 additions & 34 deletions

File tree

cmd/main.go

Lines changed: 157 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package main
33

44
import (
55
"context"
6+
"encoding/json"
67
"fmt"
78
"io"
89
"io/fs"
@@ -35,8 +36,60 @@ var (
3536
flagExamplesDir string
3637
flagTimeout int
3738
flagQuiet bool
39+
flagJSON bool
40+
flagCore string
3841
)
3942

43+
// jsonCheck is the CI-friendly serialization of a single health check.
44+
type jsonCheck struct {
45+
Name string `json:"name"`
46+
OK bool `json:"ok"`
47+
Optional bool `json:"optional"`
48+
Extra string `json:"extra,omitempty"`
49+
Error string `json:"error,omitempty"`
50+
}
51+
52+
// jsonResult is the CI-friendly serialization of one variant's run.
53+
type jsonResult struct {
54+
Dir string `json:"dir"`
55+
Name string `json:"name"`
56+
Variant string `json:"variant,omitempty"`
57+
Core string `json:"core,omitempty"`
58+
CoreVersion string `json:"core_version,omitempty"`
59+
Pass bool `json:"pass"`
60+
Censor string `json:"censor,omitempty"`
61+
DurationMs int64 `json:"duration_ms"`
62+
Checks []jsonCheck `json:"checks"`
63+
Error string `json:"error,omitempty"`
64+
}
65+
66+
// jsonReport is the top-level CI output for run-all.
67+
type jsonReport struct {
68+
Passed int `json:"passed"`
69+
Failed int `json:"failed"`
70+
Results []jsonResult `json:"results"`
71+
}
72+
73+
func toJSONResult(dir, core string, res *runner.Result) jsonResult {
74+
jr := jsonResult{
75+
Dir: dir, Name: res.Name, Variant: res.Variant, Core: core,
76+
CoreVersion: res.CoreVersion, Pass: res.Pass,
77+
Censor: res.Fingerprint.Verdict,
78+
DurationMs: res.Duration.Milliseconds(),
79+
}
80+
if res.Err != nil {
81+
jr.Error = res.Err.Error()
82+
}
83+
for _, c := range res.Checks {
84+
jc := jsonCheck{Name: c.Name, OK: c.OK, Optional: c.Optional, Extra: c.Extra}
85+
if c.Err != nil {
86+
jc.Error = c.Err.Error()
87+
}
88+
jr.Checks = append(jr.Checks, jc)
89+
}
90+
return jr
91+
}
92+
4093
func rootCmd() *cobra.Command {
4194
root := &cobra.Command{
4295
Use: "hiddify-health",
@@ -62,7 +115,7 @@ func rootCmd() *cobra.Command {
62115
// --- run ---
63116

64117
func runCmd() *cobra.Command {
65-
return &cobra.Command{
118+
c := &cobra.Command{
66119
Use: "run <example-dir>",
67120
Short: "Run one example test",
68121
Args: cobra.ExactArgs(1),
@@ -71,22 +124,43 @@ func runCmd() *cobra.Command {
71124
if db != nil {
72125
defer db.Close()
73126
}
74-
return runOne(cmd.Context(), args[0], db)
127+
jrs, anyFail := runOne(cmd.Context(), args[0], db)
128+
if flagJSON {
129+
printJSONReport(jrs)
130+
}
131+
if anyFail {
132+
return fmt.Errorf("test failed")
133+
}
134+
return nil
75135
},
76136
}
137+
c.Flags().BoolVar(&flagJSON, "json", false, "emit machine-readable JSON report (for CI)")
138+
return c
77139
}
78140

79-
func runOne(ctx context.Context, dir string, db *store.DB) error {
80-
fmt.Printf("▶ %s\n", dir)
141+
// runOne runs every variant of one example, persists to db, prints the
142+
// human log/summary (unless --json), and returns the JSON results plus
143+
// whether any variant failed.
144+
func runOne(ctx context.Context, dir string, db *store.DB) ([]jsonResult, bool) {
81145
logOut := io.Writer(os.Stdout)
82-
if flagQuiet {
146+
if flagQuiet || flagJSON {
83147
logOut = io.Discard
84148
}
149+
if !flagJSON {
150+
fmt.Printf("▶ %s\n", dir)
151+
}
152+
153+
core := coreOf(dir)
85154
results, err := runner.Run(ctx, dir, logOut)
86-
if err != nil {
87-
fmt.Printf(" ERROR: %v\n", err)
88-
return err
155+
if err != nil && len(results) == 0 {
156+
// Hard failure before any variant produced a result.
157+
if !flagJSON {
158+
fmt.Printf(" ERROR: %v\n", err)
159+
}
160+
return []jsonResult{{Dir: dir, Name: filepath.Base(dir), Core: core, Pass: false, Error: err.Error()}}, true
89161
}
162+
163+
var jrs []jsonResult
90164
anyFail := false
91165
for _, res := range results {
92166
if db != nil {
@@ -104,34 +178,63 @@ func runOne(ctx context.Context, dir string, db *store.DB) error {
104178
}
105179
_, _ = db.Save(rec)
106180
}
107-
status := "PASS"
181+
jrs = append(jrs, toJSONResult(dir, core, res))
108182
if !res.Pass {
109-
status = "FAIL"
110183
anyFail = true
111184
}
112-
label := res.Name
113-
if res.Variant != "" && res.Variant != res.Name {
114-
label = res.Variant
115-
}
116-
fmt.Printf(" [%s] %s duration=%s censor=%s\n",
117-
label, status, res.Duration.Round(time.Millisecond), res.Fingerprint.Verdict)
118-
if res.Err != nil {
119-
fmt.Printf(" error: %v\n", res.Err)
185+
if !flagJSON {
186+
status := "PASS"
187+
if !res.Pass {
188+
status = "FAIL"
189+
}
190+
label := res.Name
191+
if res.Variant != "" && res.Variant != res.Name {
192+
label = res.Variant
193+
}
194+
fmt.Printf(" [%s] %s duration=%s censor=%s\n",
195+
label, status, res.Duration.Round(time.Millisecond), res.Fingerprint.Verdict)
196+
if res.Err != nil {
197+
fmt.Printf(" error: %v\n", res.Err)
198+
}
120199
}
121200
}
122-
if anyFail {
123-
return fmt.Errorf("test failed")
201+
return jrs, anyFail
202+
}
203+
204+
// coreOf returns the core name declared in dir's run config ("" if unknown).
205+
func coreOf(dir string) string {
206+
cfg, err := runner.LoadRunConfig(dir)
207+
if err != nil {
208+
return ""
124209
}
125-
return nil
210+
return cfg.Core
211+
}
212+
213+
func printJSONReport(results []jsonResult) {
214+
rep := jsonReport{Results: results}
215+
for _, r := range results {
216+
if r.Pass {
217+
rep.Passed++
218+
} else {
219+
rep.Failed++
220+
}
221+
}
222+
enc := json.NewEncoder(os.Stdout)
223+
enc.SetIndent("", " ")
224+
_ = enc.Encode(rep)
126225
}
127226

128227
// --- run-all ---
129228

130229
func runAllCmd() *cobra.Command {
131-
return &cobra.Command{
230+
c := &cobra.Command{
132231
Use: "run-all [examples-dir]",
133232
Short: "Run all examples; exit 1 if any fail",
134-
Args: cobra.MaximumNArgs(1),
233+
Long: "Run all examples under the given directory (default: ./examples).\n" +
234+
"Pass a subdirectory to test only that subtree, e.g.\n" +
235+
" hiddify-health run-all examples/xray\n" +
236+
"Filter by core with --core, e.g. --core sing-box.",
237+
Args: cobra.MaximumNArgs(1),
135238
RunE: func(cmd *cobra.Command, args []string) error {
136239
root := flagExamplesDir
137240
if len(args) > 0 {
@@ -141,8 +244,15 @@ func runAllCmd() *cobra.Command {
141244
if err != nil {
142245
return err
143246
}
247+
if flagCore != "" {
248+
dirs = filterByCore(dirs, flagCore)
249+
}
144250
if len(dirs) == 0 {
145-
fmt.Println("No run.json files found under", root)
251+
if !flagJSON {
252+
fmt.Println("No matching examples found under", root)
253+
} else {
254+
printJSONReport(nil)
255+
}
146256
return nil
147257
}
148258

@@ -151,21 +261,42 @@ func runAllCmd() *cobra.Command {
151261
defer db.Close()
152262
}
153263

264+
var allJRS []jsonResult
154265
pass, fail := 0, 0
155266
for _, dir := range dirs {
156-
if err := runOne(cmd.Context(), dir, db); err != nil {
267+
jrs, anyFail := runOne(cmd.Context(), dir, db)
268+
allJRS = append(allJRS, jrs...)
269+
if anyFail {
157270
fail++
158271
} else {
159272
pass++
160273
}
161274
}
162-
fmt.Printf("\n--- %d passed %d failed ---\n", pass, fail)
275+
if flagJSON {
276+
printJSONReport(allJRS)
277+
} else {
278+
fmt.Printf("\n--- %d passed %d failed ---\n", pass, fail)
279+
}
163280
if fail > 0 {
164281
return fmt.Errorf("%d test(s) failed", fail)
165282
}
166283
return nil
167284
},
168285
}
286+
c.Flags().BoolVar(&flagJSON, "json", false, "emit machine-readable JSON report (for CI)")
287+
c.Flags().StringVar(&flagCore, "core", "", "only run examples for this core (e.g. sing-box, xray)")
288+
return c
289+
}
290+
291+
// filterByCore keeps only example dirs whose run config declares the given core.
292+
func filterByCore(dirs []string, core string) []string {
293+
var out []string
294+
for _, dir := range dirs {
295+
if coreOf(dir) == core {
296+
out = append(out, dir)
297+
}
298+
}
299+
return out
169300
}
170301

171302
// --- check ---

hiddify-health

528 Bytes
Binary file not shown.

internal/runner/runner.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,7 @@ func runVariant(ctx context.Context, dir string, cfg RunConfig, v Variant, out i
304304
}
305305
log.Printf("%s", msg)
306306
}
307+
res.Checks = hresults
307308
res.Pass = pass
308309
res.Fingerprint = detect.Passive(hresults)
309310

internal/web/static/index.html

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
.example.active{background:#1e2a3a}
1616
.ex-name{font-weight:600;font-size:.9rem}
1717
.ex-core{font-size:.75rem;color:#718096;margin-top:2px}
18+
.ex-meta{font-size:.7rem;color:#718096;margin-top:3px}
1819
.badge{display:inline-block;padding:2px 7px;border-radius:9px;font-size:.7rem;font-weight:700;margin-top:4px}
1920
.badge.pass{background:#065f46;color:#6ee7b7}
2021
.badge.fail{background:#7f1d1d;color:#fca5a5}
@@ -61,6 +62,7 @@
6162
#btn-run-all{width:calc(100% - 32px);margin:10px 16px;background:#065f46;color:#fff}
6263
#btn-run-all:hover{background:#047857}
6364
#btn-run-all:disabled{background:#374151;color:#6b7280;cursor:not-allowed}
65+
#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)}
6466
#summary-table{width:100%;border-collapse:collapse;font-size:.82rem}
6567
#summary-table th,#summary-table td{padding:7px 10px;text-align:left;border-bottom:1px solid #1a202c}
6668
#summary-table th{color:#718096;font-size:.72rem;text-transform:uppercase;position:sticky;top:0;background:#0d1117}
@@ -73,6 +75,7 @@
7375
<div id="sidebar">
7476
<h1>🛡 Hiddify Health</h1>
7577
<button id="btn-run-all">▶ Run All</button>
78+
<select id="core-filter" title="Filter by core"><option value="">All cores</option></select>
7679
<div id="examples"><div class="welcome">Loading…</div></div>
7780
</div>
7881

@@ -107,23 +110,57 @@ <h2 id="title">Select an example</h2>
107110
const res = await fetch('/api/examples');
108111
const items = await res.json() || [];
109112
examplesList = items;
113+
// Populate the core filter dropdown (preserve current selection).
114+
const sel = document.getElementById('core-filter');
115+
const cur = sel.value;
116+
const cores = [...new Set(items.map(i => i.core).filter(Boolean))].sort();
117+
sel.innerHTML = '<option value="">All cores</option>' +
118+
cores.map(c => `<option value="${c}">${c}</option>`).join('');
119+
sel.value = cur;
120+
renderExamples();
121+
}
122+
123+
function visibleExamples() {
124+
const core = document.getElementById('core-filter').value;
125+
return core ? examplesList.filter(i => i.core === core) : examplesList;
126+
}
127+
128+
function renderExamples() {
129+
const items = visibleExamples();
110130
const el = document.getElementById('examples');
111-
if (!items.length) { el.innerHTML='<div class="welcome">No examples found.<br>Add run.json files to the examples/ directory.</div>'; return; }
131+
if (!items.length) { el.innerHTML='<div class="welcome">No examples for this filter.</div>'; return; }
112132
el.innerHTML = '';
113133
items.forEach(item => {
114134
const div = document.createElement('div');
115135
div.className = 'example';
116136
div.dataset.dir = item.dir;
117-
let badge = '';
118-
if (item.last_run) {
119-
badge = `<span class="badge ${item.last_run.pass?'pass':'fail'}">${item.last_run.pass?'PASS':'FAIL'}</span>`;
120-
}
121-
div.innerHTML = `<div class="ex-name">${item.name}</div><div class="ex-core">${item.core||''}</div>${badge}`;
137+
div.innerHTML = `<div class="ex-name">${item.name}</div><div class="ex-core">${item.core||''}</div>${lastRunMeta(item.last_run)}`;
122138
div.onclick = () => selectExample(item);
123139
el.appendChild(div);
124140
});
125141
}
126142

143+
// lastRunMeta renders the PASS/FAIL badge plus censor verdict + throughput
144+
// from the last stored run (store.Record marshals fields PascalCase).
145+
function lastRunMeta(rec) {
146+
if (!rec) return '<div class="ex-meta">not run yet</div>';
147+
const badge = `<span class="badge ${rec.Pass?'pass':'fail'}">${rec.Pass?'PASS':'FAIL'}</span>`;
148+
let meta = '';
149+
const fp = rec.Fingerprint || {};
150+
if (fp.Verdict && fp.Verdict !== 'unknown') {
151+
const cls = 'fp-'+fp.Verdict;
152+
meta += `<span class="fp-badge ${cls}" style="margin:4px 6px 0 0;font-size:.65rem;padding:1px 6px">${fp.Verdict}</span>`;
153+
}
154+
let speed = '';
155+
(rec.Checks||[]).forEach(c => {
156+
if ((c.Name==='download'||c.Name==='speedtest') && c.Throughput) {
157+
speed = (c.Throughput/1024).toFixed(0)+' KB/s';
158+
}
159+
});
160+
if (speed) meta += `<span class="ex-meta" style="display:inline">⚡ ${speed}</span>`;
161+
return `<div style="margin-top:4px">${badge}${meta}</div>`;
162+
}
163+
127164
function selectExample(item) {
128165
selectedDir = item.dir;
129166
document.getElementById('title').textContent = item.name;
@@ -314,9 +351,11 @@ <h2 id="title">Select an example</h2>
314351
let runAllActive = false;
315352

316353
document.getElementById('btn-run-all').onclick = runAll;
354+
document.getElementById('core-filter').addEventListener('change', renderExamples);
317355

318356
async function runAll() {
319-
if (runAllActive || !examplesList.length) return;
357+
const toRun = visibleExamples();
358+
if (runAllActive || !toRun.length) return;
320359
runAllActive = true;
321360
const btnAll = document.getElementById('btn-run-all');
322361
btnAll.disabled = true;
@@ -336,7 +375,7 @@ <h2 id="title">Select an example</h2>
336375
document.getElementById('checks-list').innerHTML = '<h3>Results</h3>';
337376
const tbody = log.querySelector('tbody');
338377

339-
for (const item of examplesList) {
378+
for (const item of toRun) {
340379
const phRow = document.createElement('tr');
341380
phRow.innerHTML = `<td>${item.name}</td><td colspan="${CHECKS_ORDER.length+4}"><span class="badge running">RUNNING</span></td>`;
342381
tbody.appendChild(phRow);

0 commit comments

Comments
 (0)