mirror of
https://github.com/sartoopjj/thefeed.git
synced 2026-05-19 08:04:42 +03:00
better resolver score board (faster load) +UI features
This commit is contained in:
+7
-1
@@ -36,6 +36,11 @@ thefeed یک سیستم تونل DNS است که به شما اجازه می
|
||||
- فشردهسازی پیامها (deflate)
|
||||
- محافظت رابط وب با رمز عبور (`--password` سمت کلاینت)
|
||||
- لاگ زنده درخواستهای DNS در مرورگر
|
||||
- **جستجوی پیامها**: جستجو در پیامهای کانال فعلی با هایلایت نتایج و ناوبری قبلی/بعدی
|
||||
- **خروجی پیامها**: کپی N پیام آخر یک کانال به کلیپبورد
|
||||
- **نمایش ریزالورهای فعال**: مشاهده لیست ریزالورهای سالم و فعال از تنظیمات
|
||||
- **تصویر پسزمینه**: تنظیم URL تصویر پسزمینه برای پنل پیامها (ذخیره محلی)
|
||||
- **تایماوت DNS**: تنظیم تایماوت کوئری DNS برای هر پروفایل (پیشفرض ۱۵ ثانیه)
|
||||
- **اسکنر ریزالور**: اسکن بازههای IP و CIDR برای پیدا کردن سرورهای DNS کارآمد
|
||||
|
||||
### اسکنر ریزالور
|
||||
@@ -44,11 +49,12 @@ thefeed یک سیستم تونل DNS است که به شما اجازه می
|
||||
|
||||
- **اهداف متنوع**: آیپیهای تکی، CIDR (مثل `5.1.0.0/16`)، یا نام دامنه — هر خط یکی
|
||||
- **بارگذاری CIDR ایران**: دکمه یککلیکی برای بارگذاری لیست بازههای ISP ایران
|
||||
- **پاک کردن اهداف**: دکمه برای پاک کردن سریع لیست CIDR/IP اسکنر
|
||||
- **انتخاب پروفایل**: انتخاب کنید کدام پروفایل برای تست استفاده شود
|
||||
- **قابل تنظیم**: همزمانی (پیشفرض ۵۰)، تایماوت (پیشفرض ۱۵ ثانیه)، حداکثر آیپی
|
||||
- **گسترش /24**: وقتی ریزالور کارآمد پیدا شد، آیپیهای نزدیک در همان /24 هم بررسی میشوند
|
||||
- **مکث / ادامه / توقف**: کنترل کامل روی اسکنهای طولانی (مکث واقعاً ارسال درخواستهای جدید را متوقف میکند)
|
||||
- **زمان پاسخ**: نتایج شامل تأخیر هستند تا سریعترینها را انتخاب کنید
|
||||
- **زمان پاسخ**: نتایج بر اساس تأخیر مرتب شدهاند تا سریعترینها اول نمایش داده شوند
|
||||
- **انتخاب نتایج**: چکباکس برای انتخاب ریزالورهای مورد نظر
|
||||
- **اعمال نتایج**: افزودن یا جایگزینی لیست ریزالورهای پروفایل مستقیم از اسکنر
|
||||
- **کپی**: دکمه کپی برای هر آیپی، کپی انتخابشدهها، یا کپی همه
|
||||
|
||||
@@ -310,6 +310,8 @@ chmod +x thefeed-client
|
||||
|
||||
Also available: `thefeed-android-arm64-upx.apk` (UPX-compressed embedded client).
|
||||
|
||||
The Android app automatically requests battery optimization exemption on first launch so the background service is not killed by the OS.
|
||||
|
||||
|
||||
You can build or download a native Android app that:
|
||||
- runs thefeed client binary in a foreground/background service
|
||||
@@ -354,8 +356,13 @@ The browser-based UI has:
|
||||
- **New message badges**: visual indicators for channels with new messages
|
||||
- **Next-fetch timer**: countdown to next automatic refresh
|
||||
- **Media detection**: `[IMAGE]`, `[VIDEO]`, `[DOCUMENT]` tag highlighting
|
||||
- **Message search**: search within the current channel's messages with match highlighting and prev/next navigation
|
||||
- **Export messages**: export the last N messages of a channel to clipboard
|
||||
- **Log panel** (bottom): live DNS query log
|
||||
- **Settings modal**: configure domain, passphrase, resolvers, query mode, rate limit, concurrent requests (scatter), timeout, debug mode
|
||||
- **Working resolvers**: view the list of currently active/healthy resolvers from settings
|
||||
- **Background image**: set a custom background image URL for the messages panel (stored locally)
|
||||
- **DNS query timeout**: configurable per-profile DNS query timeout (default 15s) in the profile editor
|
||||
- **Per-profile cache**: 1-hour browser cache so data is visible instantly on reopen
|
||||
- **Resolver Scanner**: scan IP ranges and CIDRs to discover working DNS resolvers
|
||||
|
||||
@@ -365,11 +372,12 @@ The web UI includes a built-in resolver scanner (🔍 icon in sidebar) that prob
|
||||
|
||||
- **Flexible targets**: enter individual IPs, CIDRs (e.g. `5.1.0.0/16`), or domain names — one per line
|
||||
- **Iran CIDRs preset**: one-click button to load a curated list of Iranian ISP ranges
|
||||
- **Clear targets**: button to quickly clear the scanner CIDR/IP list
|
||||
- **Profile-aware**: select which profile's domain and passphrase to use for probing
|
||||
- **Configurable**: set concurrency (default 50), timeout (default 15s), and max IPs to scan
|
||||
- **Expand /24**: when a working resolver is found, automatically scan all nearby IPs in the same /24 subnet
|
||||
- **Pause / Resume / Stop**: full control over long-running scans (pause actually stops dispatching new probes)
|
||||
- **Response time**: results include latency so you can pick the fastest resolvers
|
||||
- **Response time**: results are sorted by latency so the fastest resolvers appear first
|
||||
- **Selectable results**: checkboxes to select which resolvers to apply or copy
|
||||
- **Apply results**: append to or overwrite your profile's resolver list directly from the scanner
|
||||
- **Copy**: per-IP copy buttons, copy selected, or copy all discovered resolver IPs
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
|
||||
@@ -8,7 +8,9 @@ import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.PowerManager
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import android.webkit.WebResourceError
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebSettings
|
||||
@@ -58,6 +60,7 @@ class MainActivity : ComponentActivity() {
|
||||
txtStatus = findViewById(R.id.txtStatus)
|
||||
|
||||
requestNotificationPermission()
|
||||
requestDisableBatteryOptimization()
|
||||
configureWebView()
|
||||
registerBackHandler()
|
||||
startThefeedService()
|
||||
@@ -88,6 +91,23 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("BatteryLife")
|
||||
private fun requestDisableBatteryOptimization() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
if (!pm.isIgnoringBatteryOptimizations(packageName)) {
|
||||
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
||||
data = Uri.parse("package:$packageName")
|
||||
}
|
||||
try {
|
||||
startActivity(intent)
|
||||
} catch (_: Exception) {
|
||||
// Some devices don't support this intent
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startThefeedService() {
|
||||
val intent = Intent(this, ThefeedService::class.java)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
|
||||
@@ -41,7 +41,7 @@ func (s *resolverStat) score() float64 {
|
||||
totalMs := atomic.LoadInt64(&s.totalMs)
|
||||
total := success + failure
|
||||
if total == 0 {
|
||||
return 1.0 // no data yet → neutral weight
|
||||
return 0.2 // no data yet → low initial weight
|
||||
}
|
||||
successRate := float64(success) / float64(total)
|
||||
var avgMs float64
|
||||
@@ -50,8 +50,12 @@ func (s *resolverStat) score() float64 {
|
||||
} else {
|
||||
avgMs = 30000 // 30 s effective penalty for 0% success resolvers
|
||||
}
|
||||
// Higher success rate + lower latency → higher score.
|
||||
return successRate / (avgMs/1000.0 + 0.1)
|
||||
// Success rate dominates (squared); latency is a mild tiebreaker.
|
||||
score := successRate * successRate / (avgMs/5000.0 + 1.0)
|
||||
if score < 0.001 {
|
||||
score = 0.001
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
// Fetcher fetches feed blocks over DNS.
|
||||
@@ -174,6 +178,55 @@ func (f *Fetcher) SetResolvers(resolvers []string) {
|
||||
copy(f.activeResolvers, resolvers)
|
||||
}
|
||||
|
||||
// RemoveActiveResolver removes a resolver from the active pool.
|
||||
func (f *Fetcher) RemoveActiveResolver(addr string) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
filtered := make([]string, 0, len(f.activeResolvers))
|
||||
for _, r := range f.activeResolvers {
|
||||
if r != addr {
|
||||
filtered = append(filtered, r)
|
||||
}
|
||||
}
|
||||
f.activeResolvers = filtered
|
||||
f.log("removed resolver %s, %d active remaining", addr, len(filtered))
|
||||
}
|
||||
|
||||
// ResetStats clears all resolver scoring data.
|
||||
func (f *Fetcher) ResetStats() {
|
||||
f.stats.Range(func(key, _ any) bool {
|
||||
f.stats.Delete(key)
|
||||
return true
|
||||
})
|
||||
f.log("resolver scoreboard reset")
|
||||
}
|
||||
|
||||
// ExportStats returns a snapshot of all resolver stats.
|
||||
func (f *Fetcher) ExportStats() map[string][3]int64 {
|
||||
out := make(map[string][3]int64)
|
||||
f.stats.Range(func(key, val any) bool {
|
||||
s := val.(*resolverStat)
|
||||
out[key.(string)] = [3]int64{
|
||||
atomic.LoadInt64(&s.success),
|
||||
atomic.LoadInt64(&s.failure),
|
||||
atomic.LoadInt64(&s.totalMs),
|
||||
}
|
||||
return true
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// ImportStats loads previously exported stats into this fetcher.
|
||||
func (f *Fetcher) ImportStats(m map[string][3]int64) {
|
||||
for key, vals := range m {
|
||||
f.stats.Store(key, &resolverStat{
|
||||
success: vals[0],
|
||||
failure: vals[1],
|
||||
totalMs: vals[2],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// AllResolvers returns all user-configured resolvers.
|
||||
func (f *Fetcher) AllResolvers() []string {
|
||||
f.mu.RLock()
|
||||
@@ -192,6 +245,42 @@ func (f *Fetcher) Resolvers() []string {
|
||||
return result
|
||||
}
|
||||
|
||||
// ResolverInfo holds public stats for a single resolver.
|
||||
type ResolverInfo struct {
|
||||
Addr string `json:"addr"`
|
||||
Score float64 `json:"score"`
|
||||
Success int64 `json:"success"`
|
||||
Failure int64 `json:"failure"`
|
||||
AvgMs float64 `json:"avgMs"`
|
||||
}
|
||||
|
||||
// ResolverScoreboard returns stats for all active resolvers sorted by score descending.
|
||||
func (f *Fetcher) ResolverScoreboard() []ResolverInfo {
|
||||
resolvers := f.Resolvers()
|
||||
infos := make([]ResolverInfo, 0, len(resolvers))
|
||||
for _, r := range resolvers {
|
||||
key := r
|
||||
if !strings.Contains(key, ":") {
|
||||
key += ":53"
|
||||
}
|
||||
info := ResolverInfo{Addr: r}
|
||||
if v, ok := f.stats.Load(key); ok {
|
||||
s := v.(*resolverStat)
|
||||
info.Success = atomic.LoadInt64(&s.success)
|
||||
info.Failure = atomic.LoadInt64(&s.failure)
|
||||
if info.Success > 0 {
|
||||
info.AvgMs = float64(atomic.LoadInt64(&s.totalMs)) / float64(info.Success)
|
||||
}
|
||||
info.Score = s.score()
|
||||
} else {
|
||||
info.Score = 0.2
|
||||
}
|
||||
infos = append(infos, info)
|
||||
}
|
||||
sort.Slice(infos, func(i, j int) bool { return infos[i].Score > infos[j].Score })
|
||||
return infos
|
||||
}
|
||||
|
||||
// SetScatter sets the number of resolvers queried simultaneously per DNS block request.
|
||||
// 1 = sequential (no scatter). Values > 1 fan out to N resolvers and use the fastest response.
|
||||
// Must be called before Start().
|
||||
|
||||
@@ -215,6 +215,42 @@
|
||||
color: var(--accent)
|
||||
}
|
||||
|
||||
/* Sidebar toolbar: compact text buttons */
|
||||
.sidebar-toolbar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px 12px 6px;
|
||||
flex-wrap: wrap
|
||||
}
|
||||
.sidebar-toolbar .stb {
|
||||
flex: 1 1 auto;
|
||||
padding: 5px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
transition: background .15s, color .15s
|
||||
}
|
||||
.sidebar-toolbar .stb:hover {
|
||||
background: var(--hover);
|
||||
color: var(--text)
|
||||
}
|
||||
.sidebar-toolbar .stb.scanning {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent)
|
||||
}
|
||||
.sidebar-toolbar .stb .stb-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
margin-left: 3px;
|
||||
vertical-align: middle
|
||||
}
|
||||
|
||||
/* ===== CHANNEL LIST ===== */
|
||||
.channel-list {
|
||||
flex: 1;
|
||||
@@ -421,6 +457,37 @@
|
||||
line-height: 1
|
||||
}
|
||||
|
||||
/* ===== SEARCH BAR ===== */
|
||||
.msg-search-bar {
|
||||
display: none;
|
||||
padding: 6px 14px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
gap: 8px;
|
||||
align-items: center
|
||||
}
|
||||
.msg-search-bar.active { display: flex }
|
||||
.msg-search-bar input {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
outline: none
|
||||
}
|
||||
.msg-search-bar input:focus { border-color: var(--accent) }
|
||||
.msg-search-bar .search-nav { display: flex; gap: 4px; align-items: center; font-size: 12px; color: var(--text-dim) }
|
||||
.msg-search-bar button { background: none; border: none; color: var(--text-dim); font-size: 16px; cursor: pointer; padding: 4px }
|
||||
.msg-search-bar button:hover { color: var(--text) }
|
||||
.msg .search-highlight { background: rgba(255,200,0,.35); border-radius: 2px; padding: 0 1px }
|
||||
.msg .search-highlight.current { background: rgba(255,200,0,.7) }
|
||||
|
||||
/* ===== EXPORT MODAL ===== */
|
||||
.export-row { display: flex; gap: 8px; align-items: center; margin-bottom: 12px }
|
||||
.export-row input[type=number] { width: 80px; padding: 6px 8px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg); color: var(--text); font-size: 13px }
|
||||
|
||||
/* ===== MESSAGES ===== */
|
||||
.messages {
|
||||
flex: 1;
|
||||
@@ -1376,8 +1443,11 @@
|
||||
<span class="profile-btn-arrow"><span class="plus">+</span>▼</span>
|
||||
</button>
|
||||
<button class="icon-btn" onclick="openSettings()" title="Settings" data-i18n-title="settings">⚙</button>
|
||||
<button class="icon-btn" id="scannerIconBtn" onclick="openScanner()" title="Scanner" data-i18n-title="scanner_title" style="font-size:18px">🔍</button>
|
||||
<button class="icon-btn" onclick="jumpToLog()" title="LOG">📜</button>
|
||||
</div>
|
||||
<div class="sidebar-toolbar">
|
||||
<button class="stb" id="scannerIconBtn" onclick="openScanner()" data-i18n="sidebar_scanner">Scanner</button>
|
||||
<button class="stb" id="resolversSidebarBtn" onclick="openResolversModal()"><span data-i18n="sidebar_resolvers">Resolvers</span> <span class="stb-badge" id="resolversBadge" style="color:var(--error)">0</span></button>
|
||||
<button class="stb" onclick="jumpToLog()" data-i18n="sidebar_log">Log</button>
|
||||
</div>
|
||||
<input class="sidebar-search" id="channelSearch" type="text" data-i18n-ph="search" placeholder="Search..."
|
||||
oninput="filterChannels()">
|
||||
@@ -1404,10 +1474,23 @@
|
||||
<span class="next-fetch-label" id="nextFetchTimer"></span><span class="next-fetch-info" id="nextFetchInfoBtn"
|
||||
style="display:none" data-i18n-title="next_fetch_info" title="" onclick="showToast(t('next_fetch_info'))"
|
||||
tabindex="0">ⓘ</span>
|
||||
<button class="icon-btn" onclick="toggleMsgSearch()" title="Search" data-i18n-title="search_messages"
|
||||
style="width:auto;height:30px;font-size:12px;padding:0 10px;border-radius:8px;border:1px solid var(--border);background:var(--surface)" data-i18n="search_messages">Search</button>
|
||||
<button class="icon-btn" onclick="openExportModal()" title="Export" data-i18n-title="export_messages"
|
||||
style="width:auto;height:30px;font-size:12px;padding:0 10px;border-radius:8px;border:1px solid var(--border);background:var(--surface)" data-i18n="export_messages">Export</button>
|
||||
<button class="icon-btn" id="refreshBtn" onclick="doRefreshUI()" title="Refresh"
|
||||
style="width:40px;height:40px;font-size:20px">↻</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="msg-search-bar" id="msgSearchBar">
|
||||
<input type="text" id="msgSearchInput" data-i18n-ph="search_messages" placeholder="Search messages..." oninput="doMsgSearch()">
|
||||
<div class="search-nav">
|
||||
<span id="msgSearchCount"></span>
|
||||
<button onclick="msgSearchPrev()" title="Previous">▲</button>
|
||||
<button onclick="msgSearchNext()" title="Next">▼</button>
|
||||
<button onclick="closeMsgSearch()" title="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="messages" id="messages">
|
||||
<div class="empty-state">
|
||||
<div class="big-icon">📡</div>
|
||||
@@ -1496,6 +1579,13 @@
|
||||
style="font-size:11px;padding:4px 12px;color:var(--danger,#e74c3c)" data-i18n="clear_cache">Clear
|
||||
Cache</button>
|
||||
</div>
|
||||
<div class="form-group" style="margin-top:12px">
|
||||
<label data-i18n="bg_image">Background Image</label>
|
||||
<div style="display:flex;gap:6px;align-items:center">
|
||||
<input type="file" id="bgImageInput" accept="image/*" style="flex:1;font-size:12px;color:var(--text)" onchange="applyBgImage()">
|
||||
<button class="btn btn-flat" onclick="clearBgImage()" style="font-size:11px;padding:4px 10px;color:var(--error)" data-i18n="clear_bg">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-flat" onclick="closeSettings()" data-i18n="cancel">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="saveSettings()" data-i18n="save">Save</button>
|
||||
@@ -1551,6 +1641,8 @@
|
||||
id="peRateLimit" value="6" min="0" step="0.1"></div>
|
||||
<div class="form-group"><label data-i18n="scatter">Parallel resolvers per block</label><input type="number" id="peScatter"
|
||||
value="4" min="1" max="5" title="How many resolvers are queried at the same time for one block"></div>
|
||||
<div class="form-group"><label data-i18n="dns_timeout">DNS Query Timeout (seconds)</label><input type="number" id="peTimeout"
|
||||
value="15" min="1" max="60" step="1"></div>
|
||||
<!-- Channel Management (editing only) -->
|
||||
<div id="peChannelSection" style="display:none">
|
||||
<hr class="section-divider">
|
||||
@@ -1575,6 +1667,38 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== EXPORT MODAL ===== -->
|
||||
<div class="modal-overlay" id="exportModal">
|
||||
<div class="modal" style="max-width:380px">
|
||||
<h2 data-i18n="export_title">Copy Messages</h2>
|
||||
<div class="export-row">
|
||||
<label data-i18n="export_count">Number of messages</label>
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<button class="btn btn-outline" onclick="var e=document.getElementById('exportCount');e.value=Math.max(1,parseInt(e.value||1)-1)" style="width:32px;height:32px;padding:0;font-size:18px;border-radius:8px">−</button>
|
||||
<input type="number" id="exportCount" value="10" min="1" max="500" style="width:70px;text-align:center">
|
||||
<button class="btn btn-outline" onclick="var e=document.getElementById('exportCount');e.value=Math.min(parseInt(e.max)||500,parseInt(e.value||0)+1)" style="width:32px;height:32px;padding:0;font-size:18px;border-radius:8px">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-flat" onclick="closeExportModal()" data-i18n="cancel">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="doExport()" data-i18n="export_copy">Copy to Clipboard</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== RESOLVERS MODAL ===== -->
|
||||
<div class="modal-overlay" id="resolversModal">
|
||||
<div class="modal" style="max-width:560px">
|
||||
<h2 data-i18n="resolvers_title">Working Resolvers</h2>
|
||||
<div id="resolversListEl" style="max-height:400px;overflow-y:auto;font-size:13px;margin-bottom:14px"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-flat" onclick="closeResolversModal()" data-i18n="close">Close</button>
|
||||
<button class="btn btn-outline" onclick="resetScoreboard()" data-i18n="reset_scoreboard">Reset Scores</button>
|
||||
<button class="btn btn-outline" onclick="copyResolversList()" data-i18n="copy">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== SCANNER MODAL ===== -->
|
||||
<div class="modal-overlay" id="scannerModal">
|
||||
<div class="modal" style="max-width:520px">
|
||||
@@ -1594,7 +1718,10 @@
|
||||
<div class="form-group">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px">
|
||||
<label data-i18n="scanner_targets">IPs or CIDRs (one per line)</label>
|
||||
<button class="btn btn-flat" onclick="loadScannerPresets()" data-i18n="scanner_load_presets" style="font-size:12px;padding:4px 10px">🇮🇷 IR</button>
|
||||
<div style="display:flex;gap:4px">
|
||||
<button class="btn btn-flat" onclick="document.getElementById('scanTargets').value=''" data-i18n="scanner_clear_targets" style="font-size:12px;padding:4px 10px">🗑 Clear</button>
|
||||
<button class="btn btn-flat" onclick="loadScannerPresets()" data-i18n="scanner_load_presets" style="font-size:12px;padding:4px 10px">🇮🇷 IR</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea id="scanTargets" rows="3" placeholder="5.1.0.0/16 8.8.8.8 1.1.1.1" style="width:100%;font-family:monospace;font-size:13px"></textarea>
|
||||
</div>
|
||||
@@ -1781,6 +1908,29 @@
|
||||
rescan_prompt_msg: '{n} ریزالور سالم از اسکن قبلی موجود است. بدون بررسی مجدد ادامه دهیم؟',
|
||||
rescan_prompt_skip: 'ادامه بدون اسکن',
|
||||
rescan_prompt_yes: 'بررسی مجدد',
|
||||
search_messages: 'جستجو',
|
||||
search_no_results: 'نتیجهای یافت نشد',
|
||||
sidebar_scanner: 'اسکنر',
|
||||
sidebar_resolvers: 'ریزالورها',
|
||||
sidebar_log: 'لاگ',
|
||||
export_title: 'کپی پیامها',
|
||||
export_messages: 'کپی پیام',
|
||||
export_count: 'تعداد پیام',
|
||||
export_copy: 'کپی در کلیپبورد',
|
||||
export_copied: 'پیامها کپی شدند!',
|
||||
export_no_messages: 'پیامی برای خروجی وجود ندارد',
|
||||
show_resolvers: 'ریزالورهای فعال',
|
||||
show_resolvers_btn: 'نمایش',
|
||||
resolvers_title: 'ریزالورهای فعال',
|
||||
no_active_resolvers: 'ریزالور فعالی وجود ندارد',
|
||||
resolver_speed: 'سرعت',
|
||||
resolver_score: 'امتیاز',
|
||||
reset_scoreboard: 'ریست امتیازها',
|
||||
bg_image: 'تصویر پسزمینه',
|
||||
apply: 'اعمال',
|
||||
clear_bg: 'پاک کردن',
|
||||
dns_timeout: 'تایماوت DNS (ثانیه)',
|
||||
scanner_clear_targets: '\uD83D\uDDD1 پاک کردن',
|
||||
},
|
||||
en: {
|
||||
search: 'Search...', settings: 'Settings', profiles: 'Profiles',
|
||||
@@ -1869,6 +2019,29 @@
|
||||
rescan_prompt_msg: '{n} healthy resolvers from previous scan. Continue without rescanning?',
|
||||
rescan_prompt_skip: 'Skip Rescan',
|
||||
rescan_prompt_yes: 'Rescan',
|
||||
search_messages: 'Search',
|
||||
search_no_results: 'No results',
|
||||
sidebar_scanner: 'Scanner',
|
||||
sidebar_resolvers: 'Resolvers',
|
||||
sidebar_log: 'Log',
|
||||
export_title: 'Copy Messages',
|
||||
export_messages: 'Copy',
|
||||
export_count: 'Number of messages',
|
||||
export_copy: 'Copy to Clipboard',
|
||||
export_copied: 'Messages copied!',
|
||||
export_no_messages: 'No messages to export',
|
||||
show_resolvers: 'Working Resolvers',
|
||||
show_resolvers_btn: 'Show',
|
||||
resolvers_title: 'Working Resolvers',
|
||||
no_active_resolvers: 'No active resolvers',
|
||||
resolver_speed: 'Speed',
|
||||
resolver_score: 'Score',
|
||||
reset_scoreboard: 'Reset Scores',
|
||||
bg_image: 'Background Image',
|
||||
apply: 'Apply',
|
||||
clear_bg: 'Clear',
|
||||
dns_timeout: 'DNS Query Timeout (s)',
|
||||
scanner_clear_targets: '\uD83D\uDDD1 Clear',
|
||||
}
|
||||
};
|
||||
var lang = localStorage.getItem('thefeed_lang') || 'fa';
|
||||
@@ -1921,7 +2094,9 @@
|
||||
loadTheme();
|
||||
applyLang();
|
||||
await loadFontSize();
|
||||
loadBgImage();
|
||||
connectSSE();
|
||||
refreshResolversBadge();
|
||||
try {
|
||||
var r = await fetch('/api/status'); var st = await r.json();
|
||||
await loadProfiles();
|
||||
@@ -2203,9 +2378,9 @@
|
||||
if (isActive) h += '<span class="active-badge">' + t('active') + '</span>';
|
||||
h += '</div><div class="profile-row-domain">' + esc(p.config.domain) + '</div></div>';
|
||||
h += '<div class="profile-row-btns">';
|
||||
if (isActive) h += '<button class="btn btn-flat btn-sm" onclick="event.stopPropagation();doRescanFromProfiles()" title="' + t('rescan') + '" style="color:var(--success)">🔄</button>';
|
||||
h += '<button class="btn btn-flat btn-sm" onclick="event.stopPropagation();toggleSharePanel(\'' + p.id + '\')" title="' + t('share') + '">🔗</button>';
|
||||
h += '<button class="btn btn-flat btn-sm" onclick="event.stopPropagation();openProfileEditor(\'' + p.id + '\')" title="' + t('edit') + '">✎</button>';
|
||||
if (isActive) h += '<button class="btn btn-flat btn-sm" onclick="event.stopPropagation();doRescanFromProfiles()" title="' + t('rescan') + '" style="color:var(--success);font-size:11px">' + t('rescan') + '</button>';
|
||||
h += '<button class="btn btn-flat btn-sm" onclick="event.stopPropagation();toggleSharePanel(\'' + p.id + '\')" title="' + t('share') + '" style="font-size:11px">' + t('share') + '</button>';
|
||||
h += '<button class="btn btn-flat btn-sm" onclick="event.stopPropagation();openProfileEditor(\'' + p.id + '\')" title="' + t('edit') + '" style="font-size:11px">' + t('edit') + '</button>';
|
||||
h += '</div></div>';
|
||||
// Share panel (hidden by default)
|
||||
h += '<div class="share-panel" id="' + shareId + '" style="display:none">';
|
||||
@@ -2305,6 +2480,7 @@
|
||||
document.getElementById('peQueryMode').value = p.config.queryMode || 'single';
|
||||
document.getElementById('peRateLimit').value = p.config.rateLimit || 6;
|
||||
document.getElementById('peScatter').value = p.config.scatter || 4;
|
||||
document.getElementById('peTimeout').value = p.config.timeout || 15;
|
||||
}
|
||||
document.getElementById('peChannelSection').style.display = '';
|
||||
var isActive = id === activeProfileId;
|
||||
@@ -2328,6 +2504,7 @@
|
||||
document.getElementById('peQueryMode').value = 'single';
|
||||
document.getElementById('peRateLimit').value = '6';
|
||||
document.getElementById('peScatter').value = '4';
|
||||
document.getElementById('peTimeout').value = '15';
|
||||
document.getElementById('peChannelSection').style.display = 'none';
|
||||
}
|
||||
}
|
||||
@@ -2376,7 +2553,7 @@
|
||||
var key = document.getElementById('peKey').value;
|
||||
var resolvers = document.getElementById('peResolvers').value.trim().split(/[\n,]+/).map(function (s) { return s.trim() }).filter(Boolean);
|
||||
if (!domain || !key || !resolvers.length) { errEl.textContent = t('resolvers') + ' / ' + t('domain') + ' / ' + t('passphrase'); errEl.style.display = 'block'; return }
|
||||
var profile = { id: editingProfileId || '', nickname: nick || domain, config: { domain: domain, key: key, resolvers: resolvers, queryMode: document.getElementById('peQueryMode').value, rateLimit: parseFloat(document.getElementById('peRateLimit').value) || 6, scatter: parseInt(document.getElementById('peScatter').value) || 4 } };
|
||||
var profile = { id: editingProfileId || '', nickname: nick || domain, config: { domain: domain, key: key, resolvers: resolvers, queryMode: document.getElementById('peQueryMode').value, rateLimit: parseFloat(document.getElementById('peRateLimit').value) || 6, scatter: parseInt(document.getElementById('peScatter').value) || 4, timeout: parseInt(document.getElementById('peTimeout').value) || 15 } };
|
||||
var action = editingProfileId ? 'update' : 'create';
|
||||
var wasFirst = !profiles || !profiles.profiles || profiles.profiles.length === 0;
|
||||
// Check if we should skip resolver check (existing healthy resolvers)
|
||||
@@ -3199,11 +3376,289 @@
|
||||
navigator.clipboard.writeText(ips.join('\n')).then(function () { showToast(t('copied')) });
|
||||
}
|
||||
|
||||
// ===== MESSAGE SEARCH =====
|
||||
var msgSearchMatches = [], msgSearchIdx = -1;
|
||||
// Normalize Arabic/Persian: map ي→ی, ك→ک, ة→ه, etc.
|
||||
function normalizeArabicPersian(s) {
|
||||
return s
|
||||
.replace(/\u064A/g, '\u06CC') // Arabic Ya -> Persian Ya
|
||||
.replace(/\u0643/g, '\u06A9') // Arabic Kaf -> Persian Kaf
|
||||
.replace(/\u0629/g, '\u0647') // Arabic Ta Marbuta -> He
|
||||
.replace(/\u0649/g, '\u06CC') // Arabic Alef Maksura -> Persian Ya
|
||||
.replace(/\u06C0/g, '\u0647') // He with Hamza above -> He
|
||||
.replace(/[\u0623\u0625\u0622]/g, '\u0627') // Alef variants -> plain Alef
|
||||
.replace(/\u0624/g, '\u0648') // Waw with Hamza -> plain Waw
|
||||
.replace(/\u0626/g, '\u06CC') // Ya with Hamza -> Ya
|
||||
.replace(/\u0621/g, '') // standalone Hamza -> remove
|
||||
.replace(/[\u064B-\u065F\u0610-\u061A\u0670]/g, '') // strip tashkil/diacritics
|
||||
.replace(/[\u200C\u200D\u200E\u200F]/g, '') // strip ZWNJ, ZWJ, directional marks
|
||||
}
|
||||
function toggleMsgSearch() {
|
||||
var bar = document.getElementById('msgSearchBar');
|
||||
if (bar.classList.contains('active')) { closeMsgSearch(); return }
|
||||
bar.classList.add('active');
|
||||
var inp = document.getElementById('msgSearchInput');
|
||||
inp.value = '';
|
||||
inp.focus();
|
||||
msgSearchMatches = []; msgSearchIdx = -1;
|
||||
document.getElementById('msgSearchCount').textContent = '';
|
||||
}
|
||||
function closeMsgSearch() {
|
||||
document.getElementById('msgSearchBar').classList.remove('active');
|
||||
document.getElementById('msgSearchInput').value = '';
|
||||
// Remove highlights
|
||||
document.querySelectorAll('.msg .search-highlight').forEach(function (el) {
|
||||
el.outerHTML = el.textContent;
|
||||
});
|
||||
msgSearchMatches = []; msgSearchIdx = -1;
|
||||
document.getElementById('msgSearchCount').textContent = '';
|
||||
}
|
||||
function doMsgSearch() {
|
||||
var q = normalizeArabicPersian(document.getElementById('msgSearchInput').value.trim().toLowerCase());
|
||||
// Remove old highlights
|
||||
document.querySelectorAll('.msg .search-highlight').forEach(function (el) {
|
||||
el.outerHTML = el.textContent;
|
||||
});
|
||||
msgSearchMatches = []; msgSearchIdx = -1;
|
||||
if (!q) { document.getElementById('msgSearchCount').textContent = ''; return }
|
||||
var msgs = document.querySelectorAll('.msg');
|
||||
msgs.forEach(function (msgEl) {
|
||||
highlightTextNodes(msgEl, q);
|
||||
});
|
||||
msgSearchMatches = Array.from(document.querySelectorAll('.msg .search-highlight'));
|
||||
if (msgSearchMatches.length > 0) {
|
||||
// Start from the last match (bottom of chat, most recent)
|
||||
msgSearchIdx = msgSearchMatches.length - 1;
|
||||
scrollToSearchMatch();
|
||||
document.getElementById('msgSearchCount').textContent = (msgSearchIdx + 1) + '/' + msgSearchMatches.length;
|
||||
} else {
|
||||
document.getElementById('msgSearchCount').textContent = t('search_no_results');
|
||||
}
|
||||
}
|
||||
function highlightTextNodes(el, q) {
|
||||
// Skip metadata and buttons
|
||||
if (el.classList && (el.classList.contains('msg-meta') || el.classList.contains('media-tag'))) return;
|
||||
var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null, false);
|
||||
var nodes = [];
|
||||
while (walker.nextNode()) {
|
||||
// Skip nodes inside msg-meta
|
||||
var p = walker.currentNode.parentNode;
|
||||
var skip = false;
|
||||
while (p && p !== el) { if (p.classList && (p.classList.contains('msg-meta') || p.classList.contains('media-tag'))) { skip = true; break } p = p.parentNode; }
|
||||
if (!skip) nodes.push(walker.currentNode);
|
||||
}
|
||||
for (var i = nodes.length - 1; i >= 0; i--) {
|
||||
var node = nodes[i];
|
||||
var src = node.textContent;
|
||||
var normalized = normalizeArabicPersian(src.toLowerCase());
|
||||
var idx = normalized.indexOf(q);
|
||||
if (idx === -1) continue;
|
||||
// Map normalized indices back to original string positions
|
||||
var frag = document.createDocumentFragment();
|
||||
var pos = 0;
|
||||
while (idx !== -1) {
|
||||
// Find the original char positions that correspond to normalized idx..idx+q.length
|
||||
var origStart = mapNormIdx(src, idx);
|
||||
var origEnd = mapNormIdx(src, idx + q.length);
|
||||
if (origStart > pos) frag.appendChild(document.createTextNode(src.substring(pos, origStart)));
|
||||
var span = document.createElement('span');
|
||||
span.className = 'search-highlight';
|
||||
span.textContent = src.substring(origStart, origEnd);
|
||||
frag.appendChild(span);
|
||||
pos = origEnd;
|
||||
idx = normalized.indexOf(q, idx + q.length);
|
||||
}
|
||||
if (pos < src.length) frag.appendChild(document.createTextNode(src.substring(pos)));
|
||||
node.parentNode.replaceChild(frag, node);
|
||||
}
|
||||
}
|
||||
// Map a position in normalized text to position in original text
|
||||
function mapNormIdx(original, normPos) {
|
||||
var ni = 0;
|
||||
for (var oi = 0; oi <= original.length; oi++) {
|
||||
if (ni >= normPos) return oi;
|
||||
if (oi < original.length) {
|
||||
var ch = original[oi];
|
||||
var norm = normalizeArabicPersian(ch.toLowerCase());
|
||||
ni += norm.length;
|
||||
}
|
||||
}
|
||||
return original.length;
|
||||
}
|
||||
function scrollToSearchMatch() {
|
||||
if (msgSearchMatches.length === 0) return;
|
||||
msgSearchMatches.forEach(function (el) { el.classList.remove('current') });
|
||||
var cur = msgSearchMatches[msgSearchIdx];
|
||||
if (cur) { cur.classList.add('current'); cur.scrollIntoView({ behavior: 'smooth', block: 'center' }); }
|
||||
document.getElementById('msgSearchCount').textContent = (msgSearchIdx + 1) + '/' + msgSearchMatches.length;
|
||||
}
|
||||
function msgSearchNext() {
|
||||
if (msgSearchMatches.length === 0) return;
|
||||
msgSearchIdx = (msgSearchIdx + 1) % msgSearchMatches.length;
|
||||
scrollToSearchMatch();
|
||||
}
|
||||
function msgSearchPrev() {
|
||||
if (msgSearchMatches.length === 0) return;
|
||||
msgSearchIdx = (msgSearchIdx - 1 + msgSearchMatches.length) % msgSearchMatches.length;
|
||||
scrollToSearchMatch();
|
||||
}
|
||||
|
||||
// ===== EXPORT MESSAGES =====
|
||||
function openExportModal() {
|
||||
if (!currentMsgTexts.length) { showToast(t('export_no_messages')); return }
|
||||
document.getElementById('exportCount').value = Math.min(10, currentMsgTexts.length);
|
||||
document.getElementById('exportCount').max = currentMsgTexts.length;
|
||||
document.getElementById('exportModal').classList.add('active');
|
||||
}
|
||||
function closeExportModal() { document.getElementById('exportModal').classList.remove('active') }
|
||||
function doExport() {
|
||||
var n = parseInt(document.getElementById('exportCount').value) || 10;
|
||||
if (n < 1) n = 1;
|
||||
if (n > currentMsgTexts.length) n = currentMsgTexts.length;
|
||||
var chName = selectedChannel > 0 && channels[selectedChannel - 1] ? (channels[selectedChannel - 1].Name || channels[selectedChannel - 1].name || 'Channel') : 'Channel';
|
||||
// Take last N messages (most recent)
|
||||
var start = currentMsgTexts.length - n;
|
||||
var lines = [];
|
||||
lines.push('=== ' + chName + ' ===');
|
||||
for (var i = start; i < currentMsgTexts.length; i++) {
|
||||
lines.push('');
|
||||
lines.push(currentMsgTexts[i]);
|
||||
}
|
||||
navigator.clipboard.writeText(lines.join('\n')).then(function () {
|
||||
showToast(t('export_copied'));
|
||||
closeExportModal();
|
||||
}).catch(function () { showToast('Copy failed') });
|
||||
}
|
||||
|
||||
// ===== WORKING RESOLVERS =====
|
||||
function updateResolversBadge(count) {
|
||||
var badge = document.getElementById('resolversBadge');
|
||||
if (!badge) return;
|
||||
badge.textContent = count;
|
||||
badge.style.color = count > 0 ? 'var(--success, #27ae60)' : 'var(--error, #e74c3c)';
|
||||
}
|
||||
async function refreshResolversBadge() {
|
||||
try {
|
||||
var r = await fetch('/api/resolvers/active');
|
||||
if (!r.ok) return;
|
||||
var data = await r.json();
|
||||
updateResolversBadge((data.resolvers || []).length);
|
||||
} catch (e) { }
|
||||
}
|
||||
var resolversRefreshTimer = null;
|
||||
async function _fetchResolversBoard(el) {
|
||||
try {
|
||||
var r = await fetch('/api/resolvers/active');
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
var data = await r.json();
|
||||
var board = data.scoreboard || [];
|
||||
if (!board.length) { el.innerHTML = '<div style="color:var(--text-dim)">' + t('no_active_resolvers') + '</div>'; updateResolversBadge(0); return }
|
||||
updateResolversBadge(board.length);
|
||||
var h = '<table style="width:100%;border-collapse:collapse;font-size:12px">';
|
||||
h += '<thead><tr style="border-bottom:2px solid var(--border);text-align:left">';
|
||||
h += '<th style="padding:6px 8px">Resolver</th>';
|
||||
h += '<th style="padding:6px 8px;text-align:right">' + t('resolver_speed') + '</th>';
|
||||
h += '<th style="padding:6px 8px;text-align:right">' + t('resolver_score') + '</th>';
|
||||
h += '<th style="padding:6px 8px;text-align:center">\u2705</th>';
|
||||
h += '<th style="padding:6px 8px;text-align:center">\u274C</th>';
|
||||
h += '<th style="padding:6px 8px"></th>';
|
||||
h += '</tr></thead><tbody>';
|
||||
for (var i = 0; i < board.length; i++) {
|
||||
var b = board[i];
|
||||
var scoreColor = b.score >= 0.5 ? 'var(--success)' : b.score >= 0.15 ? 'var(--text)' : 'var(--error)';
|
||||
h += '<tr style="border-bottom:1px solid var(--border)">';
|
||||
h += '<td style="padding:5px 8px;font-family:monospace">' + esc(b.addr) + '</td>';
|
||||
h += '<td style="padding:5px 8px;text-align:right">' + (b.avgMs > 0 ? Math.round(b.avgMs) + 'ms' : '-') + '</td>';
|
||||
h += '<td style="padding:5px 8px;text-align:right;color:' + scoreColor + ';font-weight:600">' + b.score.toFixed(2) + '</td>';
|
||||
h += '<td style="padding:5px 8px;text-align:center;color:var(--success)">' + b.success + '</td>';
|
||||
h += '<td style="padding:5px 8px;text-align:center;color:var(--error)">' + b.failure + '</td>';
|
||||
h += '<td style="padding:5px 8px;text-align:center"><button onclick="removeResolver(\'' + esc(b.addr) + '\')" style="background:none;border:none;color:var(--error);cursor:pointer;font-size:14px;padding:2px 4px" title="Remove">×</button></td>';
|
||||
h += '</tr>';
|
||||
}
|
||||
h += '</tbody></table>';
|
||||
el.innerHTML = h;
|
||||
} catch (e) { el.innerHTML = '<div style="color:var(--error)">' + esc(e.message) + '</div>' }
|
||||
}
|
||||
async function openResolversModal() {
|
||||
var el = document.getElementById('resolversListEl');
|
||||
el.innerHTML = '<div style="color:var(--text-dim)">' + t('loading') + '</div>';
|
||||
document.getElementById('resolversModal').classList.add('active');
|
||||
await _fetchResolversBoard(el);
|
||||
if (resolversRefreshTimer) clearInterval(resolversRefreshTimer);
|
||||
resolversRefreshTimer = setInterval(function () {
|
||||
if (!document.getElementById('resolversModal').classList.contains('active')) {
|
||||
clearInterval(resolversRefreshTimer); resolversRefreshTimer = null; return;
|
||||
}
|
||||
_fetchResolversBoard(document.getElementById('resolversListEl'));
|
||||
}, 3000);
|
||||
}
|
||||
function closeResolversModal() {
|
||||
document.getElementById('resolversModal').classList.remove('active');
|
||||
if (resolversRefreshTimer) { clearInterval(resolversRefreshTimer); resolversRefreshTimer = null; }
|
||||
}
|
||||
async function removeResolver(addr) {
|
||||
try {
|
||||
await fetch('/api/resolvers/remove', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ addr: addr }) });
|
||||
_fetchResolversBoard(document.getElementById('resolversListEl'));
|
||||
} catch (e) { }
|
||||
}
|
||||
async function resetScoreboard() {
|
||||
try {
|
||||
await fetch('/api/resolvers/reset-stats', { method: 'POST' });
|
||||
_fetchResolversBoard(document.getElementById('resolversListEl'));
|
||||
} catch (e) { }
|
||||
}
|
||||
function copyResolversList() {
|
||||
var rows = document.querySelectorAll('#resolversListEl tbody tr');
|
||||
var lines = [];
|
||||
rows.forEach(function (tr) {
|
||||
var cells = tr.querySelectorAll('td');
|
||||
if (cells.length > 0) lines.push(cells[0].textContent.trim());
|
||||
});
|
||||
if (!lines.length) { showToast(t('no_active_resolvers')); return }
|
||||
navigator.clipboard.writeText(lines.join('\n')).then(function () { showToast(t('copied')) });
|
||||
}
|
||||
|
||||
// ===== BACKGROUND IMAGE =====
|
||||
function _setBg(data) {
|
||||
var m = document.getElementById('messages');
|
||||
m.style.backgroundImage = data ? 'url("' + data + '")' : '';
|
||||
m.style.backgroundSize = data ? 'cover' : '';
|
||||
m.style.backgroundPosition = data ? 'center' : '';
|
||||
m.style.backgroundRepeat = data ? 'no-repeat' : '';
|
||||
m.style.backgroundAttachment = data ? 'fixed' : '';
|
||||
}
|
||||
function loadBgImage() {
|
||||
var data = localStorage.getItem('thefeed_bg_image') || '';
|
||||
if (data) _setBg(data);
|
||||
}
|
||||
function applyBgImage() {
|
||||
var inp = document.getElementById('bgImageInput');
|
||||
if (!inp.files || !inp.files[0]) return;
|
||||
var file = inp.files[0];
|
||||
if (file.size > 5 * 1024 * 1024) { showToast('File too large (max 5MB)'); return }
|
||||
var reader = new FileReader();
|
||||
reader.onload = function (e) {
|
||||
var data = e.target.result;
|
||||
try { localStorage.setItem('thefeed_bg_image', data) } catch (ex) { showToast('File too large for storage'); return }
|
||||
_setBg(data);
|
||||
showToast(t('apply'));
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
function clearBgImage() {
|
||||
localStorage.removeItem('thefeed_bg_image');
|
||||
_setBg('');
|
||||
document.getElementById('bgImageInput').value = '';
|
||||
showToast(t('clear_bg'));
|
||||
}
|
||||
|
||||
// ===== EVENTS =====
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter' && document.activeElement === document.getElementById('sendInput')) { e.preventDefault(); sendMessage() }
|
||||
if (e.key === 'Enter' && document.activeElement === document.getElementById('peAddChannelInput')) { e.preventDefault(); addChannelEditor() }
|
||||
if (e.key === 'Escape') { closeSettings(); closeProfiles(); closeProfileEditor(); closeScanner() }
|
||||
if (e.key === 'Enter' && document.activeElement === document.getElementById('msgSearchInput')) { e.preventDefault(); msgSearchNext() }
|
||||
if (e.key === 'Escape') { closeSettings(); closeProfiles(); closeProfileEditor(); closeScanner(); closeMsgSearch(); closeExportModal(); closeResolversModal() }
|
||||
});
|
||||
window.addEventListener('resize', function () { if (window.innerWidth > 768) document.getElementById('app').classList.remove('chat-open') });
|
||||
|
||||
|
||||
@@ -187,6 +187,9 @@ func (s *Server) Run() error {
|
||||
mux.HandleFunc("/api/version-check", s.handleVersionCheck)
|
||||
mux.HandleFunc("/api/cache/clear", s.handleClearCache)
|
||||
mux.HandleFunc("/api/resolvers/apply-saved", s.handleApplySavedResolvers)
|
||||
mux.HandleFunc("/api/resolvers/active", s.handleActiveResolvers)
|
||||
mux.HandleFunc("/api/resolvers/remove", s.handleRemoveResolver)
|
||||
mux.HandleFunc("/api/resolvers/reset-stats", s.handleResetResolverStats)
|
||||
mux.HandleFunc("/api/scanner/start", s.handleScannerStart)
|
||||
mux.HandleFunc("/api/scanner/stop", s.handleScannerStop)
|
||||
mux.HandleFunc("/api/scanner/pause", s.handleScannerPause)
|
||||
@@ -626,6 +629,11 @@ func (s *Server) initFetcher() error {
|
||||
|
||||
// Cancel goroutines from the previous fetcher configuration.
|
||||
// This also cancels any in-progress manual rescan (via the context chain).
|
||||
// Preserve resolver stats across fetcher re-creation (e.g. profile switch).
|
||||
var prevStats map[string][3]int64
|
||||
if s.fetcher != nil {
|
||||
prevStats = s.fetcher.ExportStats()
|
||||
}
|
||||
if s.fetcherCancel != nil {
|
||||
s.fetcherCancel()
|
||||
}
|
||||
@@ -646,6 +654,11 @@ func (s *Server) initFetcher() error {
|
||||
return fmt.Errorf("create fetcher: %w", err)
|
||||
}
|
||||
|
||||
// Restore resolver stats from the previous fetcher.
|
||||
if prevStats != nil {
|
||||
fetcher.ImportStats(prevStats)
|
||||
}
|
||||
|
||||
if cfg.QueryMode == "double" {
|
||||
fetcher.SetQueryMode(protocol.QueryMultiLabel)
|
||||
}
|
||||
@@ -1170,6 +1183,64 @@ func (s *Server) handleApplySavedResolvers(w http.ResponseWriter, r *http.Reques
|
||||
writeJSON(w, map[string]any{"ok": true, "count": len(ls.Resolvers)})
|
||||
}
|
||||
|
||||
func (s *Server) handleActiveResolvers(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
s.mu.RLock()
|
||||
fetcher := s.fetcher
|
||||
s.mu.RUnlock()
|
||||
if fetcher == nil {
|
||||
writeJSON(w, map[string]any{"resolvers": []string{}, "scoreboard": []client.ResolverInfo{}})
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{
|
||||
"resolvers": fetcher.Resolvers(),
|
||||
"all": fetcher.AllResolvers(),
|
||||
"scoreboard": fetcher.ResolverScoreboard(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleRemoveResolver(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Addr string `json:"addr"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Addr == "" {
|
||||
http.Error(w, "addr required", 400)
|
||||
return
|
||||
}
|
||||
s.mu.RLock()
|
||||
fetcher := s.fetcher
|
||||
s.mu.RUnlock()
|
||||
if fetcher == nil {
|
||||
http.Error(w, "no active fetcher", 400)
|
||||
return
|
||||
}
|
||||
fetcher.RemoveActiveResolver(req.Addr)
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleResetResolverStats(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
s.mu.RLock()
|
||||
fetcher := s.fetcher
|
||||
s.mu.RUnlock()
|
||||
if fetcher == nil {
|
||||
http.Error(w, "no active fetcher", 400)
|
||||
return
|
||||
}
|
||||
fetcher.ResetStats()
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) saveConfig(cfg *Config) error {
|
||||
path := filepath.Join(s.dataDir, "config.json")
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
|
||||
@@ -537,3 +537,70 @@ func TestE2E_MessagesHaveTimestamps(t *testing.T) {
|
||||
// This verifies the API structure supports the timestamp-based separator.
|
||||
t.Logf("messages response contains %d messages", len(result.Messages))
|
||||
}
|
||||
|
||||
// TestE2E_ActiveResolversAPI verifies the /api/resolvers/active endpoint.
|
||||
func TestE2E_ActiveResolversAPI(t *testing.T) {
|
||||
base, _ := startWebServer(t)
|
||||
|
||||
// Before config: should return empty resolvers
|
||||
resp, err := http.Get(base + "/api/resolvers/active")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /api/resolvers/active: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
var result map[string]any
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
resolvers, ok := result["resolvers"].([]any)
|
||||
if !ok {
|
||||
t.Fatal("expected 'resolvers' key in response")
|
||||
}
|
||||
t.Logf("active resolvers: %d", len(resolvers))
|
||||
|
||||
// Method not allowed
|
||||
resp2, err := http.Post(base+"/api/resolvers/active", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("POST /api/resolvers/active: %v", err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
if resp2.StatusCode != 405 {
|
||||
t.Errorf("expected 405 for POST, got %d", resp2.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestE2E_WebUI_NewFeatures verifies the index.html includes new UI elements.
|
||||
func TestE2E_WebUI_NewFeatures(t *testing.T) {
|
||||
base, _ := startWebServer(t)
|
||||
|
||||
resp, err := http.Get(base + "/")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
html := string(bodyBytes)
|
||||
|
||||
checks := map[string]string{
|
||||
"message search bar": "msgSearchBar",
|
||||
"search input": "msgSearchInput",
|
||||
"export modal": "exportModal",
|
||||
"resolvers modal": "resolversModal",
|
||||
"background image": "bgImageInput",
|
||||
"dns timeout field": "peTimeout",
|
||||
"scanner clear button": "scanner_clear_targets",
|
||||
"search function": "doMsgSearch",
|
||||
"export function": "doExport",
|
||||
"bg image function": "applyBgImage",
|
||||
"resolvers function": "openResolversModal",
|
||||
"sidebar toolbar": "sidebar-toolbar",
|
||||
"resolvers badge": "resolversBadge",
|
||||
"normalize function": "normalizeArabicPersian",
|
||||
}
|
||||
for name, needle := range checks {
|
||||
if !strings.Contains(html, needle) {
|
||||
t.Errorf("%s: expected HTML to contain %q", name, needle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user