feat: add a zero-install web UI for easy over an extracted engine
- extract the headless search out of `easy` into an engine that measures the baseline, resolves the target, and drives the producer/consumer loop, so the CLI and web share one search - make `State` fully observable — structured discovery feed, run phase, baseline/target, and final outcome — and render the same snapshots two ways: the in-place terminal panel and an SSE stream - serve a small embedded page (HTML/JS/Pico baked in via include_str!) on a hand-rolled hyper service: POST /api/run runs one search at a time, GET /api/events streams live snapshots as JSON until it settles - open the UI on a bare invocation (double-clicked binary); `web` with --host/--port/--no-open controls it explicitly - bump to 0.2.0
This commit is contained in:
Generated
+15
-1
@@ -238,17 +238,23 @@ checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
|
||||
|
||||
[[package]]
|
||||
name = "fast-xray"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
"clap",
|
||||
"console",
|
||||
"futures",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"indicatif",
|
||||
"ipnet",
|
||||
"rand 0.8.6",
|
||||
"reqwest",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-tungstenite",
|
||||
@@ -442,6 +448,12 @@ version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.10.1"
|
||||
@@ -455,6 +467,7 @@ dependencies = [
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"smallvec",
|
||||
@@ -992,6 +1005,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+7
-1
@@ -1,20 +1,26 @@
|
||||
[package]
|
||||
name = "fast-xray"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
description = "Fast Cloudflare IP optimizer for CDN-fronted Xray/VLESS nodes."
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
bytes = "1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
console = "0.15"
|
||||
futures = "0.3"
|
||||
http-body-util = "0.1"
|
||||
hyper = { version = "1", features = ["http1", "server"] }
|
||||
hyper-util = { version = "0.1", features = ["tokio"] }
|
||||
indicatif = "0.17"
|
||||
ipnet = "2"
|
||||
rand = "0.8"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["io-util", "macros", "net", "rt-multi-thread", "time"] }
|
||||
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12", "logging"] }
|
||||
tokio-tungstenite = "0.24"
|
||||
|
||||
+26
-3
@@ -4,13 +4,13 @@ use std::path::PathBuf;
|
||||
|
||||
use clap::{Args, Parser, Subcommand};
|
||||
|
||||
/// With no subcommand, the top-level args run the full auto pipeline
|
||||
/// (ping → latency → speed → export).
|
||||
/// With a node but no subcommand, the top-level args run the full auto pipeline
|
||||
/// (ping → latency → speed → export). With no arguments at all (e.g. a
|
||||
/// double-clicked binary), the local web UI launches.
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "fast-xray",
|
||||
about = "Fast Cloudflare IP optimizer for CDN-fronted Xray/VLESS nodes.",
|
||||
arg_required_else_help = true,
|
||||
args_conflicts_with_subcommands = true
|
||||
)]
|
||||
pub(crate) struct Cli {
|
||||
@@ -33,6 +33,8 @@ pub(crate) enum Command {
|
||||
Export(ExportArgs),
|
||||
/// Find one IP fast enough to hit a target speed, then stop.
|
||||
Easy(EasyArgs),
|
||||
/// Launch the local web UI (zero-install front end for `easy`).
|
||||
Web(WebArgs),
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
@@ -246,6 +248,27 @@ pub(crate) struct EasyArgs {
|
||||
pub(crate) output: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct WebArgs {
|
||||
/// Address to bind. Use 0.0.0.0 to expose on the LAN or behind a reverse proxy.
|
||||
#[arg(long, default_value = "127.0.0.1")]
|
||||
pub(crate) host: String,
|
||||
|
||||
/// Port to listen on.
|
||||
#[arg(short = 'p', long, default_value_t = 8080)]
|
||||
pub(crate) port: u16,
|
||||
|
||||
/// Don't open the browser automatically on startup.
|
||||
#[arg(long)]
|
||||
pub(crate) no_open: bool,
|
||||
}
|
||||
|
||||
impl Default for WebArgs {
|
||||
fn default() -> Self {
|
||||
Self { host: "127.0.0.1".to_string(), port: 8080, no_open: false }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct ExportArgs {
|
||||
/// Input vless:// node URL (or use --node-file).
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
//! The headless `easy` engine: measure the direct link, resolve a target, then
|
||||
//! drive the producer/consumer search against a shared [`State`]. The terminal
|
||||
//! CLI and the web server both build their UI around this — one observes the
|
||||
//! [`State`] as an in-place panel, the other streams its snapshots over SSE.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
|
||||
use crate::cloudflare::CfRanges;
|
||||
use crate::speed;
|
||||
use crate::vless::VlessNode;
|
||||
|
||||
use super::consumer::consume;
|
||||
use super::producer::produce;
|
||||
use super::state::{Found, Phase, State};
|
||||
use super::{DEFAULT_SPEED_FRACTION, DIRECT_TIMEOUT, SPEED_BYTES, secs};
|
||||
|
||||
/// Measure the direct (no-proxy) download speed — the link ceiling and the
|
||||
/// source of a default target. One longer measurement: the mirror's throughput
|
||||
/// is too bimodal for short samples to agree. `None` if direct access is blocked.
|
||||
pub(crate) async fn measure_baseline() -> Option<f64> {
|
||||
speed::measure_direct(secs(DIRECT_TIMEOUT), SPEED_BYTES).await.ok()
|
||||
}
|
||||
|
||||
/// Resolve the speed target from an explicit request (if any) and the measured
|
||||
/// `baseline`, returning `(target, note, explicit)`. With neither a request nor
|
||||
/// a measured link there's nothing to aim for; a request above the link is
|
||||
/// rejected.
|
||||
pub(crate) fn resolve_target(
|
||||
speed: Option<f64>,
|
||||
baseline: Option<f64>,
|
||||
) -> Result<(f64, String, bool)> {
|
||||
match (speed, baseline) {
|
||||
(Some(s), _) if s <= 0.0 => Err(anyhow!("target speed must be greater than 0")),
|
||||
(Some(s), Some(base)) if s > base => Err(anyhow!(
|
||||
"direct speed is only {base:.2} MB/s — can't find a node ≥ {s:.2} MB/s"
|
||||
)),
|
||||
(Some(s), _) => Ok((s, String::new(), true)),
|
||||
(None, Some(base)) => Ok((
|
||||
base * DEFAULT_SPEED_FRACTION,
|
||||
format!(" ({:.0}% of direct {base:.2})", DEFAULT_SPEED_FRACTION * 100.0),
|
||||
false,
|
||||
)),
|
||||
(None, None) => Err(anyhow!(
|
||||
"couldn't measure direct speed to pick a default target — set a target speed"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the search: mark the state `Searching`, spawn discovery, run the
|
||||
/// screen/confirm loop, and return the winner (or `None` if the source dried up
|
||||
/// first). The caller settles the final outcome on the state.
|
||||
pub(crate) async fn search(
|
||||
node: Arc<VlessNode>,
|
||||
ranges: Arc<CfRanges>,
|
||||
max: usize,
|
||||
ipv6: bool,
|
||||
explicit_target: bool,
|
||||
state: State,
|
||||
) -> Option<Found> {
|
||||
state.set_phase(Phase::Searching);
|
||||
let producer = tokio::spawn(produce(ranges, node.clone(), max, ipv6, state.clone()));
|
||||
let hit = consume(node, state.clone(), explicit_target).await;
|
||||
producer.abort();
|
||||
hit
|
||||
}
|
||||
+20
-33
@@ -12,6 +12,7 @@
|
||||
//! results, sharing one mutex-guarded [`state`] bucket between the three tasks.
|
||||
|
||||
mod consumer;
|
||||
mod engine;
|
||||
mod producer;
|
||||
mod state;
|
||||
mod ui;
|
||||
@@ -27,13 +28,14 @@ use indicatif::ProgressBar;
|
||||
use crate::cli::EasyArgs;
|
||||
use crate::cloudflare::{self, Family};
|
||||
use crate::report::{print_easy_found, resolve_node, write_file};
|
||||
use crate::speed;
|
||||
|
||||
use consumer::consume;
|
||||
use producer::produce;
|
||||
use state::{Found, State};
|
||||
use ui::{Panel, render_loop};
|
||||
|
||||
// Surface the headless engine and observable state for the web server, which
|
||||
// builds an alternate (SSE) UI around the same search.
|
||||
pub(crate) use engine::{measure_baseline, resolve_target, search};
|
||||
pub(crate) use state::{FeedStage, FeedState, Found, Outcome, Phase, Snapshot, State};
|
||||
|
||||
// Fixed knobs — the point of `easy` is to not expose these.
|
||||
const CONCURRENCY: usize = 5; // independent screens in flight, also the screen floor divisor
|
||||
const QUEUE_HIGH: usize = 50; // validated-queue refill target
|
||||
@@ -70,19 +72,20 @@ pub(crate) async fn run(args: EasyArgs) -> Result<()> {
|
||||
return Err(anyhow!("--speed must be greater than 0"));
|
||||
}
|
||||
|
||||
let state = State::new();
|
||||
|
||||
// 0. Direct (no-proxy) speed via a domestic mirror — the link ceiling, and
|
||||
// the source of the default target when --speed is omitted. One longer
|
||||
// (8 s) measurement: the mirror's throughput is too bimodal for repeated
|
||||
// short samples to agree on.
|
||||
// the source of the default target when --speed is omitted.
|
||||
let spinner = spin("Measuring direct (no-proxy) download speed…");
|
||||
let baseline = speed::measure_direct(secs(DIRECT_TIMEOUT), SPEED_BYTES).await;
|
||||
match &baseline {
|
||||
Ok(mbs) => spinner.finish_with_message(format!(
|
||||
let baseline = engine::measure_baseline().await;
|
||||
state.set_baseline(baseline);
|
||||
match baseline {
|
||||
Some(mbs) => spinner.finish_with_message(format!(
|
||||
"{} direct download speed: {} MB/s",
|
||||
style("✓").green().bold(),
|
||||
style(format!("{mbs:.2}")).cyan()
|
||||
)),
|
||||
Err(_) => spinner.finish_with_message(format!(
|
||||
None => spinner.finish_with_message(format!(
|
||||
"{} direct speed unavailable (direct access blocked?)",
|
||||
style("!").yellow().bold()
|
||||
)),
|
||||
@@ -91,24 +94,8 @@ pub(crate) async fn run(args: EasyArgs) -> Result<()> {
|
||||
// Resolve the target: explicit --speed, else a fraction of the measured
|
||||
// direct speed. Reject a target the local link can't reach; with neither a
|
||||
// target nor a measured link there's nothing to aim for.
|
||||
let explicit_target = args.speed.is_some();
|
||||
let (target, note) = match (args.speed, baseline) {
|
||||
(Some(s), Ok(base)) if s > base => {
|
||||
return Err(anyhow!(
|
||||
"direct speed is only {base:.2} MB/s — can't find a node ≥ {s:.2} MB/s"
|
||||
));
|
||||
}
|
||||
(Some(s), _) => (s, String::new()),
|
||||
(None, Ok(base)) => (
|
||||
base * DEFAULT_SPEED_FRACTION,
|
||||
format!(" ({:.0}% of direct {base:.2})", DEFAULT_SPEED_FRACTION * 100.0),
|
||||
),
|
||||
(None, Err(_)) => {
|
||||
return Err(anyhow!(
|
||||
"couldn't measure direct speed to pick a default target — pass --speed <MB/s>"
|
||||
));
|
||||
}
|
||||
};
|
||||
let (target, note, explicit_target) = engine::resolve_target(args.speed, baseline)?;
|
||||
state.set_target(target);
|
||||
|
||||
let family = if args.ipv6 { Family::Both } else { Family::V4 };
|
||||
let spinner = spin("Fetching Cloudflare ranges…");
|
||||
@@ -127,16 +114,16 @@ pub(crate) async fn run(args: EasyArgs) -> Result<()> {
|
||||
args.max
|
||||
);
|
||||
|
||||
let state = State::new(target);
|
||||
let producer = tokio::spawn(produce(ranges.clone(), node.clone(), args.max, args.ipv6, state.clone()));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let ui = tokio::spawn(render_loop(state.clone(), Panel::new(), args.max, stop.clone()));
|
||||
|
||||
let hit = consume(node.clone(), state.clone(), explicit_target).await;
|
||||
let hit =
|
||||
engine::search(node.clone(), ranges, args.max, args.ipv6, explicit_target, state.clone())
|
||||
.await;
|
||||
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
let _ = ui.await; // render loop clears the view before returning
|
||||
producer.abort();
|
||||
state.set_phase(Phase::Done);
|
||||
|
||||
match hit {
|
||||
Some(Found { ip, latency, mbs }) => {
|
||||
|
||||
+38
-6
@@ -14,8 +14,7 @@ use crate::latency::{self, LatStatus};
|
||||
use crate::ping;
|
||||
use crate::vless::VlessNode;
|
||||
|
||||
use super::state::State;
|
||||
use super::ui::{lat_feed_line, ping_feed_line};
|
||||
use super::state::{FeedEntry, FeedStage, FeedState, State};
|
||||
use super::{
|
||||
LAT_CONCURRENCY, LAT_MAX_MS, LAT_TIMEOUT, MAX_BARREN_ROUNDS, PING_CONCURRENCY, PING_MAX_MS,
|
||||
PING_MIN_MS, PING_TIMEOUT, POLL, PRODUCE_BATCH, QUEUE_HIGH, QUEUE_LOW, millis, secs,
|
||||
@@ -90,9 +89,19 @@ async fn discover_round(
|
||||
let ping_side = async {
|
||||
ping::run(ranges, cfg, move |p| match p {
|
||||
ping::Progress::Probing { ip, status, latency, .. } => {
|
||||
if let Some(line) = ping_feed_line(ip, &status, latency) {
|
||||
state_ping.push_feed(line);
|
||||
let _ = tx.unbounded_send(ip);
|
||||
// Only reachable IPs (with a timed RTT) feed the live log and
|
||||
// flow on to the latency stage; the unreachable firehose is hidden.
|
||||
if matches!(status, ping::ProbeStatus::Ok) {
|
||||
if let Some(d) = latency {
|
||||
state_ping.push_feed(FeedEntry {
|
||||
stage: FeedStage::Ping,
|
||||
ip,
|
||||
ms: Some(d.as_secs_f64() * 1000.0),
|
||||
state: FeedState::Ok,
|
||||
note: None,
|
||||
});
|
||||
let _ = tx.unbounded_send(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -116,7 +125,30 @@ async fn discover_round(
|
||||
Ok(d) => LatStatus::TooSlow(d),
|
||||
Err(e) => LatStatus::Failed(e.to_string()),
|
||||
};
|
||||
state_lat.push_feed(lat_feed_line(ip, &status));
|
||||
let entry = match &status {
|
||||
LatStatus::Ok(d) => FeedEntry {
|
||||
stage: FeedStage::Latency,
|
||||
ip,
|
||||
ms: Some(d.as_secs_f64() * 1000.0),
|
||||
state: FeedState::Ok,
|
||||
note: None,
|
||||
},
|
||||
LatStatus::TooSlow(d) => FeedEntry {
|
||||
stage: FeedStage::Latency,
|
||||
ip,
|
||||
ms: Some(d.as_secs_f64() * 1000.0),
|
||||
state: FeedState::Slow,
|
||||
note: None,
|
||||
},
|
||||
LatStatus::Failed(why) => FeedEntry {
|
||||
stage: FeedStage::Latency,
|
||||
ip,
|
||||
ms: None,
|
||||
state: FeedState::Fail,
|
||||
note: Some(why.clone()),
|
||||
},
|
||||
};
|
||||
state_lat.push_feed(entry);
|
||||
if let LatStatus::Ok(d) = status {
|
||||
if state_lat.try_enqueue(ip, d, max_valid) {
|
||||
minted += 1;
|
||||
|
||||
+112
-25
@@ -2,7 +2,8 @@
|
||||
//! queue, and the live testing/feed/recent views, all behind one [`State`]
|
||||
//! handle whose methods are the *only* way to touch the bucket. A plain mutex
|
||||
//! (never held across `.await`) is enough: producer and consumer drive it
|
||||
//! through these methods, the renderer reads a [`Snapshot`].
|
||||
//! through these methods; a renderer (the terminal panel) or the web SSE loop
|
||||
//! reads a [`Snapshot`].
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::net::IpAddr;
|
||||
@@ -12,21 +13,68 @@ use std::time::Duration;
|
||||
use super::{FEED, RECENT};
|
||||
|
||||
/// The winning IP: address, carried latency, measured speed (MB/s).
|
||||
pub(super) struct Found {
|
||||
pub(super) ip: IpAddr,
|
||||
pub(super) latency: Duration,
|
||||
pub(super) mbs: f64,
|
||||
pub(crate) struct Found {
|
||||
pub(crate) ip: IpAddr,
|
||||
pub(crate) latency: Duration,
|
||||
pub(crate) mbs: f64,
|
||||
}
|
||||
|
||||
/// A point-in-time copy of everything the renderer draws.
|
||||
pub(super) struct Snapshot {
|
||||
pub(super) testing: Vec<IpAddr>,
|
||||
pub(super) feed: Vec<String>,
|
||||
pub(super) recent: Vec<(IpAddr, Option<f64>, bool)>, // newest first
|
||||
pub(super) valid: usize,
|
||||
pub(super) queued: usize,
|
||||
pub(super) confirming: bool,
|
||||
pub(super) recalibrating: bool,
|
||||
/// Coarse run phase, surfaced to the web UI. `confirming`/`recalibrating` are
|
||||
/// finer flags layered on top of `Searching`.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum Phase {
|
||||
Measuring,
|
||||
Searching,
|
||||
Done,
|
||||
}
|
||||
|
||||
/// Which discovery stage produced a feed line.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum FeedStage {
|
||||
Ping,
|
||||
Latency,
|
||||
}
|
||||
|
||||
/// How a feed line turned out.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum FeedState {
|
||||
Ok,
|
||||
Slow,
|
||||
Fail,
|
||||
}
|
||||
|
||||
/// One structured discovery-feed line: the terminal panel formats it into ANSI,
|
||||
/// the web layer serializes it to JSON — one source of truth for both.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct FeedEntry {
|
||||
pub(crate) stage: FeedStage,
|
||||
pub(crate) ip: IpAddr,
|
||||
pub(crate) ms: Option<f64>,
|
||||
pub(crate) state: FeedState,
|
||||
pub(crate) note: Option<String>,
|
||||
}
|
||||
|
||||
/// The terminal end of a run, surfaced to the web UI as the final SSE frame.
|
||||
#[derive(Clone)]
|
||||
pub(crate) enum Outcome {
|
||||
Pending,
|
||||
Found { ip: IpAddr, mbs: f64, latency_ms: f64, url: String },
|
||||
NotFound,
|
||||
}
|
||||
|
||||
/// A point-in-time copy of everything an observer draws.
|
||||
pub(crate) struct Snapshot {
|
||||
pub(crate) phase: Phase,
|
||||
pub(crate) baseline: Option<f64>,
|
||||
pub(crate) target: f64,
|
||||
pub(crate) testing: Vec<IpAddr>,
|
||||
pub(crate) feed: Vec<FeedEntry>,
|
||||
pub(crate) recent: Vec<(IpAddr, Option<f64>, bool)>, // newest first
|
||||
pub(crate) valid: usize,
|
||||
pub(crate) queued: usize,
|
||||
pub(crate) confirming: bool,
|
||||
pub(crate) recalibrating: bool,
|
||||
pub(crate) outcome: Outcome,
|
||||
}
|
||||
|
||||
/// One finished speed test, kept for the "recent" panel.
|
||||
@@ -40,24 +88,28 @@ struct Bucket {
|
||||
queue: VecDeque<(IpAddr, Duration)>, // validated, awaiting a concurrent screen
|
||||
confirm: VecDeque<(IpAddr, Duration)>, // promising (≥ target/n), awaiting a solo confirm
|
||||
testing: Vec<IpAddr>, // IPs under test right now (screen or confirm)
|
||||
feed: VecDeque<String>, // last FEED discovery lines (producer's ping/latency)
|
||||
feed: VecDeque<FeedEntry>, // last FEED discovery lines (producer's ping/latency)
|
||||
recent: VecDeque<Finished>, // last RECENT finished, oldest first
|
||||
valid: usize, // total minted (gates --max + header)
|
||||
fails: usize, // tested-and-rejected count (drives recalibration)
|
||||
phase: Phase, // coarse phase for the web UI
|
||||
baseline: Option<f64>, // measured direct (no-proxy) speed, MB/s
|
||||
target: f64, // current target MB/s (recalibrated mid-run)
|
||||
confirming: bool, // a solo confirm is running (pauses screens, tints UI)
|
||||
recalibrating: bool, // a direct re-measure is running
|
||||
producer_done: bool,
|
||||
found: bool,
|
||||
outcome: Outcome, // final result, once the run settles
|
||||
}
|
||||
|
||||
/// Cloneable handle to the shared bucket. All locking lives here.
|
||||
#[derive(Clone)]
|
||||
pub(super) struct State(Arc<Mutex<Bucket>>);
|
||||
pub(crate) struct State(Arc<Mutex<Bucket>>);
|
||||
|
||||
impl State {
|
||||
/// A fresh state aimed at `target` MB/s.
|
||||
pub(super) fn new(target: f64) -> Self {
|
||||
/// A fresh state. The target starts at 0 and is set once the baseline is
|
||||
/// measured (see [`State::set_target`]); the run starts in `Measuring`.
|
||||
pub(crate) fn new() -> Self {
|
||||
State(Arc::new(Mutex::new(Bucket {
|
||||
queue: VecDeque::new(),
|
||||
confirm: VecDeque::new(),
|
||||
@@ -66,11 +118,14 @@ impl State {
|
||||
recent: VecDeque::new(),
|
||||
valid: 0,
|
||||
fails: 0,
|
||||
target,
|
||||
phase: Phase::Measuring,
|
||||
baseline: None,
|
||||
target: 0.0,
|
||||
confirming: false,
|
||||
recalibrating: false,
|
||||
producer_done: false,
|
||||
found: false,
|
||||
outcome: Outcome::Pending,
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -84,7 +139,7 @@ impl State {
|
||||
self.lock().target
|
||||
}
|
||||
|
||||
pub(super) fn valid(&self) -> usize {
|
||||
pub(crate) fn valid(&self) -> usize {
|
||||
self.lock().valid
|
||||
}
|
||||
|
||||
@@ -104,6 +159,34 @@ impl State {
|
||||
b.fails > 0 && b.fails % every == 0 && !b.found
|
||||
}
|
||||
|
||||
// --- phase / baseline / target (driven by the engine) ---
|
||||
|
||||
pub(crate) fn set_phase(&self, phase: Phase) {
|
||||
self.lock().phase = phase;
|
||||
}
|
||||
|
||||
pub(crate) fn set_baseline(&self, baseline: Option<f64>) {
|
||||
self.lock().baseline = baseline;
|
||||
}
|
||||
|
||||
pub(crate) fn set_target(&self, target: f64) {
|
||||
self.lock().target = target;
|
||||
}
|
||||
|
||||
/// Record the winning node and settle the run (phase `Done`).
|
||||
pub(crate) fn finish_found(&self, ip: IpAddr, mbs: f64, latency_ms: f64, url: String) {
|
||||
let mut b = self.lock();
|
||||
b.phase = Phase::Done;
|
||||
b.outcome = Outcome::Found { ip, mbs, latency_ms, url };
|
||||
}
|
||||
|
||||
/// Settle the run with no fast-enough IP found (phase `Done`).
|
||||
pub(crate) fn finish_not_found(&self) {
|
||||
let mut b = self.lock();
|
||||
b.phase = Phase::Done;
|
||||
b.outcome = Outcome::NotFound;
|
||||
}
|
||||
|
||||
// --- consumer ---
|
||||
|
||||
pub(super) fn pop_queue(&self) -> Option<(IpAddr, Duration)> {
|
||||
@@ -186,10 +269,10 @@ impl State {
|
||||
true
|
||||
}
|
||||
|
||||
/// Push one line onto the bounded discovery feed (newest last).
|
||||
pub(super) fn push_feed(&self, line: String) {
|
||||
/// Push one entry onto the bounded discovery feed (newest last).
|
||||
pub(super) fn push_feed(&self, entry: FeedEntry) {
|
||||
let mut b = self.lock();
|
||||
b.feed.push_back(line);
|
||||
b.feed.push_back(entry);
|
||||
while b.feed.len() > FEED {
|
||||
b.feed.pop_front();
|
||||
}
|
||||
@@ -199,11 +282,14 @@ impl State {
|
||||
self.lock().producer_done = true;
|
||||
}
|
||||
|
||||
// --- renderer ---
|
||||
// --- observers (terminal panel / web SSE) ---
|
||||
|
||||
pub(super) fn snapshot(&self) -> Snapshot {
|
||||
pub(crate) fn snapshot(&self) -> Snapshot {
|
||||
let b = self.lock();
|
||||
Snapshot {
|
||||
phase: b.phase,
|
||||
baseline: b.baseline,
|
||||
target: b.target,
|
||||
testing: b.testing.clone(),
|
||||
feed: b.feed.iter().cloned().collect(),
|
||||
recent: b.recent.iter().rev().map(|f| (f.ip, f.mbs, f.pass)).collect(),
|
||||
@@ -211,6 +297,7 @@ impl State {
|
||||
queued: b.queue.len(),
|
||||
confirming: b.confirming,
|
||||
recalibrating: b.recalibrating,
|
||||
outcome: b.outcome.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+18
-41
@@ -1,18 +1,13 @@
|
||||
//! The fixed in-place panel — header, discovery feed, testing rows, recent rows
|
||||
//! — and the line formatters that feed it.
|
||||
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use console::{Style, style};
|
||||
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
||||
|
||||
use crate::latency::LatStatus;
|
||||
use crate::ping;
|
||||
|
||||
use super::state::{Snapshot, State};
|
||||
use super::state::{FeedEntry, FeedStage, FeedState, Snapshot, State};
|
||||
use super::{CONCURRENCY, FEED, FRAME, FRAMES, QUEUE_LOW, RECENT};
|
||||
|
||||
/// Periodically redraw the panel until `stop`, then clear it.
|
||||
@@ -64,7 +59,7 @@ impl Panel {
|
||||
|
||||
fn render(&self, state: &State, max: usize, tick: usize) {
|
||||
let frame = FRAMES[tick % FRAMES.len()];
|
||||
let Snapshot { testing, feed, recent, valid, queued, confirming, recalibrating } =
|
||||
let Snapshot { testing, feed, recent, valid, queued, confirming, recalibrating, .. } =
|
||||
state.snapshot();
|
||||
|
||||
// Header: the search budget remaining (max valid IPs minus those minted).
|
||||
@@ -92,7 +87,7 @@ impl Panel {
|
||||
// Discovery feed: the ping + latency live log, scrolling (newest last).
|
||||
for (i, pb) in self.discover.iter().enumerate() {
|
||||
match feed.get(i) {
|
||||
Some(line) => pb.set_message(line.clone()),
|
||||
Some(entry) => pb.set_message(feed_line(entry)),
|
||||
None => pb.set_message(String::new()),
|
||||
}
|
||||
}
|
||||
@@ -153,37 +148,19 @@ fn health_color(ratio: f64) -> u8 {
|
||||
RAMP[i]
|
||||
}
|
||||
|
||||
/// A ping-stage feed line for an IP that passed the filters
|
||||
/// (`[ping] <ip> <rtt>`); `None` for the unreachable/filtered firehose.
|
||||
pub(super) fn ping_feed_line(ip: IpAddr, status: &ping::ProbeStatus, latency: Option<Duration>) -> Option<String> {
|
||||
if !matches!(status, ping::ProbeStatus::Ok) {
|
||||
return None;
|
||||
}
|
||||
let ms = format!("{:.0}ms", latency?.as_secs_f64() * 1000.0);
|
||||
Some(format!(
|
||||
" {} {} {}",
|
||||
style(format!("{:<9}", "[ping]")).blue().dim(),
|
||||
style(format!("{:<15}", ip.to_string())).dim(),
|
||||
style(ms).green().dim(),
|
||||
))
|
||||
}
|
||||
|
||||
/// A latency-stage feed line (`[latency] <ip> <rtt|reason>`), dimmed like the
|
||||
/// `latency` command's live log.
|
||||
pub(super) fn lat_feed_line(ip: IpAddr, status: &LatStatus) -> String {
|
||||
let tag = style(format!("{:<9}", "[latency]")).cyan().dim();
|
||||
let addr = style(format!("{:<15}", ip.to_string())).dim();
|
||||
match status {
|
||||
LatStatus::Ok(d) => {
|
||||
let ms = format!("{:.0}ms", d.as_secs_f64() * 1000.0);
|
||||
format!(" {tag} {addr} {}", style(ms).green().dim())
|
||||
}
|
||||
LatStatus::TooSlow(d) => {
|
||||
let ms = format!("{:.0}ms", d.as_secs_f64() * 1000.0);
|
||||
format!(" {tag} {addr} {}", style(format!("{ms} too slow")).red().dim())
|
||||
}
|
||||
LatStatus::Failed(why) => {
|
||||
format!(" {tag} {addr} {}", style(why.clone()).red().dim())
|
||||
}
|
||||
}
|
||||
/// Format one structured discovery-feed entry into a dimmed ANSI line
|
||||
/// (`[ping]/[latency] <ip> <rtt|reason>`), matching the live logs of the
|
||||
/// standalone `ping`/`latency` commands.
|
||||
fn feed_line(e: &FeedEntry) -> String {
|
||||
let tag = match e.stage {
|
||||
FeedStage::Ping => style(format!("{:<9}", "[ping]")).blue().dim(),
|
||||
FeedStage::Latency => style(format!("{:<9}", "[latency]")).cyan().dim(),
|
||||
};
|
||||
let addr = style(format!("{:<15}", e.ip.to_string())).dim();
|
||||
let tail = match e.state {
|
||||
FeedState::Ok => style(format!("{:.0}ms", e.ms.unwrap_or(0.0))).green().dim(),
|
||||
FeedState::Slow => style(format!("{:.0}ms too slow", e.ms.unwrap_or(0.0))).red().dim(),
|
||||
FeedState::Fail => style(e.note.clone().unwrap_or_default()).red().dim(),
|
||||
};
|
||||
format!(" {tag} {addr} {tail}")
|
||||
}
|
||||
|
||||
+9
-2
@@ -15,11 +15,12 @@ mod ping;
|
||||
mod report;
|
||||
mod speed;
|
||||
mod vless;
|
||||
mod web;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
|
||||
use cli::{Cli, Command};
|
||||
use cli::{Cli, Command, WebArgs};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
@@ -33,6 +34,12 @@ async fn main() -> Result<()> {
|
||||
Some(Command::Speed(args)) => commands::run_speed(args).await,
|
||||
Some(Command::Export(args)) => commands::run_export(args),
|
||||
Some(Command::Easy(args)) => easy::run(args).await,
|
||||
None => commands::run_auto(cli.auto).await,
|
||||
Some(Command::Web(args)) => web::run(args).await,
|
||||
// A node without a subcommand runs the auto pipeline; bare invocation
|
||||
// (e.g. a double-clicked binary) opens the web UI.
|
||||
None => match cli.auto.node {
|
||||
Some(_) => commands::run_auto(cli.auto).await,
|
||||
None => web::run(WebArgs::default()).await,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Maps an `easy` [`Snapshot`] into the JSON snapshot the browser renders, one
|
||||
//! field-for-field with what `static/app.js` reads (see its `paint`/`feedLine`/
|
||||
//! `recentLine`/`showResult`). Serialized with serde and pushed over SSE.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::easy::{FeedStage, FeedState, Outcome, Phase, Snapshot};
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Event {
|
||||
phase: &'static str,
|
||||
baseline: Option<f64>,
|
||||
target: Option<f64>,
|
||||
max: usize,
|
||||
valid: usize,
|
||||
queued: usize,
|
||||
feed: Vec<Feed>,
|
||||
testing: Vec<Testing>,
|
||||
recent: Vec<Recent>,
|
||||
outcome: Option<&'static str>,
|
||||
result: Option<NodeResult>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Feed {
|
||||
stage: &'static str,
|
||||
ip: String,
|
||||
ms: Option<i64>,
|
||||
state: &'static str,
|
||||
note: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Testing {
|
||||
ip: String,
|
||||
mode: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Recent {
|
||||
ip: String,
|
||||
mbs: Option<f64>,
|
||||
pass: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct NodeResult {
|
||||
ip: String,
|
||||
mbs: f64,
|
||||
latency_ms: f64,
|
||||
url: String,
|
||||
}
|
||||
|
||||
/// Serialize a snapshot to the browser's `data:` payload. `max` is the run's
|
||||
/// valid-IP budget (carried by the session, not the snapshot).
|
||||
pub(super) fn to_json(snap: &Snapshot, max: usize) -> String {
|
||||
let done = snap.phase == Phase::Done;
|
||||
// The frontend's phase pill: most specific state wins.
|
||||
let phase = if done {
|
||||
"done"
|
||||
} else if snap.recalibrating {
|
||||
"recalibrating"
|
||||
} else if snap.confirming {
|
||||
"confirming"
|
||||
} else if snap.phase == Phase::Measuring {
|
||||
"measuring"
|
||||
} else {
|
||||
"searching"
|
||||
};
|
||||
|
||||
let feed = snap
|
||||
.feed
|
||||
.iter()
|
||||
.map(|e| Feed {
|
||||
stage: match e.stage {
|
||||
FeedStage::Ping => "ping",
|
||||
FeedStage::Latency => "latency",
|
||||
},
|
||||
ip: e.ip.to_string(),
|
||||
ms: e.ms.map(|m| m.round() as i64),
|
||||
state: match e.state {
|
||||
FeedState::Ok => "ok",
|
||||
FeedState::Slow => "slow",
|
||||
FeedState::Fail => "fail",
|
||||
},
|
||||
note: e.note.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mode = if snap.confirming { "confirm" } else { "screen" };
|
||||
let testing = snap.testing.iter().map(|ip| Testing { ip: ip.to_string(), mode }).collect();
|
||||
|
||||
let recent = snap
|
||||
.recent
|
||||
.iter()
|
||||
.map(|(ip, mbs, pass)| Recent { ip: ip.to_string(), mbs: *mbs, pass: *pass })
|
||||
.collect();
|
||||
|
||||
let (outcome, result) = match &snap.outcome {
|
||||
Outcome::Found { ip, mbs, latency_ms, url } => (
|
||||
Some("found"),
|
||||
Some(NodeResult { ip: ip.to_string(), mbs: *mbs, latency_ms: *latency_ms, url: url.clone() }),
|
||||
),
|
||||
Outcome::NotFound => (Some("notfound"), None),
|
||||
Outcome::Pending => (None, None),
|
||||
};
|
||||
|
||||
let event = Event {
|
||||
phase,
|
||||
baseline: snap.baseline,
|
||||
target: (snap.target > 0.0).then_some(snap.target),
|
||||
max,
|
||||
valid: snap.valid,
|
||||
queued: snap.queued,
|
||||
feed,
|
||||
testing,
|
||||
recent,
|
||||
outcome,
|
||||
result,
|
||||
};
|
||||
serde_json::to_string(&event).unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! `web`: a zero-install local UI for the `easy` search. Serves a small embedded
|
||||
//! page that drives one search at a time and streams its progress over SSE, so
|
||||
//! anyone can double-click the binary and optimize a node in the browser — no
|
||||
//! toolchain, no terminal.
|
||||
|
||||
mod event;
|
||||
mod server;
|
||||
mod session;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use console::style;
|
||||
use hyper::server::conn::http1;
|
||||
use hyper::service::service_fn;
|
||||
use hyper_util::rt::TokioIo;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
use crate::cli::WebArgs;
|
||||
use session::Session;
|
||||
|
||||
pub(crate) async fn run(args: WebArgs) -> Result<()> {
|
||||
let addr: SocketAddr = format!("{}:{}", args.host, args.port)
|
||||
.parse()
|
||||
.with_context(|| format!("invalid host/port {}:{}", args.host, args.port))?;
|
||||
let listener = TcpListener::bind(addr).await.with_context(|| format!("bind {addr}"))?;
|
||||
|
||||
// Show a clickable localhost URL even when bound to 0.0.0.0 (LAN / proxy).
|
||||
let display_host = if args.host == "0.0.0.0" { "localhost" } else { args.host.as_str() };
|
||||
let url = format!("http://{display_host}:{}/", args.port);
|
||||
eprintln!("{} fast-xray web → {}", style("✓").green().bold(), style(&url).cyan());
|
||||
eprintln!(" {}", style("按 Ctrl+C 退出").dim());
|
||||
if !args.no_open {
|
||||
open_browser(&url);
|
||||
}
|
||||
|
||||
let session = Arc::new(Session::new());
|
||||
loop {
|
||||
let (stream, _) = listener.accept().await.context("accept connection")?;
|
||||
let io = TokioIo::new(stream);
|
||||
let session = session.clone();
|
||||
tokio::spawn(async move {
|
||||
let service = service_fn(move |req| server::handle(req, session.clone()));
|
||||
// A client hanging up mid-SSE surfaces here as an error; it's normal.
|
||||
let _ = http1::Builder::new().serve_connection(io, service).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort open of the default browser at `url`; failure is silently fine.
|
||||
fn open_browser(url: &str) {
|
||||
use std::process::Command;
|
||||
#[cfg(target_os = "windows")]
|
||||
let _ = Command::new("cmd").args(["/C", "start", "", url]).spawn();
|
||||
#[cfg(target_os = "macos")]
|
||||
let _ = Command::new("open").arg(url).spawn();
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let _ = Command::new("xdg-open").arg(url).spawn();
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
//! HTTP routing for the web UI on a hand-rolled hyper service: three embedded
|
||||
//! static assets, a `POST /api/run` to launch a search, and a `GET /api/events`
|
||||
//! SSE stream that pushes a live snapshot every frame until the run settles.
|
||||
|
||||
use std::convert::Infallible;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use bytes::Bytes;
|
||||
use http_body_util::combinators::BoxBody;
|
||||
use http_body_util::{BodyExt, Full, StreamBody};
|
||||
use hyper::body::{Frame, Incoming};
|
||||
use hyper::header::{CACHE_CONTROL, CONTENT_TYPE};
|
||||
use hyper::{Method, Request, Response, StatusCode};
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::session::Session;
|
||||
|
||||
// The whole frontend, baked into the binary so a single exe is all anyone needs.
|
||||
const INDEX_HTML: &str = include_str!("static/index.html");
|
||||
const APP_JS: &str = include_str!("static/app.js");
|
||||
const PICO_CSS: &str = include_str!("static/pico.min.css");
|
||||
|
||||
const FRAME: Duration = Duration::from_millis(120); // SSE push cadence (matches the UI tick)
|
||||
|
||||
type Body = BoxBody<Bytes, Infallible>;
|
||||
|
||||
/// Request payload for `POST /api/run`.
|
||||
#[derive(Deserialize)]
|
||||
struct RunRequest {
|
||||
node: String,
|
||||
#[serde(default)]
|
||||
max: Option<usize>,
|
||||
}
|
||||
|
||||
/// Route a request. Infallible: every path yields a response.
|
||||
pub(super) async fn handle(
|
||||
req: Request<Incoming>,
|
||||
session: Arc<Session>,
|
||||
) -> Result<Response<Body>, Infallible> {
|
||||
let resp = match (req.method(), req.uri().path()) {
|
||||
(&Method::GET, "/") => asset(INDEX_HTML, "text/html; charset=utf-8"),
|
||||
(&Method::GET, "/app.js") => asset(APP_JS, "text/javascript; charset=utf-8"),
|
||||
(&Method::GET, "/pico.min.css") => asset(PICO_CSS, "text/css; charset=utf-8"),
|
||||
(&Method::POST, "/api/run") => start_run(req, session).await,
|
||||
(&Method::GET, "/api/events") => events(session),
|
||||
_ => text(StatusCode::NOT_FOUND, "not found"),
|
||||
};
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
/// Parse the node + budget and start a search; 400 on a bad body or node URL.
|
||||
async fn start_run(req: Request<Incoming>, session: Arc<Session>) -> Response<Body> {
|
||||
let bytes = match req.into_body().collect().await {
|
||||
Ok(collected) => collected.to_bytes(),
|
||||
Err(_) => return text(StatusCode::BAD_REQUEST, "could not read request body"),
|
||||
};
|
||||
let parsed: RunRequest = match serde_json::from_slice(&bytes) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(_) => return text(StatusCode::BAD_REQUEST, "invalid JSON body"),
|
||||
};
|
||||
match session.start(parsed.node.trim(), parsed.max.unwrap_or(1000)) {
|
||||
Ok(()) => json(StatusCode::OK, "{\"ok\":true}"),
|
||||
Err(e) => text(StatusCode::BAD_REQUEST, &e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Server-sent events: one snapshot frame per `FRAME`, ending after the frame
|
||||
/// that reports `done` so the browser's EventSource can close cleanly.
|
||||
fn events(session: Arc<Session>) -> Response<Body> {
|
||||
let stream = futures::stream::unfold(false, move |stop| {
|
||||
let session = session.clone();
|
||||
async move {
|
||||
if stop {
|
||||
return None;
|
||||
}
|
||||
tokio::time::sleep(FRAME).await;
|
||||
let (payload, done) = match session.event() {
|
||||
Some((json, done)) => (format!("data: {json}\n\n"), done),
|
||||
None => (": waiting\n\n".to_string(), false), // no run yet — keep-alive comment
|
||||
};
|
||||
let frame = Ok::<_, Infallible>(Frame::data(Bytes::from(payload)));
|
||||
Some((frame, done))
|
||||
}
|
||||
});
|
||||
Response::builder()
|
||||
.header(CONTENT_TYPE, "text/event-stream")
|
||||
.header(CACHE_CONTROL, "no-cache")
|
||||
.body(StreamBody::new(stream).boxed())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn full(body: impl Into<Bytes>) -> Body {
|
||||
Full::new(body.into()).boxed()
|
||||
}
|
||||
|
||||
fn asset(body: &'static str, content_type: &str) -> Response<Body> {
|
||||
Response::builder().header(CONTENT_TYPE, content_type).body(full(body)).unwrap()
|
||||
}
|
||||
|
||||
fn json(status: StatusCode, body: &'static str) -> Response<Body> {
|
||||
Response::builder()
|
||||
.status(status)
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(full(body))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn text(status: StatusCode, body: &str) -> Response<Body> {
|
||||
Response::builder()
|
||||
.status(status)
|
||||
.header(CONTENT_TYPE, "text/plain; charset=utf-8")
|
||||
.body(full(body.to_string()))
|
||||
.unwrap()
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//! The single-run session behind the web server: one `easy` search at a time.
|
||||
//! `start` kicks off a background task that drives the search to completion;
|
||||
//! `event` reads the live snapshot for the SSE stream. Starting again replaces
|
||||
//! (and aborts) any previous run.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use anyhow::Result;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::cloudflare::{self, Family};
|
||||
use crate::easy::{self, Phase, State};
|
||||
use crate::vless::VlessNode;
|
||||
|
||||
use super::event;
|
||||
|
||||
/// One in-progress (or finished) run: its observable state, the run's valid-IP
|
||||
/// budget, and the driving task (kept so a restart can abort it).
|
||||
struct Run {
|
||||
state: State,
|
||||
max: usize,
|
||||
task: JoinHandle<()>,
|
||||
}
|
||||
|
||||
/// Holds at most one run. Cloneable via `Arc`.
|
||||
pub(super) struct Session {
|
||||
current: Mutex<Option<Run>>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub(super) fn new() -> Self {
|
||||
Session { current: Mutex::new(None) }
|
||||
}
|
||||
|
||||
/// Validate the node and start a fresh search, replacing any previous run.
|
||||
pub(super) fn start(&self, node_url: &str, max: usize) -> Result<()> {
|
||||
let node = Arc::new(VlessNode::parse(node_url)?);
|
||||
let max = max.clamp(1, 5000);
|
||||
let state = State::new();
|
||||
let task = tokio::spawn(drive(node, max, state.clone()));
|
||||
|
||||
let mut current = self.current.lock().unwrap();
|
||||
if let Some(old) = current.replace(Run { state, max, task }) {
|
||||
old.task.abort();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The current snapshot as an SSE JSON payload, plus whether it has settled.
|
||||
/// `None` before any run has started.
|
||||
pub(super) fn event(&self) -> Option<(String, bool)> {
|
||||
let current = self.current.lock().unwrap();
|
||||
let run = current.as_ref()?;
|
||||
let snap = run.state.snapshot();
|
||||
let done = snap.phase == Phase::Done;
|
||||
Some((event::to_json(&snap, run.max), done))
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive one search to completion, recording the final outcome on `state`.
|
||||
/// Mirrors `easy::run` minus the terminal UI: measure → target → ranges →
|
||||
/// search. The web UI never sets an explicit target, so it always aims for the
|
||||
/// default fraction of the measured direct speed.
|
||||
async fn drive(node: Arc<VlessNode>, max: usize, state: State) {
|
||||
state.set_phase(Phase::Measuring);
|
||||
let baseline = easy::measure_baseline().await;
|
||||
state.set_baseline(baseline);
|
||||
|
||||
let (target, _note, explicit) = match easy::resolve_target(None, baseline) {
|
||||
Ok(resolved) => resolved,
|
||||
Err(_) => return state.finish_not_found(), // direct blocked → no target to aim for
|
||||
};
|
||||
state.set_target(target);
|
||||
|
||||
let ranges = match cloudflare::fetch_ranges(Family::V4).await {
|
||||
Ok(ranges) => Arc::new(ranges),
|
||||
Err(_) => return state.finish_not_found(),
|
||||
};
|
||||
|
||||
match easy::search(node.clone(), ranges, max, false, explicit, state.clone()).await {
|
||||
Some(found) => {
|
||||
let latency_ms = found.latency.as_secs_f64() * 1000.0;
|
||||
let alias = format!("{:.2}MB-{:.0}ms-{}", found.mbs, latency_ms, found.ip);
|
||||
let url = node.to_url(found.ip, &alias);
|
||||
state.finish_found(found.ip, found.mbs, latency_ms, url);
|
||||
}
|
||||
None => state.finish_not_found(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
"use strict";
|
||||
|
||||
// fast-xray web frontend.
|
||||
//
|
||||
// Renders a single live "snapshot" object each frame. When a real backend is
|
||||
// present it streams snapshots over SSE (/api/events); opened directly (file://
|
||||
// or no backend) it falls back to a self-contained DEMO that synthesises the
|
||||
// same shape, so the page can be previewed without the server.
|
||||
|
||||
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
// ---- theme (light/dark, remembered) ---------------------------------------
|
||||
|
||||
const themeBtn = $("theme-toggle");
|
||||
function applyTheme(t) {
|
||||
document.documentElement.setAttribute("data-theme", t);
|
||||
themeBtn.textContent = t === "dark" ? "☀" : "🌙"; // icon shows what you'd switch TO
|
||||
}
|
||||
applyTheme(localStorage.getItem("theme") || "dark");
|
||||
themeBtn.addEventListener("click", () => {
|
||||
const next = document.documentElement.getAttribute("data-theme") === "dark" ? "light" : "dark";
|
||||
localStorage.setItem("theme", next);
|
||||
applyTheme(next);
|
||||
});
|
||||
|
||||
let tick = 0;
|
||||
let last = null; // most recent snapshot, re-rendered on every spinner tick
|
||||
setInterval(() => { tick++; if (last) paint(last); }, 120);
|
||||
|
||||
// ---- formatting helpers ----------------------------------------------------
|
||||
|
||||
const spinner = (cls) => `<span class="spin ${cls || ""}">${FRAMES[tick % FRAMES.length]}</span>`;
|
||||
const pad = (s, n) => { s = String(s); return s + " ".repeat(Math.max(0, n - s.length)); };
|
||||
const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c]));
|
||||
|
||||
function feedLine(f) {
|
||||
const tag = f.stage === "ping"
|
||||
? `<span class="tag-ping">${pad("[ping]", 9)}</span>`
|
||||
: `<span class="tag-lat">${pad("[latency]", 9)}</span>`;
|
||||
const ip = `<span class="ip">${pad(f.ip, 15)}</span>`;
|
||||
let tail;
|
||||
if (f.state === "ok") tail = `<span class="ok">${f.ms}ms</span>`;
|
||||
else if (f.state === "slow") tail = `<span class="bad">${f.ms}ms too slow</span>`;
|
||||
else tail = `<span class="bad">${esc(f.note || "failed")}</span>`;
|
||||
return `<div class="line">${tag} ${ip} ${tail}</div>`;
|
||||
}
|
||||
|
||||
function testingLine(t) {
|
||||
const confirm = t.mode === "confirm";
|
||||
const verb = confirm ? `<span class="spin confirm">confirming…</span>` : `<span class="spin">testing…</span>`;
|
||||
return `<div class="line">${spinner(confirm ? "confirm" : "")} <span class="ip">${pad(t.ip, 15)}</span> ${verb}</div>`;
|
||||
}
|
||||
|
||||
function recentLine(r) {
|
||||
const mark = r.pass ? `<span class="ok">✓</span>` : `<span class="bad">✗</span>`;
|
||||
const val = r.mbs == null
|
||||
? `<span class="dim">failed</span>`
|
||||
: `<span class="${r.pass ? "ok" : "dim"}">${r.mbs.toFixed(2)} MB/s</span>`;
|
||||
return `<div class="line">${mark} <span class="ip">${pad(r.ip, 15)}</span> ${val}</div>`;
|
||||
}
|
||||
|
||||
// ---- the single render entry point ----------------------------------------
|
||||
|
||||
const PHASE = {
|
||||
measuring: ["searching", "测直连速度…"],
|
||||
searching: ["searching", "筛选中"],
|
||||
confirming: ["confirming", "确认中"],
|
||||
recalibrating: ["recalibrating", "重测直连"],
|
||||
done: ["done", "完成"],
|
||||
};
|
||||
|
||||
function paint(s) {
|
||||
// status bar
|
||||
$("status").classList.add("on");
|
||||
$("s-base").textContent = s.baseline == null ? (s.phase === "measuring" ? "测量中…" : "不可用") : `${s.baseline.toFixed(2)} MB/s`;
|
||||
$("s-target").textContent = s.target == null ? "—" : `${s.target.toFixed(2)} MB/s`;
|
||||
$("s-budget").textContent = s.max == null ? "—" : Math.max(0, s.max - (s.valid || 0));
|
||||
$("s-queue").textContent = s.queued == null ? "—" : s.queued;
|
||||
const [cls, label] = PHASE[s.phase] || PHASE.searching;
|
||||
const phaseEl = $("s-phase");
|
||||
phaseEl.className = `pill ${cls}`;
|
||||
phaseEl.textContent = label;
|
||||
|
||||
// live panels
|
||||
$("p-queue").textContent = s.queued == null ? "" : `队列 ${s.queued}`;
|
||||
$("feed").innerHTML = (s.feed && s.feed.length) ? s.feed.map(feedLine).join("") : `<span class="empty">等待发现…</span>`;
|
||||
$("testing-head").textContent = s.phase === "confirming" ? "confirming(单路确认)" : "testing(并发初筛)";
|
||||
$("testing").innerHTML = (s.testing && s.testing.length) ? s.testing.map(testingLine).join("") : `<span class="empty">空闲</span>`;
|
||||
$("recent").innerHTML = (s.recent && s.recent.length) ? s.recent.map(recentLine).join("") : `<span class="empty">暂无结果</span>`;
|
||||
|
||||
// result
|
||||
if (s.outcome) showResult(s);
|
||||
if (s.phase === "done") setRunning(false);
|
||||
}
|
||||
|
||||
function resetResult() {
|
||||
const box = $("result");
|
||||
box.className = "result on";
|
||||
$("r-title").textContent = "优选中…";
|
||||
$("r-metrics").innerHTML = `<span class="dim">完成后在此给出最终节点</span>`;
|
||||
$("r-url").style.display = "none";
|
||||
}
|
||||
|
||||
function showResult(s) {
|
||||
const box = $("result");
|
||||
box.classList.add("on");
|
||||
if (s.outcome === "found" && s.result) {
|
||||
const r = s.result;
|
||||
box.className = "result on win";
|
||||
$("r-title").innerHTML = `<span class="ok">✓</span> 找到节点`;
|
||||
$("r-metrics").innerHTML =
|
||||
`<div class="stat"><span class="lbl">速度</span><b class="big ok">${r.mbs.toFixed(2)}<small> MB/s</small></b></div>` +
|
||||
`<div class="stat"><span class="lbl">延迟</span><b>${r.latency_ms.toFixed(0)} ms</b></div>` +
|
||||
`<div class="stat"><span class="lbl">IP</span><b>${esc(r.ip)}</b></div>`;
|
||||
$("r-url").style.display = "flex";
|
||||
$("r-code").textContent = r.url;
|
||||
} else {
|
||||
box.className = "result on lose";
|
||||
$("r-title").innerHTML = `<span class="bad">✗</span> 没找到达标的 IP`;
|
||||
$("r-metrics").innerHTML = `<span class="dim">测了 ${s.valid || 0} 个有效 IP 仍未达到目标速度,可调高最大个数再试。</span>`;
|
||||
$("r-url").style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
// ---- form + lifecycle ------------------------------------------------------
|
||||
|
||||
let running = false;
|
||||
function setRunning(on) {
|
||||
running = on;
|
||||
$("start").textContent = on ? "优选中…" : "开始优选";
|
||||
$("start").setAttribute("aria-busy", on ? "true" : "false");
|
||||
$("node").disabled = on;
|
||||
$("max").disabled = on;
|
||||
}
|
||||
|
||||
$("form").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
if (running) return;
|
||||
const node = $("node").value.trim();
|
||||
const max = parseInt($("max").value, 10) || 100;
|
||||
if (!node.startsWith("vless://")) {
|
||||
$("hint").textContent = "请粘贴一个 vless:// 开头的节点链接";
|
||||
return;
|
||||
}
|
||||
$("hint").textContent = "";
|
||||
resetResult();
|
||||
last = null;
|
||||
setRunning(true);
|
||||
|
||||
try {
|
||||
const resp = await fetch("/api/run", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ node, max }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await resp.text());
|
||||
streamEvents();
|
||||
} catch (err) {
|
||||
// No backend (preview / file://) → run the local demo instead.
|
||||
runDemo(max);
|
||||
}
|
||||
});
|
||||
|
||||
function streamEvents() {
|
||||
const es = new EventSource("/api/events");
|
||||
es.onmessage = (ev) => {
|
||||
const snap = JSON.parse(ev.data);
|
||||
last = snap;
|
||||
paint(snap);
|
||||
if (snap.phase === "done") { es.close(); }
|
||||
};
|
||||
es.onerror = () => { /* EventSource auto-reconnects; nothing to do */ };
|
||||
}
|
||||
|
||||
$("copy").addEventListener("click", async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText($("r-code").textContent);
|
||||
$("copy").textContent = "已复制";
|
||||
setTimeout(() => ($("copy").textContent = "复制"), 1200);
|
||||
} catch { /* clipboard blocked on insecure origin */ }
|
||||
});
|
||||
|
||||
// ---- DEMO: synthesise snapshots so the page is alive without a backend -----
|
||||
|
||||
function rndIp() {
|
||||
const seg = [[104, 19], [172, 67], [162, 159], [188, 114]][Math.floor(Math.random() * 4)];
|
||||
return `${seg[0]}.${seg[1]}.${(Math.random() * 256) | 0}.${(Math.random() * 256) | 0}`;
|
||||
}
|
||||
|
||||
function runDemo(max) {
|
||||
const s = {
|
||||
phase: "measuring", baseline: null, target: null, max,
|
||||
valid: 0, queued: 0, feed: [], testing: [], recent: [], outcome: null, result: null,
|
||||
};
|
||||
last = s; paint(s);
|
||||
|
||||
setTimeout(() => {
|
||||
s.baseline = 12.4; s.target = 9.92; s.phase = "searching"; s.queued = 4;
|
||||
}, 1500);
|
||||
|
||||
const timer = setInterval(() => {
|
||||
if (s.phase === "measuring") return;
|
||||
|
||||
// grow discovery feed
|
||||
const ip = rndIp();
|
||||
const pingMs = 8 + ((Math.random() * 40) | 0);
|
||||
s.feed.push({ stage: "ping", ip, ms: pingMs, state: "ok" });
|
||||
if (Math.random() < 0.7) {
|
||||
const latMs = 120 + ((Math.random() * 200) | 0);
|
||||
const slow = latMs > 290;
|
||||
s.feed.push({ stage: "latency", ip, ms: latMs, state: slow ? "slow" : "ok" });
|
||||
if (!slow) { s.valid++; s.queued = Math.min(50, s.queued + 1); }
|
||||
}
|
||||
while (s.feed.length > 5) s.feed.shift();
|
||||
|
||||
// testing rows (concurrent screens)
|
||||
s.testing = Array.from({ length: 3 + ((Math.random() * 3) | 0) }, () => ({ ip: rndIp(), mode: "screen" }));
|
||||
if (Math.random() < 0.18) s.testing = [{ ip: rndIp(), mode: "confirm" }], s.phase = "confirming";
|
||||
else if (s.phase === "confirming") s.phase = "searching";
|
||||
|
||||
// recent results
|
||||
if (Math.random() < 0.6) {
|
||||
const mbs = +(Math.random() * 12).toFixed(2);
|
||||
s.recent.unshift({ ip: rndIp(), mbs, pass: false });
|
||||
while (s.recent.length > 3) s.recent.pop();
|
||||
if (s.queued > 0) s.queued--;
|
||||
}
|
||||
|
||||
last = s; paint(s);
|
||||
|
||||
// settle on a win after enough valid IPs
|
||||
if (s.valid >= 14) {
|
||||
clearInterval(timer);
|
||||
const ip = rndIp(), mbs = 10.21, lat = 156;
|
||||
s.phase = "done"; s.outcome = "found"; s.testing = [];
|
||||
s.recent.unshift({ ip, mbs, pass: true });
|
||||
s.result = {
|
||||
ip, mbs, latency_ms: lat,
|
||||
url: `vless://b831381d-6324-4d53-ad4f-8cda48b30811@${ip}:443?encryption=none&security=tls&type=ws&host=example.com&path=%2F#${mbs.toFixed(2)}MB-${lat}ms-${ip}`,
|
||||
};
|
||||
last = s; paint(s);
|
||||
}
|
||||
}, 320);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>fast-xray · 优选 Cloudflare IP</title>
|
||||
<link rel="stylesheet" href="/pico.min.css" />
|
||||
<style>
|
||||
:root {
|
||||
--pico-font-size: 100%;
|
||||
--mono: ui-monospace, SFMono-Regular, "Cascadia Code", Menlo, Consolas, monospace;
|
||||
/* live-panel palette (dark default) */
|
||||
--panel-bg: #0d1117; --panel-border: #1f2630; --head-bg: #11161d; --head-fg: #8b98a9;
|
||||
--c-dim: #5b6675; --c-ip: #c9d4e3; --c-ok: #8ce99a; --c-bad: #ff8585; --c-warn: #ffd166;
|
||||
--c-ping: #6ea8ff; --c-lat: #54c7ec; --c-spin: #7cc4ff; --c-confirm: #d6a8ff; --c-empty: #3a434f;
|
||||
--code-bg: #0d1117; --code-fg: #7cc4ff;
|
||||
}
|
||||
[data-theme="light"] {
|
||||
--panel-bg: #ffffff; --panel-border: #d0d7de; --head-bg: #f3f5f8; --head-fg: #57606a;
|
||||
--c-dim: #8c959f; --c-ip: #1f2328; --c-ok: #1a7f37; --c-bad: #cf222e; --c-warn: #9a6700;
|
||||
--c-ping: #0969da; --c-lat: #0550ae; --c-spin: #0969da; --c-confirm: #8250df; --c-empty: #afb8c1;
|
||||
--code-bg: #f6f8fa; --code-fg: #0550ae;
|
||||
}
|
||||
body { max-width: 1180px; margin: 0 auto; padding: 2rem 1rem 4rem; }
|
||||
|
||||
/* status bar — pinned at the very top, full width */
|
||||
.status { display: flex; gap: 1.2rem; flex-wrap: wrap; align-items: center;
|
||||
font-size: .85rem; padding: .6rem .9rem; border-radius: var(--pico-border-radius);
|
||||
background: var(--pico-card-background-color); border: 1px solid var(--panel-border);
|
||||
margin-bottom: 1.4rem; }
|
||||
.status .k { color: var(--pico-muted-color); margin-right: .35rem; }
|
||||
.status .v { font-variant-numeric: tabular-nums; font-weight: 600; }
|
||||
.pill { margin-left: auto; padding: .1rem .55rem; border-radius: 999px; font-size: .75rem; font-weight: 600; }
|
||||
.pill.searching { background: #1e3a5f; color: #7cc4ff; }
|
||||
.pill.confirming { background: #3d2a52; color: #d6a8ff; }
|
||||
.pill.recalibrating { background: #4a3c1a; color: #ffd166; }
|
||||
.pill.done { background: #1e4620; color: #8ce99a; }
|
||||
[data-theme="light"] .pill.searching { background: #ddf4ff; color: #0969da; }
|
||||
[data-theme="light"] .pill.confirming { background: #f3e8ff; color: #8250df; }
|
||||
[data-theme="light"] .pill.recalibrating { background: #fff8c5; color: #9a6700; }
|
||||
[data-theme="light"] .pill.done { background: #dafbe1; color: #1a7f37; }
|
||||
|
||||
.theme-toggle { width: auto; margin: 0; padding: .3rem .55rem; font-size: 1.05rem; line-height: 1;
|
||||
border-radius: 8px; background: var(--pico-card-background-color);
|
||||
border: 1px solid var(--pico-muted-border-color); color: var(--pico-color); cursor: pointer; }
|
||||
.theme-toggle:hover { border-color: var(--pico-primary); }
|
||||
|
||||
.brand { display: flex; align-items: center; justify-content: space-between; gap: .6rem; margin-bottom: 1.2rem; }
|
||||
.brand h1 { margin: 0; font-size: 1.5rem; letter-spacing: .5px; }
|
||||
.brand .sub { color: var(--pico-muted-color); font-size: .85rem; }
|
||||
|
||||
/* two columns: left = input + result, right = process */
|
||||
.cols { display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr); gap: 1.4rem; align-items: start; }
|
||||
@media (max-width: 760px) { .cols { grid-template-columns: 1fr; } }
|
||||
.col { display: flex; flex-direction: column; gap: 1rem; min-width: 0; }
|
||||
.col-title { font-size: .72rem; letter-spacing: .5px; text-transform: uppercase;
|
||||
color: var(--pico-muted-color); margin: .3rem 0 -.4rem .2rem; }
|
||||
|
||||
/* form */
|
||||
form { margin: 0; }
|
||||
label small { color: var(--pico-muted-color); font-weight: normal; }
|
||||
textarea { font-family: var(--mono); font-size: .82rem; min-height: 10rem; resize: vertical; }
|
||||
.go { display: flex; justify-content: space-between; align-items: center; gap: .8rem; margin-top: .3rem; }
|
||||
.go select { width: auto; min-width: 120px; margin: 0; }
|
||||
#start { width: auto; padding: .6rem 1.8rem; margin: 0; }
|
||||
|
||||
/* terminal-ish live panels — fixed heights so they never jitter */
|
||||
.panel { background: var(--panel-bg); border: 1px solid var(--panel-border); border-radius: var(--pico-border-radius);
|
||||
font-family: var(--mono); font-size: .8rem; line-height: 1.65; overflow: hidden; }
|
||||
.panel > .head { display: flex; justify-content: space-between; padding: .35rem .8rem;
|
||||
color: var(--head-fg); background: var(--head-bg); border-bottom: 1px solid var(--panel-border);
|
||||
font-size: .72rem; letter-spacing: .5px; text-transform: uppercase; }
|
||||
.panel > .body { padding: .5rem .8rem; overflow: hidden;
|
||||
height: calc(var(--rows, 3) * 1.65em + 1em); }
|
||||
.panel .line { white-space: pre; }
|
||||
.dim { color: var(--c-dim); }
|
||||
.ip { color: var(--c-ip); }
|
||||
.ok { color: var(--c-ok); }
|
||||
.bad { color: var(--c-bad); }
|
||||
.warn { color: var(--c-warn); }
|
||||
.tag-ping { color: var(--c-ping); }
|
||||
.tag-lat { color: var(--c-lat); }
|
||||
.spin { color: var(--c-spin); }
|
||||
.spin.confirm { color: var(--c-confirm); }
|
||||
.empty { color: var(--c-empty); }
|
||||
|
||||
/* result (left column, bottom) */
|
||||
.result { display: none; }
|
||||
.result.on { display: block; }
|
||||
.result article { margin: 0; padding: 1.1rem 1.2rem; border: 1px solid #1f2630;
|
||||
border-left: 3px solid #2a3038; border-radius: var(--pico-border-radius);
|
||||
background: var(--pico-card-background-color); }
|
||||
.result.win article { border-left-color: #2ea043; box-shadow: 0 0 0 1px #2ea04322, 0 10px 30px -16px #2ea04366; }
|
||||
.result.lose article { border-left-color: #d1242f; }
|
||||
.result h3 { margin: 0 0 .8rem; font-size: 1rem; display: flex; align-items: center; gap: .45rem; }
|
||||
.metrics { display: flex; gap: 1.9rem; flex-wrap: wrap; align-items: flex-end; margin: 0; }
|
||||
.stat { display: flex; flex-direction: column; gap: .2rem; }
|
||||
.stat .lbl { font-size: .68rem; letter-spacing: .5px; text-transform: uppercase; color: var(--pico-muted-color); }
|
||||
.stat b { font-size: 1.05rem; font-family: var(--mono); font-variant-numeric: tabular-nums; font-weight: 600; }
|
||||
.stat b.big { font-size: 1.7rem; line-height: 1; }
|
||||
.stat b.big small { font-size: .85rem; font-weight: 500; color: var(--pico-muted-color); }
|
||||
.url { display: flex; gap: .6rem; align-items: flex-start; margin-top: 1rem; }
|
||||
.url code { flex: 1; min-width: 0; white-space: normal; word-break: break-all; line-height: 1.55;
|
||||
font-size: .78rem; padding: .6rem .75rem; background: var(--code-bg); border: 1px solid var(--panel-border);
|
||||
border-radius: 6px; color: var(--code-fg); }
|
||||
.url button { flex: 0 0 auto; width: auto; margin: 0; padding: .5rem 1.2rem; }
|
||||
|
||||
/* slim, low-key scrollbars everywhere (Firefox + WebKit) */
|
||||
* { scrollbar-width: thin; scrollbar-color: #2b3340 transparent; }
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #2b3340; border-radius: 8px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #3a4554; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="brand">
|
||||
<h1>fast-xray</h1>
|
||||
<button id="theme-toggle" class="theme-toggle" type="button" aria-label="切换主题">☀</button>
|
||||
</header>
|
||||
|
||||
<div class="status" id="status">
|
||||
<span><span class="k">直连</span><span class="v" id="s-base">—</span></span>
|
||||
<span><span class="k">目标</span><span class="v" id="s-target">—</span></span>
|
||||
<span><span class="k">剩余预算</span><span class="v" id="s-budget">—</span></span>
|
||||
<span><span class="k">队列</span><span class="v" id="s-queue">—</span></span>
|
||||
<span class="pill searching" id="s-phase">准备中</span>
|
||||
</div>
|
||||
|
||||
<div class="cols">
|
||||
<!-- LEFT: input + result -->
|
||||
<div class="col">
|
||||
<form id="form">
|
||||
<label for="node">VLESS 节点
|
||||
<small>— 粘贴完整的 vless://… 链接</small>
|
||||
<textarea id="node" name="node" placeholder="vless://uuid@host:443?encryption=none&security=tls&type=ws&host=example.com&path=%2F#name" spellcheck="false"></textarea>
|
||||
</label>
|
||||
<div class="go">
|
||||
<select id="max" name="max" aria-label="最大个数">
|
||||
<option value="100">100</option>
|
||||
<option value="300">300</option>
|
||||
<option value="500">500</option>
|
||||
<option value="700">700</option>
|
||||
<option value="900">900</option>
|
||||
<option value="1000" selected>1000</option>
|
||||
<option value="1500">1500</option>
|
||||
<option value="2000">2000</option>
|
||||
</select>
|
||||
<button type="submit" id="start">开始优选</button>
|
||||
</div>
|
||||
<small id="hint" class="dim"></small>
|
||||
</form>
|
||||
|
||||
<div class="col-title">结果</div>
|
||||
<div class="result on" id="result">
|
||||
<article>
|
||||
<h3 id="r-title">等待开始</h3>
|
||||
<div class="metrics" id="r-metrics"><span class="dim">完成后在此给出最终节点</span></div>
|
||||
<div class="url" id="r-url" style="display:none">
|
||||
<code id="r-code"></code>
|
||||
<button id="copy" class="secondary">复制</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: process (fixed-height panels) -->
|
||||
<div class="col">
|
||||
<div class="col-title">过程</div>
|
||||
<div class="panel">
|
||||
<div class="head"><span>discovering · ping + latency</span><span id="p-queue"></span></div>
|
||||
<div class="body" id="feed" style="--rows:5"><span class="empty">等待发现…</span></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="head"><span id="testing-head">testing</span></div>
|
||||
<div class="body" id="testing" style="--rows:5"><span class="empty">空闲</span></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="head"><span>recent · 已测结果</span></div>
|
||||
<div class="body" id="recent" style="--rows:3"><span class="empty">暂无结果</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Vendored
+4
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user