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:
2026-06-26 10:49:57 +08:00
Unverified
parent 9de35210b7
commit 096e70fe54
16 changed files with 1134 additions and 112 deletions
Generated
+15 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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).
+67
View 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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
},
}
}
+122
View File
@@ -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())
}
+60
View File
@@ -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();
}
+115
View File
@@ -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()
}
+89
View File
@@ -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(),
}
}
+245
View File
@@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[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);
}
+187
View File
@@ -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>
+4
View File
File diff suppressed because one or more lines are too long