@@ -5,11 +5,14 @@ import (
55 "encoding/json"
66 "fmt"
77 "os"
8+ "os/signal"
89 "strings"
10+ "syscall"
911 "time"
1012
1113 "github.com/spf13/cobra"
1214
15+ "github.com/hiddify/hiddify_config_health/internal/probe"
1316 "github.com/hiddify/hiddify_config_health/internal/score"
1417 "github.com/hiddify/hiddify_config_health/internal/store"
1518 "github.com/hiddify/hiddify_config_health/internal/telemetry"
@@ -136,6 +139,142 @@ func subCmd() *cobra.Command {
136139 return c
137140}
138141
142+ // probeCmdCLI runs a long-lived probe session against a single proxy from the
143+ // terminal: starts one core, ticks health checks on an interval, and prints
144+ // one line per sample until interrupted (Ctrl-C) or the proxy is detected
145+ // blocked. This is the CLI counterpart to the web UI's "Probe Time" panel —
146+ // same internal/probe.Runner, same single-session semantics.
147+ func probeCmdCLI () * cobra.Command {
148+ var (
149+ probeCore string
150+ probeInterval int
151+ probeActions []string
152+ probeBlockAfterN int
153+ probeDownloadURL string
154+ probeUploadURL string
155+ probePageloadURL string
156+ )
157+ c := & cobra.Command {
158+ Use : "probe <proxy-uri>" ,
159+ Short : "Repeatedly probe ONE proxy over time to measure speed and detect blocking" ,
160+ Args : cobra .ExactArgs (1 ),
161+ RunE : func (cmd * cobra.Command , args []string ) error {
162+ cfg := probe.Config {
163+ ExampleDir : "cli-probe" ,
164+ ProxyURI : args [0 ],
165+ Core : probeCore ,
166+ Interval : time .Duration (probeInterval ) * time .Second ,
167+ Actions : probeActions ,
168+ BlockAfterFailures : probeBlockAfterN ,
169+ DownloadURL : probeDownloadURL ,
170+ UploadURL : probeUploadURL ,
171+ PageloadURL : probePageloadURL ,
172+ Seed : time .Now ().UnixNano (),
173+ }
174+ r , err := probe .New (cfg )
175+ if err != nil {
176+ return err
177+ }
178+
179+ db , _ := store .Open (flagDBPath )
180+ if db != nil {
181+ defer db .Close ()
182+ }
183+ var sessionID int64
184+ if db != nil {
185+ sessionID , _ = db .StartProbeSession (cfg .ExampleDir , "" , cfg .Interval , cfg .Actions )
186+ }
187+
188+ ctx , stop := signal .NotifyContext (cmd .Context (), os .Interrupt , syscall .SIGTERM )
189+ defer stop ()
190+
191+ if err := r .Start (ctx ); err != nil {
192+ return err
193+ }
194+ fmt .Printf ("Probing %s every %s — Ctrl-C to stop.\n " +
195+ "Only ONE proxy can be probed at a time; don't route other traffic through it while this runs.\n \n " ,
196+ args [0 ], cfg .Interval )
197+
198+ samples , events := r .Samples (), r .Events ()
199+ for samples != nil || events != nil {
200+ select {
201+ case s , ok := <- samples :
202+ if ! ok {
203+ samples = nil
204+ continue
205+ }
206+ printProbeSample (s )
207+ if db != nil {
208+ _ = db .SaveProbeSample (sessionID , store.ProbeSample {
209+ Timestamp : s .Timestamp , OK : s .OK , DownloadBPS : s .DownloadBPS ,
210+ UploadBPS : s .UploadBPS , PageloadMs : s .PageloadMs , Err : s .Err ,
211+ ConsecutiveFailures : s .ConsecutiveFailures , BaselineFailed : s .BaselineFailed ,
212+ CoreRestarted : s .CoreRestarted ,
213+ })
214+ }
215+ case e , ok := <- events :
216+ if ! ok {
217+ events = nil
218+ continue
219+ }
220+ if e .Blocked {
221+ fmt .Printf (">>> BLOCKED at %s <<<\n " , e .Timestamp .Format (time .RFC3339 ))
222+ if db != nil {
223+ _ = db .MarkProbeBlocked (sessionID , e .Timestamp )
224+ }
225+ } else {
226+ fmt .Printf (">>> recovered at %s <<<\n " , e .Timestamp .Format (time .RFC3339 ))
227+ }
228+ case <- ctx .Done ():
229+ r .Stop ()
230+ if db != nil {
231+ _ = db .StopProbeSession (sessionID )
232+ }
233+ fmt .Println ("\n stopped." )
234+ return nil
235+ }
236+ }
237+ return nil
238+ },
239+ }
240+ c .Flags ().StringVar (& probeCore , "core" , "sing-box" , "core to run the proxy on (sing-box | xray)" )
241+ c .Flags ().IntVar (& probeInterval , "interval" , 300 , "seconds between probes" )
242+ c .Flags ().StringSliceVar (& probeActions , "actions" , []string {"download" }, "comma-separated: pageload,download,upload,mix" )
243+ c .Flags ().IntVar (& probeBlockAfterN , "block-after" , 3 , "consecutive proxy failures (with healthy baseline) before reporting blocked" )
244+ c .Flags ().StringVar (& probeDownloadURL , "download-url" , "" , "override the download-test URL" )
245+ c .Flags ().StringVar (& probeUploadURL , "upload-url" , "" , "override the upload-test URL" )
246+ c .Flags ().StringVar (& probePageloadURL , "pageload-url" , "" , "override the pageload-test URL" )
247+ return c
248+ }
249+
250+ func printProbeSample (s probe.Sample ) {
251+ t := s .Timestamp .Format ("15:04:05" )
252+ status := "OK"
253+ if ! s .OK {
254+ status = "FAIL"
255+ }
256+ extra := ""
257+ if s .OK && s .DownloadBPS > 0 {
258+ extra += fmt .Sprintf (" ↓%s" , fmtBPS (s .DownloadBPS ))
259+ }
260+ if s .OK && s .UploadBPS > 0 {
261+ extra += fmt .Sprintf (" ↑%s" , fmtBPS (s .UploadBPS ))
262+ }
263+ if s .CoreRestarted {
264+ extra += " [core restarted]"
265+ }
266+ if s .BaselineFailed {
267+ extra += " [your network is down — not counted toward block]"
268+ }
269+ if ! s .OK && s .ConsecutiveFailures > 0 {
270+ extra += fmt .Sprintf (" (failure #%d)" , s .ConsecutiveFailures )
271+ }
272+ if ! s .OK && s .Err != "" {
273+ extra += " — " + s .Err
274+ }
275+ fmt .Printf ("%s %-4s%s\n " , t , status , extra )
276+ }
277+
139278func addProxyFlags (c * cobra.Command ) {
140279 c .Flags ().BoolVar (& flagJSON , "json" , false , "emit machine-readable JSON report (for CI)" )
141280 c .Flags ().BoolVar (& flagFull , "full" , false , "run the full advanced suite (load/entropy/probe/tls-fingerprint)" )
0 commit comments