feat: implement saved resolvers feature with UI prompt and API integration

This commit is contained in:
Sarto
2026-04-06 23:58:48 +03:30
parent 956856562e
commit 63d46b4540
4 changed files with 289 additions and 36 deletions
+21 -3
View File
@@ -120,6 +120,21 @@ func (f *Fetcher) SetRateLimit(qps float64) {
f.rateQPS = qps
}
// ScanConcurrency returns how many resolvers the scanner should probe in
// parallel, derived from the configured rate limit.
// Rule: concurrency = max(1, floor(rateQPS)).
// If rateQPS is 0 (unlimited), falls back to the default of 10.
func (f *Fetcher) ScanConcurrency() int {
if f.rateQPS <= 0 {
return 10
}
n := int(f.rateQPS)
if n < 1 {
n = 1
}
return n
}
// SetTimeout sets the per-query DNS timeout.
func (f *Fetcher) SetTimeout(d time.Duration) {
f.timeout = d
@@ -146,6 +161,7 @@ func (f *Fetcher) SetActiveResolvers(resolvers []string) {
defer f.mu.Unlock()
f.activeResolvers = make([]string, len(resolvers))
copy(f.activeResolvers, resolvers)
f.log("active resolvers updated: %d/%d healthy", len(resolvers), len(f.allResolvers))
}
// SetResolvers replaces the full resolver list and resets the active pool.
@@ -344,6 +360,7 @@ func (f *Fetcher) scatterQuery(ctx context.Context, resolvers []string, qname st
// Call once per fetcher configuration; creating a new fetcher replaces the old one.
func (f *Fetcher) Start(ctx context.Context) {
if f.rateQPS > 0 {
f.log("fetcher started: %d configured resolvers, rate=%.1f q/s, scatter=%d", len(f.allResolvers), f.rateQPS, f.scatter)
f.rateCh = make(chan struct{}, 1)
go f.runRateLimiter(ctx)
go f.runNoise(ctx)
@@ -473,9 +490,6 @@ func (f *Fetcher) FetchBlock(ctx context.Context, channel, block uint16) ([]byte
if err != nil {
return nil, fmt.Errorf("encode query: %w", err)
}
if f.debug {
f.log("[debug] query ch=%d blk=%d attempt=%d qname=%s", channel, block, attempt+1, qname)
}
scatter := f.scatter
if scatter < 1 {
@@ -485,6 +499,10 @@ func (f *Fetcher) FetchBlock(ctx context.Context, channel, block uint16) ([]byte
if len(picked) == 0 {
return nil, fmt.Errorf("no active resolvers")
}
if f.debug {
f.log("[debug] query ch=%d blk=%d attempt=%d qname=%s resolvers=[%s]",
channel, block, attempt+1, qname, strings.Join(picked, ","))
}
data, err := f.scatterQuery(ctx, picked, qname)
if err == nil {
+79 -29
View File
@@ -3,6 +3,7 @@ package client
import (
"context"
"fmt"
"math/rand"
"strings"
"sync"
"sync/atomic"
@@ -20,6 +21,7 @@ type ResolverChecker struct {
fetcher *Fetcher
timeout time.Duration
logFunc LogFunc
onScanDone func([]string) // called after each completed scan with healthy resolvers
started atomic.Bool // guards against double-start
scanMu sync.Mutex // protects scanCancel
scanRunMu sync.Mutex // only one CheckNow at a time (via TryLock)
@@ -43,6 +45,12 @@ func (rc *ResolverChecker) SetLogFunc(fn LogFunc) {
rc.logFunc = fn
}
// SetOnScanDone registers a callback invoked after each completed CheckNow pass
// with the list of healthy resolver addresses. Not called when the scan is cancelled.
func (rc *ResolverChecker) SetOnScanDone(fn func([]string)) {
rc.onScanDone = fn
}
// Start begins the periodic health-check loop in the background.
// An initial check runs immediately; subsequent checks happen every 10 minutes.
// ctx controls the lifetime — cancel it to stop the checker.
@@ -82,35 +90,51 @@ func (rc *ResolverChecker) StartAndNotify(ctx context.Context, onFirstDone func(
onFirstDone()
}
// Periodic re-check every 30 minutes.
ticker := time.NewTicker(30 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
rc.CheckNow(ctx)
// If the periodic check leaves us with no resolvers,
// fall back into the retry-every-minute loop.
if ctx.Err() == nil && len(rc.fetcher.Resolvers()) == 0 {
rc.log("All resolvers lost — scanning every minute until one recovers...")
for {
select {
case <-ctx.Done():
return
case <-time.After(1 * time.Minute):
}
rc.CheckNow(ctx)
if ctx.Err() != nil || len(rc.fetcher.Resolvers()) > 0 {
break
}
rc.log("Still no healthy resolvers — retrying in 1 minute...")
rc.runPeriodicLoop(ctx)
}()
}
// StartPeriodic starts only the periodic 30-minute health-check loop without
// running an initial scan. Use when resolvers are already available (e.g.
// loaded from a saved last-scan file on startup).
// Safe to call only once per checker instance; subsequent calls are no-ops.
func (rc *ResolverChecker) StartPeriodic(ctx context.Context) {
if !rc.started.CompareAndSwap(false, true) {
return
}
go rc.runPeriodicLoop(ctx)
}
// runPeriodicLoop is the shared 30-minute ticker loop used by both
// StartAndNotify and StartPeriodic.
func (rc *ResolverChecker) runPeriodicLoop(ctx context.Context) {
ticker := time.NewTicker(30 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
rc.CheckNow(ctx)
// If the periodic check leaves us with no resolvers,
// fall back into the retry-every-minute loop.
if ctx.Err() == nil && len(rc.fetcher.Resolvers()) == 0 {
rc.log("All resolvers lost — scanning every minute until one recovers...")
for {
select {
case <-ctx.Done():
return
case <-time.After(1 * time.Minute):
}
rc.CheckNow(ctx)
if ctx.Err() != nil || len(rc.fetcher.Resolvers()) > 0 {
break
}
rc.log("Still no healthy resolvers — retrying in 1 minute...")
}
}
}
}()
}
}
// CancelCurrentScan cancels any in-progress CheckNow call, causing it to
@@ -155,20 +179,43 @@ func (rc *ResolverChecker) CheckNow(ctx context.Context) bool {
return true
}
// Shuffle so each scan probes resolvers in a fresh random order, preventing
// the same resolvers from always being probed first (more even load distribution).
rand.Shuffle(len(resolvers), func(i, j int) { resolvers[i], resolvers[j] = resolvers[j], resolvers[i] })
total := len(resolvers)
concurrency := rc.fetcher.ScanConcurrency()
rc.log("RESOLVER_SCAN start %d", total)
rc.log("scanner started: probing %d resolvers (concurrency=%d, batch-pause every 50)", total, concurrency)
var healthy []string
var mu sync.Mutex
var done int
wg := &sync.WaitGroup{}
sem := make(chan struct{}, 10) // probe up to 10 resolvers concurrently
sem := make(chan struct{}, concurrency)
launched := 0
for _, r := range resolvers {
// Stop launching new probes if context was cancelled.
if scanCtx.Err() != nil {
break
}
// Rate-limit pause: every 50 launched probes, sleep 3-10 s so we don't
// flood resolver rate limits before moving to the next batch.
if launched > 0 && launched%50 == 0 {
pause := 3*time.Second + time.Duration(rand.Intn(8))*time.Second
timer := time.NewTimer(pause)
select {
case <-scanCtx.Done():
timer.Stop()
break
case <-timer.C:
}
if scanCtx.Err() != nil {
break
}
}
launched++
wg.Add(1)
go func(r string) {
defer wg.Done()
@@ -198,10 +245,13 @@ func (rc *ResolverChecker) CheckNow(ctx context.Context) bool {
if len(healthy) == 0 {
rc.log("Resolver check done: 0/%d healthy", len(resolvers))
rc.log("RESOLVER_SCAN done 0/%d", total)
return true
} else {
rc.log("Resolver check done: %d/%d healthy", len(healthy), len(resolvers))
rc.log("RESOLVER_SCAN done %d/%d", len(healthy), total)
}
if rc.onScanDone != nil {
rc.onScanDone(healthy)
}
rc.log("Resolver check done: %d/%d healthy", len(healthy), len(resolvers))
rc.log("RESOLVER_SCAN done %d/%d", len(healthy), total)
return true
}
+89 -2
View File
@@ -20,7 +20,7 @@ input,textarea,select{font-family:inherit}
/* ===== LAYOUT ===== */
.app{display:flex;height:100vh;direction:ltr}
.sidebar{width:280px;min-width:280px;background:var(--sidebar-bg);display:flex;flex-direction:column;border-right:1px solid var(--border);overflow:hidden}
.chat-area{flex:1;display:flex;flex-direction:column;background:var(--bg);overflow:hidden}
.chat-area{flex:1;display:flex;flex-direction:column;background:var(--bg);overflow:hidden;position:relative}
/* ===== SIDEBAR HEADER ===== */
.sidebar-header{padding:10px 12px;display:flex;flex-direction:column;gap:8px;border-bottom:1px solid var(--border)}
@@ -104,6 +104,13 @@ input,textarea,select{font-family:inherit}
.refresh-has-new{animation:badge-pulse 2s ease-in-out infinite;color:var(--accent)!important}
.msg-copy-btn{background:none;border:none;color:var(--text-dim);font-size:14px;cursor:pointer;padding:0 3px;line-height:1;flex-shrink:0;opacity:.45;transition:opacity .15s}.msg-copy-btn:hover{opacity:1}
/* ===== SCROLL-TO-BOTTOM ===== */
.scroll-down-btn{position:absolute;bottom:70px;right:16px;width:36px;height:36px;border:none;border-radius:50%;background:var(--surface2);color:var(--text);font-size:18px;display:none;align-items:center;justify-content:center;box-shadow:0 2px 10px rgba(0,0,0,.45);z-index:10;cursor:pointer;border:1px solid var(--border)}
.scroll-down-btn.visible{display:flex}
.scroll-down-btn:hover{background:var(--hover)}
.scroll-down-badge{position:absolute;top:-4px;right:-4px;background:var(--accent);color:#fff;font-size:9px;font-weight:700;min-width:16px;height:16px;border-radius:8px;display:none;align-items:center;justify-content:center;padding:0 3px}
.scroll-down-badge.visible{display:flex}
/* ===== LOG ===== */
.log-toggle{display:flex;align-items:center;justify-content:space-between;padding:3px 14px;background:var(--bg2);cursor:pointer;user-select:none;font-size:10px;color:var(--text-dim);letter-spacing:.5px;border-top:1px solid var(--border)}
.log-toggle:hover{color:var(--text)}
@@ -260,6 +267,7 @@ html[dir=ltr] .active-badge{margin-left:0;margin-right:6px}
<input class="send-input" id="sendInput" data-i18n-ph="write_message" placeholder="Write a message..." maxlength="4000">
<button class="send-btn" onclick="sendMessage()">&#10148;</button>
</div>
<button class="scroll-down-btn" id="scrollDownBtn" onclick="scrollToBottom()" title="Jump to latest">&#8595;<span class="scroll-down-badge" id="scrollDownBadge"></span></button>
<div class="progress-panel" id="progressPanel"></div>
<div class="log-toggle" onclick="toggleLog()"><span>LOG</span><span id="logToggleIcon">&#9654;</span></div>
<div class="log-panel hidden" id="logPanel"></div>
@@ -269,6 +277,19 @@ html[dir=ltr] .active-badge{margin-left:0;margin-right:6px}
<!-- TOAST -->
<div id="toast"></div>
<!-- ===== SAVED RESOLVERS POPUP ===== -->
<div class="modal-overlay" id="savedResolversModal">
<div class="modal" style="max-width:380px">
<h2 data-i18n="saved_resolvers_title">Quick Start</h2>
<p id="savedResolversMsg" style="font-size:13px;color:var(--text-dim);margin-bottom:16px;line-height:1.6"></p>
<div class="modal-actions">
<button class="btn btn-flat" onclick="savedResolversSkip()" data-i18n="saved_resolvers_skip">Skip</button>
<button class="btn btn-outline" onclick="savedResolversRescan()" data-i18n="saved_resolvers_rescan">Scan Again</button>
<button class="btn btn-primary" onclick="savedResolversUseNow()" data-i18n="saved_resolvers_use">Use Now</button>
</div>
</div>
</div>
<!-- ===== SETTINGS MODAL ===== -->
<div class="modal-overlay" id="settingsModal">
<div class="modal">
@@ -401,6 +422,14 @@ var I18N = {
add_manual:'✎ ساخت دستی',rescan:'بررسی مجدد',
new_messages:'پیام جدید',missed_messages:'{n} پیام از دست رفته یا حذف شده',
clear_cache:'پاک کردن کش',cache_cleared:'کش پاک شد!',
saved_resolvers_title:'شروع سریع',
saved_resolvers_msg:'آخرین اسکن ({t}) نتیجه داد: {n} سرور DNS سالم پیدا شد. همین‌ها را استفاده کنیم یا دوباره اسکن کنیم؟',
saved_resolvers_use:'استفاده کن (بدون اسکن)',
saved_resolvers_rescan:'اسکن مجدد',
saved_resolvers_skip:'بعداً',
saved_resolvers_applied:'سرورهای DNS ذخیره‌شده اعمال شدند',
minutes_ago:'دقیقه پیش',
hours_ago:'ساعت پیش',
},
en: {
search:'Search...',settings:'Settings',profiles:'Profiles',
@@ -435,6 +464,14 @@ var I18N = {
add_manual:'✎ Create Manually',rescan:'Rescan',
new_messages:'New messages',missed_messages:'{n} messages missed or deleted',
clear_cache:'Clear Cache',cache_cleared:'Cache cleared!',
saved_resolvers_title:'Quick Start',
saved_resolvers_msg:'Last scan ({t}) found {n} healthy DNS servers. Use them now (no scan needed), or scan again to re-verify.',
saved_resolvers_use:'Use Now (skip scan)',
saved_resolvers_rescan:'Scan Again',
saved_resolvers_skip:'Later',
saved_resolvers_applied:'Saved DNS servers applied!',
minutes_ago:'min ago',
hours_ago:'hr ago',
}
};
var lang = localStorage.getItem('thefeed_lang') || 'fa';
@@ -476,6 +513,7 @@ async function init(){
var r=await fetch('/api/status');var st=await r.json();
await loadProfiles();
if(!st.configured){openProfiles();return}
checkAndShowSavedResolversPrompt(st);
telegramLoggedIn=!!st.telegramLoggedIn;
serverNextFetch=st.nextFetch||0;
updateNextFetchDisplay();
@@ -514,6 +552,35 @@ async function clearCache(){
try{var r=await fetch('/api/cache/clear',{method:'POST'});var j=await r.json();if(j.ok){alert(t('cache_cleared'))}}catch(e){}
}
// ===== SAVED RESOLVERS PROMPT =====
function checkAndShowSavedResolversPrompt(status){
if(sessionStorage.getItem('thefeed_scan_prompt_shown'))return;
if(!status.lastScan||!status.lastScan.count)return;
var ls=status.lastScan;
var ageSec=Math.floor(Date.now()/1000)-ls.scannedAt;
var ageStr;
if(ageSec<3600)ageStr=Math.max(1,Math.round(ageSec/60))+' '+t('minutes_ago');
else ageStr=Math.round(ageSec/3600)+' '+t('hours_ago');
var msg=t('saved_resolvers_msg').replace('{n}',ls.count).replace('{t}',ageStr);
document.getElementById('savedResolversMsg').textContent=msg;
document.getElementById('savedResolversModal').classList.add('active');
}
function savedResolversSkip(){
// "Later" — just close, server already applied saved resolvers and refresh is underway
document.getElementById('savedResolversModal').classList.remove('active');
sessionStorage.setItem('thefeed_scan_prompt_shown','1');
}
function savedResolversUseNow(){
// Server already applied saved resolvers at startup; just close the popup
savedResolversSkip();
showToast(t('saved_resolvers_applied'));
}
async function savedResolversRescan(){
savedResolversSkip();
try{await fetch('/api/rescan',{method:'POST'})}catch(e){}
showToast(t('rescan_started'));
}
// ===== SSE =====
function connectSSE(){
if(eventSource)eventSource.close();
@@ -902,6 +969,7 @@ async function selectChannel(num){
document.getElementById('chatName').textContent=name;
renderChannels();updateSendPanel();
document.getElementById('messages').innerHTML='<div class="empty-state"><p>'+t('loading')+'</p></div>';
document.getElementById('scrollDownBtn').classList.remove('visible');
// Show immediate feedback progress bar
showChannelFetchProgress(num,name);
await loadMessages(num);
@@ -1017,7 +1085,7 @@ function renderMessages(msgs){
html+='<div class="msg'+(isPersian(text)?' rtl-msg':'')+'" dir="auto">'+mediaHtml+textHtml+'<div class="msg-meta"><button class="msg-copy-btn" onclick="copyMsg('+i+')" title="'+t('copy')+'">&#128203;</button><span>#'+id+'</span><span>'+timeStr+'</span></div></div>';
}
el.innerHTML=html;
if(isFirstRender||wasAtBottom)el.scrollTop=el.scrollHeight;
if(isFirstRender||wasAtBottom){el.scrollTop=el.scrollHeight;document.getElementById('scrollDownBtn').classList.remove('visible');}
}
// ===== LOG =====
@@ -1270,6 +1338,25 @@ function copyMsg(idx){
navigator.clipboard.writeText(text).then(function(){showToast(t('msg_copied'))}).catch(function(){});
}
// ===== SCROLL TO BOTTOM =====
(function(){
var messagesEl=null;
function initScrollBtn(){
messagesEl=document.getElementById('messages');
if(!messagesEl)return;
messagesEl.addEventListener('scroll',function(){
var atBottom=messagesEl.scrollHeight-messagesEl.scrollTop-messagesEl.clientHeight<150;
document.getElementById('scrollDownBtn').classList.toggle('visible',!atBottom);
});
}
// Init after DOM is ready
document.addEventListener('DOMContentLoaded',initScrollBtn,{once:true});
})();
function scrollToBottom(){
var el=document.getElementById('messages');
if(el)el.scrollTop=el.scrollHeight;
}
// ===== TOAST =====
function showToast(msg){
var el=document.getElementById('toast');el.textContent=msg;el.classList.add('show');
+100 -2
View File
@@ -10,6 +10,7 @@ import (
"fmt"
"io/fs"
"log"
mrand "math/rand/v2"
"net/http"
"os"
"path/filepath"
@@ -57,6 +58,12 @@ type ProfileList struct {
Debug bool `json:"debug,omitempty"`
}
// lastScanData is the on-disk structure for last_scan.json.
type lastScanData struct {
Resolvers []string `json:"resolvers"`
ScannedAt int64 `json:"scannedAt"`
}
// Server is the web UI server for thefeed client.
type Server struct {
dataDir string
@@ -165,6 +172,7 @@ func (s *Server) Run() error {
mux.HandleFunc("/api/profiles/switch", s.handleProfileSwitch)
mux.HandleFunc("/api/settings", s.handleSettings)
mux.HandleFunc("/api/cache/clear", s.handleClearCache)
mux.HandleFunc("/api/resolvers/apply-saved", s.handleApplySavedResolvers)
mux.HandleFunc("/", s.handleIndex)
addr := fmt.Sprintf("127.0.0.1:%d", s.port)
@@ -172,7 +180,16 @@ func (s *Server) Run() error {
fmt.Printf("\n Open in browser: http://%s\n\n", addr)
if s.fetcher != nil {
s.startCheckerThenRefresh()
if ls := s.loadLastScan(); ls != nil {
// Fast path: apply saved healthy resolvers immediately and skip the
// initial full scan. Only the periodic 30-min checker starts.
// This gives the UI near-instant channel data on app open.
s.fetcher.SetActiveResolvers(ls.Resolvers)
s.checker.StartPeriodic(s.fetcherCtx)
go s.refreshMetadataOnly()
} else {
s.startCheckerThenRefresh()
}
}
var handler http.Handler = mux
@@ -227,6 +244,14 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
status["channels"] = s.channels
status["telegramLoggedIn"] = s.telegramLoggedIn
status["nextFetch"] = s.nextFetch
// Include last resolver scan if recent (<24 h) so the frontend can offer a quick-start.
if ls := s.loadLastScan(); ls != nil {
status["lastScan"] = map[string]any{
"resolvers": ls.Resolvers,
"scannedAt": ls.ScannedAt,
"count": len(ls.Resolvers),
}
}
}
writeJSON(w, status)
}
@@ -344,7 +369,24 @@ func (s *Server) handleRescan(w http.ResponseWriter, r *http.Request) {
return
}
go func() {
// Cancel any in-progress metadata refresh so it doesn't race with the
// scan — we want fresh resolver data before we hit DNS again.
s.refreshMu.Lock()
if s.refreshCancel != nil {
s.refreshCancel()
s.refreshCancel = nil
}
s.refreshMu.Unlock()
if checker.CheckNow(baseCtx) {
// Cool-down: give resolvers time to recover from the scan's DNS
// queries before we immediately hit them again with a fetch.
sleep := 3*time.Second + time.Duration(mrand.IntN(13))*time.Second // 315 s
select {
case <-baseCtx.Done():
return
case <-time.After(sleep):
}
s.refreshMetadataOnly()
}
}()
@@ -610,6 +652,11 @@ func (s *Server) initFetcher() error {
checker.SetLogFunc(func(msg string) {
s.addLog(msg)
})
checker.SetOnScanDone(func(healthy []string) {
if len(healthy) > 0 {
s.saveLastScan(healthy)
}
})
s.checker = checker
s.fetcher = fetcher
@@ -727,7 +774,7 @@ func (s *Server) refreshMetadataOnly() {
s.refreshMu.Unlock()
}()
s.addLog("Fetching metadata...")
s.addLog(fmt.Sprintf("Fetching metadata... (%d active resolvers)", len(fetcher.Resolvers())))
// If the server's next Telegram fetch is imminent (within 5 s), wait for it first.
if dl := s.nextFetchDeadline(); !dl.IsZero() && time.Until(dl) < 5*time.Second {
@@ -961,6 +1008,57 @@ func (s *Server) loadConfig() (*Config, error) {
return &cfg, nil
}
// saveLastScan persists the healthy resolver list from the most recent scan.
func (s *Server) saveLastScan(resolvers []string) {
d := lastScanData{Resolvers: resolvers, ScannedAt: time.Now().Unix()}
b, err := json.MarshalIndent(d, "", " ")
if err != nil {
return
}
_ = os.WriteFile(filepath.Join(s.dataDir, "last_scan.json"), b, 0600)
}
// loadLastScan reads the most recent resolver scan result.
// Returns nil when the file doesn't exist or is older than 24 hours.
func (s *Server) loadLastScan() *lastScanData {
b, err := os.ReadFile(filepath.Join(s.dataDir, "last_scan.json"))
if err != nil {
return nil
}
var d lastScanData
if err := json.Unmarshal(b, &d); err != nil {
return nil
}
if len(d.Resolvers) == 0 || time.Since(time.Unix(d.ScannedAt, 0)) > 24*time.Hour {
return nil
}
return &d
}
// handleApplySavedResolvers immediately activates the resolvers from the last
// scan file, letting the UI skip the current scan and start fetching channels.
func (s *Server) handleApplySavedResolvers(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", 405)
return
}
ls := s.loadLastScan()
if ls == nil {
http.Error(w, "no saved scan", 400)
return
}
s.mu.RLock()
fetcher := s.fetcher
s.mu.RUnlock()
if fetcher == nil {
http.Error(w, "not configured", 400)
return
}
fetcher.SetActiveResolvers(ls.Resolvers)
go s.refreshMetadataOnly()
writeJSON(w, map[string]any{"ok": true, "count": len(ls.Resolvers)})
}
func (s *Server) saveConfig(cfg *Config) error {
path := filepath.Join(s.dataDir, "config.json")
data, err := json.MarshalIndent(cfg, "", " ")