mirror of
https://github.com//cppla/ServerStatus
synced 2026-08-10 22:34:01 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6030d2008 | ||
|
|
1ce8d9c337 | ||
|
|
6f782e1940 | ||
|
|
5295ac0c58 | ||
|
|
d923d67811 | ||
|
|
0ce75874a3 |
@@ -97,7 +97,7 @@ Docker 镜像中的默认路径为:
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `CONFIG_PATH` | `/app/config/config.json` | 主配置文件 |
|
||||
| `STATS_PATH` | `/app/data/stats.json` | 月流量与状态持久化文件 |
|
||||
| `STATS_PATH` | `/app/data/stats.json` | 月流量与状态持久化文件;每 60 秒写入,关键操作与正常退出时立即写入 |
|
||||
| `WEB_DIR` | `/app/web` | WebUI 静态文件目录 |
|
||||
| `HTTP_ADDR` | `:80` | WebUI 与 HTTP API 监听地址 |
|
||||
| `AGENT_ADDR` | `:35601` | 客户端 TCP 上报监听地址 |
|
||||
|
||||
+25
-30
@@ -55,21 +55,22 @@ type App struct {
|
||||
document ConfigDocument
|
||||
runtime RuntimeConfig
|
||||
|
||||
nodeMu sync.RWMutex
|
||||
nodes map[string]*NodeState
|
||||
connectionID atomic.Uint64
|
||||
generation atomic.Uint64
|
||||
agentRunning atomic.Bool
|
||||
reloadWrites atomic.Int32
|
||||
nodeMu sync.RWMutex
|
||||
nodes map[string]*NodeState
|
||||
connectionID atomic.Uint64
|
||||
generation atomic.Uint64
|
||||
agentRunning atomic.Bool
|
||||
reloadPending atomic.Bool
|
||||
|
||||
certMu sync.RWMutex
|
||||
certs map[string]*CertState
|
||||
|
||||
statsWake chan struct{}
|
||||
persistMu sync.Mutex
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
const statsFlushInterval = time.Minute
|
||||
|
||||
func NewApp(opts Options) (*App, error) {
|
||||
doc, runtime, err := readConfig(opts.ConfigPath)
|
||||
if err != nil {
|
||||
@@ -83,7 +84,6 @@ func NewApp(opts Options) (*App, error) {
|
||||
cancel: cancel,
|
||||
nodes: make(map[string]*NodeState),
|
||||
certs: make(map[string]*CertState),
|
||||
statsWake: make(chan struct{}, 1),
|
||||
logger: log.New(os.Stdout, "serverstatus ", log.LstdFlags|log.Lmicroseconds),
|
||||
}
|
||||
app.applyValidatedConfig(doc, runtime, false)
|
||||
@@ -94,7 +94,6 @@ func NewApp(opts Options) (*App, error) {
|
||||
func (a *App) StartBackground() {
|
||||
go a.statsLoop()
|
||||
go a.sslLoop()
|
||||
a.wakeStatsWriter()
|
||||
}
|
||||
|
||||
func (a *App) Close() {
|
||||
@@ -216,8 +215,12 @@ func (a *App) applyValidatedConfig(doc ConfigDocument, runtime RuntimeConfig, di
|
||||
_ = conn.Close()
|
||||
}
|
||||
}
|
||||
a.reloadWrites.Store(2)
|
||||
a.wakeStatsWriter()
|
||||
if disconnect {
|
||||
a.reloadPending.Store(true)
|
||||
if err := a.PersistStats(); err != nil {
|
||||
a.logger.Printf("write stats after config change: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sameServerIdentity(left, right ServerConfig) bool {
|
||||
@@ -246,14 +249,13 @@ func (a *App) disconnectAll(reason string) {
|
||||
}
|
||||
|
||||
func (a *App) statsLoop() {
|
||||
ticker := time.NewTicker(time.Second)
|
||||
ticker := time.NewTicker(statsFlushInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-a.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
case <-a.statsWake:
|
||||
}
|
||||
if err := a.PersistStats(); err != nil {
|
||||
a.logger.Printf("write stats: %v", err)
|
||||
@@ -261,18 +263,15 @@ func (a *App) statsLoop() {
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) wakeStatsWriter() {
|
||||
select {
|
||||
case a.statsWake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) SnapshotStats() map[string]any {
|
||||
return a.snapshotStats(false)
|
||||
result := a.snapshotStats()
|
||||
if a.reloadPending.Swap(false) {
|
||||
result["reload"] = true
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (a *App) snapshotStats(consumeReload bool) map[string]any {
|
||||
func (a *App) snapshotStats() map[string]any {
|
||||
runtime := a.RuntimeSnapshot()
|
||||
now := time.Now()
|
||||
servers := make([]any, 0, len(runtime.Servers))
|
||||
@@ -325,19 +324,13 @@ func (a *App) snapshotStats(consumeReload bool) map[string]any {
|
||||
"sslcerts": a.sslSnapshot(runtime.SSLCerts, now),
|
||||
"updated": strconv.FormatInt(now.Unix(), 10),
|
||||
}
|
||||
if a.reloadWrites.Load() > 0 {
|
||||
result["reload"] = true
|
||||
if consumeReload {
|
||||
a.reloadWrites.Add(-1)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (a *App) PersistStats() error {
|
||||
a.persistMu.Lock()
|
||||
defer a.persistMu.Unlock()
|
||||
return writeStatsFile(a.opts.StatsPath, a.snapshotStats(true))
|
||||
return writeStatsFile(a.opts.StatsPath, a.snapshotStats())
|
||||
}
|
||||
|
||||
func monthResetWindow(now time.Time, monthStart int) bool {
|
||||
@@ -452,7 +445,9 @@ func (a *App) ResetTraffic(username string) (map[string]any, *APIError) {
|
||||
node.LastNetworkIn, node.LastNetworkOut = networkIn, networkOut
|
||||
server := node.Config
|
||||
a.nodeMu.Unlock()
|
||||
a.wakeStatsWriter()
|
||||
if err := a.PersistStats(); err != nil {
|
||||
a.logger.Printf("write stats after traffic reset for %q: %v", username, err)
|
||||
}
|
||||
return map[string]any{
|
||||
"server": server,
|
||||
"stats": map[string]any{
|
||||
|
||||
@@ -1,10 +1,34 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func readPersistedServer(t *testing.T, app *App) map[string]any {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(app.opts.StatsPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stats map[string]any
|
||||
if err := json.Unmarshal(data, &stats); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
servers, ok := stats["servers"].([]any)
|
||||
if !ok || len(servers) != 1 {
|
||||
t.Fatalf("unexpected persisted servers: %#v", stats["servers"])
|
||||
}
|
||||
server, ok := servers[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected persisted server: %#v", servers[0])
|
||||
}
|
||||
return server
|
||||
}
|
||||
|
||||
func TestTrafficBaselinesResetIndependently(t *testing.T) {
|
||||
node := &NodeState{LastNetworkIn: 100, LastNetworkOut: 0}
|
||||
updateTrafficBaselines(node, 150, 500, false)
|
||||
@@ -48,3 +72,110 @@ func TestDisconnectPreservesOfflineDisplayMetadata(t *testing.T) {
|
||||
t.Fatalf("offline display metadata was discarded: %#v", serverStats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatsPersistenceUsesLiveMemoryBetweenFlushes(t *testing.T) {
|
||||
if statsFlushInterval != time.Minute {
|
||||
t.Fatalf("unexpected stats flush interval: %s", statsFlushInterval)
|
||||
}
|
||||
|
||||
app := newTestApp(t, minimalTestConfig())
|
||||
if err := app.PersistStats(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
initialInfo, err := os.Stat(app.opts.StatsPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app.nodeMu.Lock()
|
||||
node := app.nodes["s01"]
|
||||
node.Connected = true
|
||||
node.ConnectionID = 7
|
||||
app.nodeMu.Unlock()
|
||||
if !app.updateAgent("s01", 7, AgentStats{CPU: 42, NetworkIn: 1000, NetworkOut: 2000}) {
|
||||
t.Fatal("agent update was rejected")
|
||||
}
|
||||
|
||||
live := app.SnapshotStats()["servers"].([]any)[0].(map[string]any)
|
||||
if live["cpu"] != 42 {
|
||||
t.Fatalf("live snapshot was not updated: %#v", live)
|
||||
}
|
||||
beforeFlushInfo, err := os.Stat(app.opts.StatsPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !os.SameFile(initialInfo, beforeFlushInfo) {
|
||||
t.Fatal("reading the live snapshot unexpectedly rewrote stats.json")
|
||||
}
|
||||
if _, exists := readPersistedServer(t, app)["cpu"]; exists {
|
||||
t.Fatal("agent update reached disk before the next persistence run")
|
||||
}
|
||||
|
||||
if err := app.PersistStats(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
afterFlushInfo, err := os.Stat(app.opts.StatsPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if os.SameFile(beforeFlushInfo, afterFlushInfo) {
|
||||
t.Fatal("updated stats were not persisted")
|
||||
}
|
||||
if cpu := readPersistedServer(t, app)["cpu"]; cpu != float64(42) {
|
||||
t.Fatalf("unexpected persisted CPU value: %#v", cpu)
|
||||
}
|
||||
|
||||
if err := app.PersistStats(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cleanInfo, err := os.Stat(app.opts.StatsPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if os.SameFile(afterFlushInfo, cleanInfo) {
|
||||
t.Fatal("fixed-interval persistence unexpectedly skipped a write")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReloadNoticeIsIndependentFromPersistence(t *testing.T) {
|
||||
app := newTestApp(t, minimalTestConfig())
|
||||
app.reloadPending.Store(true)
|
||||
if err := app.PersistStats(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(app.opts.StatsPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var persisted map[string]any
|
||||
if err := json.Unmarshal(data, &persisted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, exists := persisted["reload"]; exists {
|
||||
t.Fatal("reload notice must not be persisted to stats.json")
|
||||
}
|
||||
if reloaded, _ := app.SnapshotStats()["reload"].(bool); !reloaded {
|
||||
t.Fatal("pending reload notice was not returned by the live endpoint")
|
||||
}
|
||||
if _, exists := app.SnapshotStats()["reload"]; exists {
|
||||
t.Fatal("reload notice was returned more than once")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetTrafficPersistsImmediately(t *testing.T) {
|
||||
app := newTestApp(t, minimalTestConfig())
|
||||
app.nodeMu.Lock()
|
||||
node := app.nodes["s01"]
|
||||
node.Connected = true
|
||||
node.HasUpdate = true
|
||||
node.Stats = AgentStats{NetworkIn: 1234, NetworkOut: 5678}
|
||||
app.nodeMu.Unlock()
|
||||
|
||||
if _, apiErr := app.ResetTraffic("s01"); apiErr != nil {
|
||||
t.Fatal(apiErr)
|
||||
}
|
||||
persisted := readPersistedServer(t, app)
|
||||
if persisted["last_network_in"] != float64(1234) || persisted["last_network_out"] != float64(5678) {
|
||||
t.Fatalf("traffic reset was not persisted immediately: %#v", persisted)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +98,6 @@ func (a *App) executeSSLCheck(key string, config SSLCertConfig) {
|
||||
if err != nil {
|
||||
state.LastError = err.Error()
|
||||
a.certMu.Unlock()
|
||||
a.wakeStatsWriter()
|
||||
return
|
||||
}
|
||||
state.ExpireTS = expireTS
|
||||
@@ -126,7 +125,6 @@ func (a *App) executeSSLCheck(key string, config SSLCertConfig) {
|
||||
}
|
||||
}
|
||||
a.certMu.Unlock()
|
||||
a.wakeStatsWriter()
|
||||
|
||||
for _, alert := range alerts {
|
||||
if err := a.sendCallback(config.Callback, alert.message, "ServerStatusSSL"); err != nil {
|
||||
|
||||
@@ -176,7 +176,6 @@ func (a *App) connectAgent(username, password string, conn net.Conn, family int)
|
||||
node.Pong = false
|
||||
node.Online4 = family == 4
|
||||
node.Online6 = family == 6
|
||||
a.wakeStatsWriter()
|
||||
return id, append([]MonitorConfig(nil), a.runtime.Monitors...), nil
|
||||
}
|
||||
|
||||
@@ -194,7 +193,6 @@ func (a *App) disconnectAgent(username string, conn net.Conn, connectionID uint6
|
||||
node.HasUpdate = false
|
||||
node.Pong = false
|
||||
a.nodeMu.Unlock()
|
||||
a.wakeStatsWriter()
|
||||
time.AfterFunc(25*time.Second, func() {
|
||||
if a.ctx.Err() != nil {
|
||||
return
|
||||
@@ -226,7 +224,6 @@ func (a *App) updateAgent(username string, connectionID uint64, update AgentStat
|
||||
node.HasUpdate = true
|
||||
node.LastUpdate = time.Now()
|
||||
a.nodeMu.Unlock()
|
||||
a.wakeStatsWriter()
|
||||
a.evaluateWatchdogs(username, false)
|
||||
return true
|
||||
}
|
||||
|
||||
+5
-3
@@ -1,7 +1,9 @@
|
||||
:root{color-scheme:dark;--bg:#0d1117;--bg-alt:#171d27;--surface:#1d2530;--surface-soft:#151b24;--border:#334155;--text:#f1f5f9;--text-dim:#a7b4c8;--accent:#3b82f6;--accent-glow:#7db5ff;--danger:#f05252;--warn:#fbbf24;--ok:#10b981;--radius:10px;--radius-sm:4px;--shadow:0 8px 22px -10px rgba(0,0,0,.75);--font:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,'Noto Sans SC',sans-serif;--trans:.25s cubic-bezier(.4,0,.2,1);--logo-start:#3b82f6;--logo-end:#2563eb;--logo-accent-grad-start:#8bc1ff;--logo-accent-grad-end:#d4e8ff}
|
||||
body.light{color-scheme:light;--bg:#f6f7f9;--bg-alt:#ffffff;--surface:#ffffff;--surface-soft:#f8fafc;--border:#e2e8f0;--text:#1e293b;--text-dim:#64748b;--accent:#2563eb;--accent-glow:#3b82f6;--shadow:0 4px 20px -4px rgba(0,0,0,.08);--logo-start:#2563eb;--logo-end:#1d4ed8;--logo-accent-grad-start:#1d4ed8;--logo-accent-grad-end:#60a5fa}
|
||||
html.light,body.light{color-scheme:light;--bg:#f6f7f9;--bg-alt:#ffffff;--surface:#ffffff;--surface-soft:#f8fafc;--border:#e2e8f0;--text:#1e293b;--text-dim:#64748b;--accent:#2563eb;--accent-glow:#3b82f6;--shadow:0 4px 20px -4px rgba(0,0,0,.08);--logo-start:#2563eb;--logo-end:#1d4ed8;--logo-accent-grad-start:#1d4ed8;--logo-accent-grad-end:#60a5fa}
|
||||
*{box-sizing:border-box}
|
||||
html,body{height:100%;margin:0;padding:0;font-family:var(--font);background:var(--bg);color:var(--text);-webkit-font-smoothing:antialiased}
|
||||
html,body{margin:0;padding:0;font-family:var(--font);background:var(--bg);color:var(--text);-webkit-font-smoothing:antialiased}
|
||||
html{min-height:100%}
|
||||
body{min-height:100vh}
|
||||
html.light{background:var(--bg);color-scheme:light}
|
||||
body,button{font-size:14px;line-height:1.35}
|
||||
a{color:var(--accent);text-decoration:none}
|
||||
@@ -103,7 +105,7 @@ body.light .virt-pill{background:color-mix(in srgb,var(--virt) 12%,#fff);color:c
|
||||
.node-name{display:inline-block;max-width:160px;overflow:hidden;text-overflow:ellipsis;vertical-align:middle;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:750;letter-spacing:.15px;color:var(--text)}
|
||||
.row-server[data-online="0"] .node-name{color:color-mix(in srgb,var(--text) 72%,var(--text-dim));font-weight:650}
|
||||
.row-server:hover .node-name{color:var(--accent)}
|
||||
.load-with-cores{display:inline-flex;align-items:flex-start;gap:.28rem;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:700;line-height:1;white-space:nowrap}
|
||||
.load-with-cores{display:inline-flex;align-items:flex-start;gap:.28rem;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;line-height:1;white-space:nowrap}
|
||||
.load-value{display:inline-block;min-width:2.55rem}
|
||||
.load-core-bubble{display:inline-flex;align-items:center;justify-content:center;height:14px;min-width:24px;padding:0 5px;margin-top:-6px;border-radius:999px;border:1px solid color-mix(in srgb,var(--accent) 34%,var(--border));background:color-mix(in srgb,var(--accent) 13%,var(--bg-alt));color:color-mix(in srgb,var(--accent) 74%,#fff);font-size:9px;font-weight:800;line-height:1;letter-spacing:0;box-shadow:0 1px 3px rgba(0,0,0,.22)}
|
||||
body.light .load-core-bubble{background:color-mix(in srgb,var(--accent) 8%,#fff);color:color-mix(in srgb,var(--accent) 78%,#111827);box-shadow:0 1px 2px rgba(15,23,42,.08)}
|
||||
|
||||
+4
-3
@@ -6,7 +6,7 @@
|
||||
<meta name="description" content="云监控,ServerStatus中文版,ServerStatus,ServerStatus cppla" />
|
||||
<title>云监控</title>
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
||||
<link rel="stylesheet" href="css/app.css?v=20260710-1" />
|
||||
<link rel="stylesheet" href="css/app.css?v=20260720-1" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
@@ -55,6 +55,7 @@
|
||||
<label class="select-field">
|
||||
<span>排序</span>
|
||||
<select id="sortSelect">
|
||||
<option value="config">配置顺序</option>
|
||||
<option value="name">节点</option>
|
||||
<option value="status">在线状态</option>
|
||||
<option value="load">负载</option>
|
||||
@@ -65,7 +66,7 @@
|
||||
<option value="loss">丢包</option>
|
||||
</select>
|
||||
</label>
|
||||
<button id="sortDirection" class="icon-text" title="切换排序方向">降序</button>
|
||||
<button id="sortDirection" class="icon-text" title="切换排序方向">升序</button>
|
||||
</section>
|
||||
|
||||
<section id="panel-servers" class="panel active" aria-labelledby="主机">
|
||||
@@ -200,6 +201,6 @@
|
||||
<a href="https://github.com/cppla/ServerStatus" target="_blank" rel="noopener">ServerStatus中文版</a>
|
||||
</footer>
|
||||
|
||||
<script src="js/app.js?v=20260710-1" defer></script>
|
||||
<script src="js/app.js?v=20260723-1" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+37
-5
@@ -1,3 +1,16 @@
|
||||
const SORT_PREFERENCE_KEY = 'serverstatusSortPreference';
|
||||
const SORT_FIELDS = new Set(['config','name','status','load','cpu','memory','hdd','traffic','loss']);
|
||||
|
||||
function loadSortPreference(){
|
||||
const fallback = { sort: 'config', dir: 'asc' };
|
||||
try{
|
||||
const saved = JSON.parse(localStorage.getItem(SORT_PREFERENCE_KEY) || 'null');
|
||||
if(saved && SORT_FIELDS.has(saved.sort) && (saved.dir === 'asc' || saved.dir === 'desc')) return saved;
|
||||
}catch(_err){}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const initialSortPreference = loadSortPreference();
|
||||
const S = {
|
||||
updated: 0,
|
||||
servers: [],
|
||||
@@ -9,7 +22,7 @@ const S = {
|
||||
layoutCompact: null,
|
||||
osOptionsSignature: '',
|
||||
suppressStatsReloadUntil: 0,
|
||||
filters: { query: '', status: 'all', os: 'all', sort: 'name', dir: 'desc' },
|
||||
filters: { query: '', status: 'all', os: 'all', ...initialSortPreference },
|
||||
admin: {
|
||||
token: localStorage.getItem('serverstatusAdminToken') || '',
|
||||
enabled: false,
|
||||
@@ -197,6 +210,7 @@ async function fetchData(){
|
||||
const base = [server.name || '-', server.location || '-', server.type || '-'].join('|');
|
||||
const key = `${base}#${index + 1}`;
|
||||
server._key = key;
|
||||
server._order = index;
|
||||
if(!S.hist[key]) S.hist[key] = { cu: [], ct: [], cm: [] };
|
||||
if(!S.loadHist[key]) S.loadHist[key] = { l1: [], l5: [], l15: [] };
|
||||
pushHistory(S.hist[key].cu, server.time_10010);
|
||||
@@ -239,6 +253,7 @@ function visibleServers(){
|
||||
const dir = S.filters.dir === 'asc' ? 1 : -1;
|
||||
const ma = metrics(a), mb = metrics(b);
|
||||
const value = (server, m) => ({
|
||||
config: num(server._order),
|
||||
name: String(server.name || ''),
|
||||
type: String(server.type || ''),
|
||||
location: String(server.location || ''),
|
||||
@@ -732,7 +747,17 @@ function bindTheme(){
|
||||
if(S.openDetailKey) refreshDetail();
|
||||
});
|
||||
}
|
||||
function syncSortControls(){
|
||||
$('sortSelect').value = S.filters.sort;
|
||||
$('sortDirection').textContent = S.filters.dir === 'desc' ? '降序' : '升序';
|
||||
}
|
||||
function saveSortPreference(){
|
||||
try{
|
||||
localStorage.setItem(SORT_PREFERENCE_KEY, JSON.stringify({ sort: S.filters.sort, dir: S.filters.dir }));
|
||||
}catch(_err){}
|
||||
}
|
||||
function bindFilters(){
|
||||
syncSortControls();
|
||||
$('serverSearch').addEventListener('input', e => { S.filters.query = e.target.value; scheduleServersRender(); });
|
||||
$('statusFilter').addEventListener('click', e => {
|
||||
if(e.target.tagName !== 'BUTTON') return;
|
||||
@@ -742,17 +767,24 @@ function bindFilters(){
|
||||
});
|
||||
$('osFilter').addEventListener('change', e => { S.filters.os = e.target.value; renderServersViewNow(); });
|
||||
$('osFilter').addEventListener('blur', renderOsOptions);
|
||||
$('sortSelect').addEventListener('change', e => { S.filters.sort = e.target.value; renderServersViewNow(); });
|
||||
$('sortSelect').addEventListener('change', e => {
|
||||
S.filters.sort = e.target.value;
|
||||
if(S.filters.sort === 'config') S.filters.dir = 'asc';
|
||||
syncSortControls();
|
||||
saveSortPreference();
|
||||
renderServersViewNow();
|
||||
});
|
||||
$('sortDirection').addEventListener('click', () => {
|
||||
S.filters.dir = S.filters.dir === 'desc' ? 'asc' : 'desc';
|
||||
$('sortDirection').textContent = S.filters.dir === 'desc' ? '降序' : '升序';
|
||||
syncSortControls();
|
||||
saveSortPreference();
|
||||
renderServersViewNow();
|
||||
});
|
||||
document.querySelectorAll('#serversTable th[data-sort]').forEach(th => th.addEventListener('click', () => {
|
||||
if(S.filters.sort === th.dataset.sort) S.filters.dir = S.filters.dir === 'desc' ? 'asc' : 'desc';
|
||||
else S.filters.sort = th.dataset.sort;
|
||||
$('sortSelect').value = S.filters.sort;
|
||||
$('sortDirection').textContent = S.filters.dir === 'desc' ? '降序' : '升序';
|
||||
syncSortControls();
|
||||
saveSortPreference();
|
||||
renderServersViewNow();
|
||||
}));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user