Files
av-parser/src/AvParser.UI/ViewModels/ProxiesViewModel.cs
T
Leonid PershinandClaude Opus 5 44fb0d3a5f Gate network parsers on a working proxy and remember what worked
The pool now warms up from what the previous run learned instead of starting
cold every launch. Startup probes the remembered proxies first, stops as soon
as ProxyMinimumLive of them answer, and writes the survivors to
proxies.state.json after the warm-up and again on shutdown. Only proxies that
ever answered are stored: the feed republishes a few thousand dead addresses
every five minutes, and "was dead an hour ago" says almost nothing.

Remembered state is a hint, not a verdict. A restored proxy sorts first in the
warm-up queue but is not counted live until it answers in this session -
otherwise a launch a week later would report live proxies it had never spoken
to, the warm-up would skip the very entries it exists to re-check, and the
parser gate would open on week-old evidence.

That gate is the other half: a parser declaring RequiresNetwork will not run
while the pool has nothing live. The Parse page disables the run button and
shows a banner that leads to the Proxies page. Parsers that work on pasted text
are never gated - they have nothing to route, and blocking them would make the
app useless whenever the public lists are down. Two new settings cover the
escape hatch and the target: "allow network parsers without a proxy" and how
many live proxies to find at startup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 19:11:51 +03:00

409 lines
15 KiB
C#

using System.Collections.ObjectModel;
using System.Globalization;
using AvParser.Core.Proxies;
using AvParser.Infrastructure.Proxies;
using AvParser.UI.Localization;
using Microsoft.Extensions.Logging;
using ReactiveUI;
using ReactiveUI.Primitives;
using ReactiveUI.Primitives.Concurrency;
using ReactiveUI.Primitives.Signals;
using ReactiveUI.SourceGenerators;
namespace AvParser.UI.ViewModels;
/// <summary>Health filter offered by the proxy list.</summary>
public enum ProxyHealthFilter
{
/// <summary>No filtering.</summary>
All,
/// <summary>Only proxies whose last check succeeded.</summary>
Alive,
/// <summary>Only proxies whose last check failed.</summary>
Dead,
/// <summary>Only proxies that were never checked.</summary>
Unchecked,
}
/// <summary>Manages the proxy pool: refresh from the feed, probe, and edit the custom list.</summary>
public partial class ProxiesViewModel : PageViewModel, IDisposable
{
/// <summary>
/// Rows rendered at once. A free feed carries a few thousand proxies, and a virtualising list
/// still pays to build every row view model, so the view is capped and says so.
/// </summary>
private const int MaxDisplayedRows = 500;
private readonly IProxyPool _pool;
private readonly IMutableProxySource _customSource;
private readonly ILogger<ProxiesViewModel> _logger;
private readonly ISequencer _mainThread;
private readonly Dictionary<string, ProxyRowViewModel> _rows = new(StringComparer.Ordinal);
private readonly Signal<RxVoid> _poolChanged = new();
/// <summary>Free-text filter over address and country.</summary>
[Reactive]
public partial string SearchText { get; set; }
/// <summary>Protocol filter; <see cref="ProxyProtocolFilter.All"/> means no filtering.</summary>
[Reactive]
public partial LocalizedOption<ProxyProtocolFilter> ProtocolFilter { get; set; }
/// <summary>Health filter.</summary>
[Reactive]
public partial LocalizedOption<ProxyHealthFilter> HealthFilter { get; set; }
/// <summary>Text box contents for adding custom proxies.</summary>
[Reactive]
public partial string NewProxies { get; set; }
/// <summary>Currently selected row.</summary>
[Reactive]
public partial ProxyRowViewModel? SelectedProxy { get; set; }
/// <summary>Outcome of the last action; <see langword="null"/> when idle.</summary>
[Reactive]
public partial string? StatusMessage { get; set; }
/// <summary>Progress of the running sweep, 0..1.</summary>
[Reactive]
public partial double SweepProgress { get; set; }
/// <summary>Whether a sweep is running.</summary>
[Reactive]
public partial bool IsSweeping { get; set; }
/// <summary>How many rows the filter matched before the display cap.</summary>
[Reactive]
public partial int MatchedCount { get; set; }
/// <summary>Total entries in the pool.</summary>
[Reactive]
public partial int TotalCount { get; set; }
/// <summary>How many entries are currently alive.</summary>
[Reactive]
public partial int AliveCount { get; set; }
/// <summary>Creates the page.</summary>
/// <param name="pool">The pool being managed.</param>
/// <param name="customSource">The user's editable list.</param>
/// <param name="loader">The shared startup load, joined so the page can report its outcome.</param>
/// <param name="logger">Diagnostics.</param>
/// <param name="mainThread">Scheduler for UI-affine updates; tests pass an immediate one.</param>
public ProxiesViewModel(
IProxyPool pool,
IMutableProxySource customSource,
IProxyPoolLoader loader,
ILogger<ProxiesViewModel> logger,
ISequencer? mainThread = null
)
{
_pool = pool ?? throw new ArgumentNullException(nameof(pool));
_customSource = customSource ?? throw new ArgumentNullException(nameof(customSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_mainThread = mainThread ?? RxSchedulers.MainThreadScheduler;
ArgumentNullException.ThrowIfNull(loader);
SearchText = string.Empty;
NewProxies = string.Empty;
ProtocolFilter = ProtocolFilters[0];
HealthFilter = HealthFilters[0];
var idle = this.WhenAnyValue(x => x.IsSweeping).Select(static sweeping => !sweeping);
RefreshCommand = ReactiveCommand.CreateFromTask(RefreshAsync, idle, _mainThread);
SweepCommand = ReactiveCommand.CreateFromTask(SweepAsync, idle, _mainThread);
AddCustomCommand = ReactiveCommand.CreateFromTask(AddCustomAsync, idle, _mainThread);
ClearCustomCommand = ReactiveCommand.CreateFromTask(ClearCustomAsync, idle, _mainThread);
RemoveSelectedCommand = ReactiveCommand.CreateFromTask(
RemoveSelectedAsync,
this.WhenAnyValue(x => x.SelectedProxy).Select(static row => row is { IsCustom: true }),
_mainThread
);
// The pool raises Changed on every single lease outcome; rebuilding the view that often
// would make a running parse unusable, so coalesce into one refresh per burst.
_poolChanged
.Throttle(TimeSpan.FromMilliseconds(250), _mainThread)
.ObserveOn(_mainThread)
.Subscribe(_ => Rebuild());
_pool.Changed += OnPoolChanged;
this.WhenAnyValue(x => x.SearchText, x => x.ProtocolFilter, x => x.HealthFilter, (_, _, _) => RxVoid.Default)
.Throttle(TimeSpan.FromMilliseconds(150), _mainThread)
.ObserveOn(_mainThread)
.Subscribe(_ => Rebuild());
foreach (var command in new[] { RefreshCommand, SweepCommand, AddCustomCommand, ClearCustomCommand })
{
command.ThrownExceptions.Subscribe(OnCommandFailed);
}
RemoveSelectedCommand.ThrownExceptions.Subscribe(OnCommandFailed);
Rebuild();
// The pool is normally already loading by the time this page is built, but joining the
// same operation means the page reports the outcome whether it is opened during the load
// or long after it. Not awaited — a constructor cannot be, and the task never throws.
_ = ReportInitialLoadAsync(loader);
}
/// <inheritdoc />
public override string TitleKey => "Page.Proxies";
/// <inheritdoc />
public override string IconKey => "IconShield";
/// <summary>Rows currently shown, already filtered and capped.</summary>
public ObservableCollection<ProxyRowViewModel> Proxies { get; } = [];
/// <summary>Protocol filter options.</summary>
public IReadOnlyList<LocalizedOption<ProxyProtocolFilter>> ProtocolFilters { get; } =
LocalizedOption<ProxyProtocolFilter>.For(
ProxyProtocolFilter.All,
ProxyProtocolFilter.Http,
ProxyProtocolFilter.Https,
ProxyProtocolFilter.Socks4,
ProxyProtocolFilter.Socks5
);
/// <summary>Health filter options.</summary>
public IReadOnlyList<LocalizedOption<ProxyHealthFilter>> HealthFilters { get; } =
LocalizedOption<ProxyHealthFilter>.ForAll();
/// <summary>Reloads every source into the pool.</summary>
public ReactiveCommand<RxVoid, RxVoid> RefreshCommand { get; }
/// <summary>Probes the whole pool.</summary>
public ReactiveCommand<RxVoid, RxVoid> SweepCommand { get; }
/// <summary>Adds the addresses typed into <see cref="NewProxies"/>.</summary>
public ReactiveCommand<RxVoid, RxVoid> AddCustomCommand { get; }
/// <summary>Removes the selected custom proxy.</summary>
public ReactiveCommand<RxVoid, RxVoid> RemoveSelectedCommand { get; }
/// <summary>Empties the custom list.</summary>
public ReactiveCommand<RxVoid, RxVoid> ClearCustomCommand { get; }
private async Task ReportInitialLoadAsync(IProxyPoolLoader loader)
{
var result = await loader.EnsureLoadedAsync().ConfigureAwait(false);
var message = Localizer.Instance.Format(
"Proxies.Status.Ready",
Localizer.Instance.Plural("Proxies.Count.Proxies", result.Total),
result.Live
);
// Anything the user has done since — an add, a check — is more interesting than the
// startup count, so do not overwrite it.
OnUi(() => StatusMessage ??= message);
}
private async Task RefreshAsync(CancellationToken cancellationToken)
{
var count = await _pool.RefreshAsync(cancellationToken).ConfigureAwait(false);
var message = Localizer.Instance.Format(
"Proxies.Status.PoolHolds",
Localizer.Instance.Plural("Proxies.Count.Proxies", count)
);
OnUi(() => StatusMessage = message);
}
private async Task SweepAsync(CancellationToken cancellationToken)
{
OnUi(() =>
{
IsSweeping = true;
SweepProgress = 0d;
StatusMessage = null;
});
try
{
var progress = new Progress<ProxySweepProgress>(value => OnUi(() => SweepProgress = value.Fraction));
var alive = await _pool.SweepAsync(progress, cancellationToken).ConfigureAwait(false);
var total = _pool.Entries.Count;
var message = Localizer.Instance.Format("Proxies.Status.Answered", Format(alive), Format(total));
OnUi(() => StatusMessage = message);
}
catch (OperationCanceledException)
{
OnUi(() => StatusMessage = Localizer.Instance["Proxies.Status.CheckCancelled"]);
}
finally
{
OnUi(() =>
{
IsSweeping = false;
SweepProgress = 0d;
});
}
}
private async Task AddCustomAsync(CancellationToken cancellationToken)
{
var (parsed, rejected) = CustomProxySource.ParseList(NewProxies);
var loc = Localizer.Instance;
if (parsed.Count == 0 && rejected.Count == 0)
{
OnUi(() => StatusMessage = loc["Proxies.Status.Nothing"]);
return;
}
var added = await _customSource.AddAsync(parsed, cancellationToken).ConfigureAwait(false);
await _pool.RefreshAsync(cancellationToken).ConfigureAwait(false);
var message = loc.Format("Proxies.Status.Added", Format(added), Format(parsed.Count));
if (rejected.Count > 0)
{
// Naming the first few beats "3 lines were invalid" when a paste is hundreds long.
var sample = string.Join(", ", rejected.Take(3));
if (rejected.Count > 3)
{
sample += " " + loc.Format("Proxies.Status.RejectedMore", Format(rejected.Count - 3));
}
message += " " + loc.Format("Proxies.Status.Rejected", sample);
}
OnUi(() =>
{
StatusMessage = message;
if (added > 0)
{
NewProxies = string.Empty;
}
});
}
private async Task RemoveSelectedAsync(CancellationToken cancellationToken)
{
if (SelectedProxy is not { IsCustom: true } row)
{
return;
}
await _customSource.RemoveAsync(row.Entry.Endpoint, cancellationToken).ConfigureAwait(false);
await _pool.RefreshAsync(cancellationToken).ConfigureAwait(false);
OnUi(() => StatusMessage = Localizer.Instance.Format("Proxies.Status.Removed", row.Address));
}
private async Task ClearCustomAsync(CancellationToken cancellationToken)
{
await _customSource.ClearAsync(cancellationToken).ConfigureAwait(false);
await _pool.RefreshAsync(cancellationToken).ConfigureAwait(false);
OnUi(() => StatusMessage = Localizer.Instance["Proxies.Status.Cleared"]);
}
/// <inheritdoc />
protected override void OnLanguageChanged()
{
base.OnLanguageChanged();
// Health and source captions live on the rows, so they need the nudge individually.
foreach (var row in _rows.Values)
{
row.Refresh();
}
}
/// <inheritdoc />
/// <remarks>
/// Detaching from the pool matters: the pool is a singleton and would otherwise keep this
/// page — and every row in it — alive for the process lifetime.
/// </remarks>
public void Dispose()
{
_pool.Changed -= OnPoolChanged;
_poolChanged.Dispose();
GC.SuppressFinalize(this);
}
private void OnPoolChanged(object? sender, EventArgs e) => _poolChanged.OnNext(RxVoid.Default);
private void Rebuild()
{
var entries = _pool.Entries;
// Reconcile rows rather than recreating them: a row rebuilt on every pool event would
// drop the user's selection mid-sweep.
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var entry in entries)
{
var key = entry.Endpoint.Key;
seen.Add(key);
if (_rows.TryGetValue(key, out var existing))
{
existing.Refresh();
}
else
{
_rows[key] = new ProxyRowViewModel(entry);
}
}
foreach (var stale in _rows.Keys.Where(key => !seen.Contains(key)).ToArray())
{
_rows.Remove(stale);
}
var matched = _rows.Values.Where(PassesFilters).OrderBy(row => row.Address, StringComparer.Ordinal).ToArray();
Proxies.Clear();
foreach (var row in matched.Take(MaxDisplayedRows))
{
Proxies.Add(row);
}
MatchedCount = matched.Length;
TotalCount = entries.Count;
AliveCount = entries.Count(entry => entry.Health == ProxyHealthState.Alive);
}
private bool PassesFilters(ProxyRowViewModel row)
{
if (
ProtocolFilter.Value != ProxyProtocolFilter.All
&& !ProtocolFilter.Value.HasFlag(ProxyOptions.ToFlag(row.Entry.Endpoint.Protocol))
)
{
return false;
}
var healthOk = HealthFilter.Value switch
{
ProxyHealthFilter.Alive => row.Entry.Health == ProxyHealthState.Alive,
ProxyHealthFilter.Dead => row.Entry.Health == ProxyHealthState.Dead,
ProxyHealthFilter.Unchecked => row.Entry.Health == ProxyHealthState.Unknown,
_ => true,
};
return healthOk && row.Matches(SearchText);
}
private void OnCommandFailed(Exception exception)
{
_logger.LogError(exception, "Proxy action failed");
OnUi(() => StatusMessage = Localizer.Instance.Format("Proxies.Status.Failed", exception.Message));
}
private static string Format(int value) => value.ToString("N0", CultureInfo.CurrentCulture);
private void OnUi(Action action) => _mainThread.Schedule(action);
}