Compare commits

...

9 Commits

Author SHA1 Message Date
sagitchu ca3a643ef7 feat: optimize node monitoring and system info 2026-03-21 18:40:28 +08:00
sagitchu ce2b234843 chore: update AGENTS.md for release v2.1.9-beta10 2026-03-21 17:31:08 +08:00
sagit ac3506847c feat: implement tunnel quality polling and service monitor tuning (#362)
Implement 1s test, 30s report pattern across all monitoring subsystems.
2026-03-21 17:30:07 +08:00
sagitchu 6e3d604618 feat: implement tunnel quality polling and service monitor tuning to 1s/30s intervals 2026-03-21 17:28:52 +08:00
sagit 4417ece7cd fix: renewal reminder styling and position (#361)
Fixes the styling of the renewal reminder for nodes that have been
dismissed.
2026-03-21 10:04:02 +08:00
sagitchu bab4371ba7 fix: renewal reminder styling and position 2026-03-21 10:02:52 +08:00
sagit 960c97cee4 release: v2.1.9-beta9 (#360)
Theme persistence fix and version bump
2026-03-20 23:39:42 +08:00
sagitchu 9d05d75fd6 docs: update AGENTS.md for release v2.1.9-beta9 2026-03-20 23:37:47 +08:00
sagitchu c137bdcc63 fix: theme persistence and update version to 2.1.9-beta9 2026-03-20 23:37:35 +08:00
16 changed files with 256 additions and 69 deletions
+3 -3
View File
@@ -1,9 +1,9 @@
# PROJECT KNOWLEDGE BASE
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Generated:** Sat Mar 21 2026
**Commit:** ac35068
**Branch:** main
**Tag:** 2.1.9-beta8
**Tag:** 2.1.9-beta10
## OVERVIEW
FLVX (formerly Flux Panel) is a traffic forwarding management system built on a forked GOST v3 stack. It ships as a Go-based admin API (SQLite/PostgreSQL) + Vite/React UI + Go forwarding agent, with optional mobile WebView wrappers.
+53 -11
View File
@@ -8,6 +8,7 @@ import (
"net"
"strings"
"sync"
"sync/atomic"
"time"
"go-backend/internal/monitoring"
@@ -20,26 +21,47 @@ type nodeCommander interface {
SendCommand(nodeID int64, cmdType string, data interface{}, timeout time.Duration) (ws.CommandResult, error)
}
const serviceMonitorReportInterval = 30 * time.Second // DB write interval per monitor
type Checker struct {
repo *repo.Repository
commander nodeCommander
lastRun map[int64]int64
inFlight map[int64]struct{}
mu sync.RWMutex
cancel context.CancelFunc
wg sync.WaitGroup
// In-memory latest result per monitor (for real-time API reads)
latestResults map[int64]*model.ServiceMonitorResult
lastDBWrite map[int64]int64 // last DB write timestamp per monitorID
mu sync.RWMutex
cancel context.CancelFunc
wg sync.WaitGroup
checking int32 // atomic flag: 1 = runChecks running, 0 = idle
}
func NewChecker(repo *repo.Repository, commander nodeCommander) *Checker {
return &Checker{
repo: repo,
commander: commander,
lastRun: make(map[int64]int64),
inFlight: make(map[int64]struct{}),
repo: repo,
commander: commander,
lastRun: make(map[int64]int64),
inFlight: make(map[int64]struct{}),
latestResults: make(map[int64]*model.ServiceMonitorResult),
lastDBWrite: make(map[int64]int64),
}
}
// GetLatestCached returns the in-memory latest results (updated every 1s).
// Returns nil if no results are cached.
func (c *Checker) GetLatestCached() []*model.ServiceMonitorResult {
c.mu.RLock()
defer c.mu.RUnlock()
results := make([]*model.ServiceMonitorResult, 0, len(c.latestResults))
for _, r := range c.latestResults {
results = append(results, r)
}
return results
}
func (c *Checker) Start(ctx context.Context) {
c.mu.Lock()
ctx, cancel := context.WithCancel(ctx)
@@ -52,7 +74,7 @@ func (c *Checker) Start(ctx context.Context) {
limits := c.loadServiceMonitorLimits()
scanInterval := time.Duration(limits.CheckerScanIntervalSec) * time.Second
if scanInterval <= 0 {
scanInterval = 30 * time.Second
scanInterval = 1 * time.Second
}
timer := time.NewTimer(scanInterval)
@@ -87,6 +109,12 @@ func (c *Checker) RunOnce(m *model.ServiceMonitor) (*model.ServiceMonitorResult,
}
func (c *Checker) runChecks(ctx context.Context) {
// Skip if previous round is still running (interval < timeout guard)
if !atomic.CompareAndSwapInt32(&c.checking, 0, 1) {
return
}
defer atomic.StoreInt32(&c.checking, 0)
if c == nil || c.repo == nil {
return
}
@@ -174,6 +202,8 @@ func (c *Checker) runChecks(ctx context.Context) {
}
close(jobs)
reportIntervalMs := int64(serviceMonitorReportInterval / time.Millisecond)
for i := 0; i < workerLimit; i++ {
c.wg.Add(1)
go func() {
@@ -188,14 +218,26 @@ func (c *Checker) runChecks(ctx context.Context) {
}
ts := time.Now().UnixMilli()
result := c.executeCheck(&m, ts, limits)
if err := c.repo.InsertServiceMonitorResult(result); err != nil {
log.Printf("monitoring write failed op=service_monitor_result.insert monitor_id=%d err=%v", result.MonitorID, err)
}
// Always update in-memory cache for real-time reads
c.mu.Lock()
c.latestResults[m.ID] = result
c.lastRun[m.ID] = result.Timestamp
delete(c.inFlight, m.ID)
// Only write to DB every 30s per monitor
lastWrite := c.lastDBWrite[m.ID]
writeToDB := ts-lastWrite >= reportIntervalMs
if writeToDB {
c.lastDBWrite[m.ID] = ts
}
c.mu.Unlock()
if writeToDB {
if err := c.repo.InsertServiceMonitorResult(result); err != nil {
log.Printf("monitoring write failed op=service_monitor_result.insert monitor_id=%d err=%v", result.MonitorID, err)
}
}
}
}
}()
@@ -744,6 +744,16 @@ func (h *Handler) monitorServiceLatestResultsHandler(w http.ResponseWriter, r *h
return
}
// Try in-memory cache first (updated every 1s)
if h.healthCheck != nil {
cached := h.healthCheck.GetLatestCached()
if len(cached) > 0 {
response.WriteJSON(w, response.OK(cached))
return
}
}
// Fallback to database
results, err := h.repo.GetLatestServiceMonitorResults()
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
@@ -4,17 +4,19 @@ import (
"context"
"log"
"sync"
"sync/atomic"
"time"
"go-backend/internal/store/model"
)
const (
tunnelQualityProbeInterval = 10 * time.Second
tunnelQualityProbeTimeout = 8 * time.Second
tunnelQualityPingTimeoutMs = 5000
tunnelQualityRetention = 24 * time.Hour // keep 24h of history
tunnelQualityPruneInterval = 10 * time.Minute
tunnelQualityProbeInterval = 1 * time.Second
tunnelQualityProbeTimeout = 8 * time.Second
tunnelQualityPingTimeoutMs = 5000
tunnelQualityRetention = 24 * time.Hour // keep 24h of history
tunnelQualityPruneInterval = 10 * time.Minute
tunnelQualityReportInterval = 30 * time.Second // DB save interval
)
// tunnelQualitySnapshot is the in-memory latest probe result for a tunnel.
@@ -27,6 +29,9 @@ type tunnelQualitySnapshot struct {
Success bool `json:"success"`
ErrorMessage string `json:"errorMessage,omitempty"`
Timestamp int64 `json:"timestamp"`
// internal fields for db reporting
lastDBWrite int64 `json:"-"`
}
// tunnelQualityProber runs periodic TCP ping probes against all enabled tunnels.
@@ -38,6 +43,7 @@ type tunnelQualityProber struct {
cancel context.CancelFunc
interval time.Duration
lastPrune int64
probing int32 // atomic flag: 1 = probeAll running, 0 = idle
}
// newTunnelQualityProber creates a new prober (not yet running).
@@ -120,6 +126,12 @@ func (p *tunnelQualityProber) maybePrune() {
}
func (p *tunnelQualityProber) probeAll() {
// Skip if previous probe round is still running (interval < timeout guard)
if !atomic.CompareAndSwapInt32(&p.probing, 0, 1) {
return
}
defer atomic.StoreInt32(&p.probing, 0)
h := p.handler
if h == nil || h.repo == nil {
return
@@ -136,7 +148,7 @@ func (p *tunnelQualityProber) probeAll() {
// Probe tunnels concurrently with a worker limit
// (mirrors health.Checker worker pool pattern)
const maxWorkers = 4
const maxWorkers = 20
sem := make(chan struct{}, maxWorkers)
var wg sync.WaitGroup
@@ -303,8 +315,28 @@ func (p *tunnelQualityProber) storeResult(snap *tunnelQualitySnapshot) {
}
// Update in-memory cache (latest per tunnel)
// Retain the lastDBWrite timestamp if it exists, so we only DB write every 30s
var lastWrite int64
if existing, ok := p.cache.Load(snap.TunnelID); ok {
if eg, ok := existing.(*tunnelQualitySnapshot); ok {
lastWrite = eg.lastDBWrite
}
}
snap.lastDBWrite = lastWrite
now := time.Now().UnixMilli()
writeToDB := false
if now-snap.lastDBWrite >= int64(tunnelQualityReportInterval/time.Millisecond) {
writeToDB = true
snap.lastDBWrite = now
}
p.cache.Store(snap.TunnelID, snap)
if !writeToDB {
return
}
// Persist to database (history)
h := p.handler
if h == nil || h.repo == nil {
+4 -4
View File
@@ -29,10 +29,10 @@ const (
func DefaultServiceMonitorLimits() ServiceMonitorLimits {
return ServiceMonitorLimits{
CheckerScanIntervalSec: 30,
WorkerLimit: 5,
MinIntervalSec: 30,
DefaultIntervalSec: 60,
CheckerScanIntervalSec: 1,
WorkerLimit: 20,
MinIntervalSec: 1,
DefaultIntervalSec: 1,
MinTimeoutSec: 1,
DefaultTimeoutSec: 5,
MaxTimeoutSec: 60,
+1 -1
View File
@@ -190,7 +190,7 @@ func NewWebSocketReporter(serverURL string, secret string) *WebSocketReporter {
return &WebSocketReporter{
url: serverURL,
curBackoff: initialBackoff, // 当前退避间隔
pingInterval: 5 * time.Second, // 指标上报间隔
pingInterval: 1 * time.Second, // 指标上报间隔(每秒采集)
configInterval: 10 * time.Minute, // 配置上报间隔
ctx: ctx,
cancel: cancel,
+42
View File
@@ -0,0 +1,42 @@
# 060 Nezha-style Monitoring (1s test, 30s report)
## Objective
Update all monitoring subsystems to test every 1 second and report (write to DB) every 30 seconds, matching Nezha-style monitoring behavior.
## Changes
### 1. Tunnel Quality Prober (`go-backend/internal/http/handler/tunnel_quality_prober.go`)
- [x] Change `tunnelQualityProbeInterval` from 10s to 1s
- [x] Add `tunnelQualityReportInterval = 30s` for DB write throttling
- [x] Update `storeResult` to cache in-memory every tick, write to DB only every 30s per tunnel
- [x] Add atomic `probing` flag to prevent overlapping `probeAll()` goroutine pile-up
- [x] Increase `maxWorkers` from 4 to 20
### 2. Service Monitor Checker (`go-backend/internal/health/checker.go`)
- [x] Add `serviceMonitorReportInterval = 30s` for DB write throttling
- [x] Add `latestResults` in-memory map and `lastDBWrite` map per monitor
- [x] Add `GetLatestCached()` method for real-time API reads
- [x] Add atomic `checking` flag to prevent overlapping `runChecks()` goroutine pile-up
- [x] Modify worker goroutines to always update in-memory cache, only write to DB every 30s
### 3. Service Monitor Limits (`go-backend/internal/monitoring/limits.go`)
- [x] Change `CheckerScanIntervalSec` default from 30 to 1
- [x] Change `WorkerLimit` default from 5 to 20
- [x] Change `MinIntervalSec` default from 30 to 1
- [x] Change `DefaultIntervalSec` default from 60 to 1
### 4. Monitoring API Handler (`go-backend/internal/http/handler/monitoring.go`)
- [x] Update `monitorServiceLatestResultsHandler` to prefer in-memory cached results from `healthCheck.GetLatestCached()`
### 5. Agent WebSocket Reporter (`go-gost/x/socket/websocket_reporter.go`)
- [x] Change `pingInterval` (metric reporting) from 5s to 1s
### 6. Frontend - Tunnel Monitor (`vite-frontend/src/pages/node/tunnel-monitor-view.tsx`)
- [x] Change `QUALITY_POLL_INTERVAL` from 10s to 1s
- [x] Update detail view text: "自动探测中(每秒测试,30秒上报)"
- [x] Update list view text: "每秒探测 · 更新于 ..."
### 7. Frontend - Service Monitor (`vite-frontend/src/pages/node/monitor-view.tsx`)
- [x] Change `DEFAULT_SERVICE_MONITOR_LIMITS` defaults to match backend (1s intervals)
- [x] Change service monitor + latest results polling from 30s to 1s
- [x] Update info bar text: "每秒测试,30秒上报"
+20 -5
View File
@@ -85,11 +85,22 @@
shortcutIconLink.removeAttribute('type');
}
// 立即检测系统主题并应用
const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
// 立即检测主题并应用(优先读取 localStorage 中的用户偏好)
var savedMode = '';
try { savedMode = localStorage.getItem('flvx:theme') || ''; } catch (_) {}
var useDark;
if (savedMode === 'dark') {
useDark = true;
} else if (savedMode === 'light') {
useDark = false;
} else {
// "system" 或无保存:跟随系统
useDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
}
// 立即设置主题类,避免闪烁
if (isDarkMode) {
if (useDark) {
document.documentElement.classList.add('dark');
document.documentElement.style.colorScheme = 'dark';
} else {
@@ -97,9 +108,13 @@
document.documentElement.style.colorScheme = 'light';
}
// 监听系统主题变化
if (window.matchMedia) {
// 仅在"跟随系统"模式下监听系统主题变化
if ((!savedMode || savedMode === 'system') && window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {
// 运行时可能已由 React 接管,这里仅做 fallback
var currentMode = '';
try { currentMode = localStorage.getItem('flvx:theme') || ''; } catch (_) {}
if (currentMode && currentMode !== 'system') return; // 用户已手动选择,不干预
if (e.matches) {
document.documentElement.classList.add('dark');
document.documentElement.style.colorScheme = 'dark';
@@ -12,6 +12,12 @@ import React from "react";
import "@/themes/loader";
import { ThemeProvider as ThemeContextProvider } from "@/themes/context";
import { initThemeSystem } from "@/themes/registry";
// Restore saved theme SYNCHRONOUSLY at module load time — before first render.
// This ensures the CSS tokens are injected before any component renders,
// preventing a flash of the default theme.
initThemeSystem();
interface ThemeProviderProps {
children: React.ReactNode;
+6
View File
@@ -27,6 +27,7 @@ import {
type AnnouncementData,
} from "@/api";
import { BackIcon, SettingsIcon } from "@/components/icons";
import { ThemeSettings } from "@/components/theme-settings";
import { isAdmin } from "@/utils/auth";
import { getCachedConfigs, configCache, updateSiteConfig } from "@/config/site";
import {
@@ -979,6 +980,11 @@ export default function ConfigPage() {
</CardBody>
</Card>
{/* 主题设置 */}
<div className="mt-6">
<ThemeSettings />
</div>
{hasChanges && (
<Card className="mt-4 bg-warning-50 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800 shadow-sm overflow-hidden">
<div className="h-10 flex items-center justify-center gap-2 text-warning-700 dark:text-warning-300">
+4 -2
View File
@@ -609,6 +609,8 @@ export default function NodePage() {
if (!metric || typeof metric !== "object") return node;
const incomingUptime = metric.uptime ?? 0;
return {
...node,
connectionStatus: "online",
@@ -620,7 +622,7 @@ export default function NodePage() {
downloadTraffic: metric.netInBytes ?? metric.bytes_received ?? 0,
uploadSpeed: metric.netOutSpeed ?? metric.net_out_speed ?? 0,
downloadSpeed: metric.netInSpeed ?? metric.net_in_speed ?? 0,
uptime: metric.uptime ?? 0,
uptime: incomingUptime || node.systemInfo?.uptime || 0,
diskUsage: metric.diskUsage ?? metric.disk_usage,
load1: metric.load1,
load5: metric.load5,
@@ -1947,7 +1949,7 @@ export default function NodePage() {
{(listeners) => (
<Card
key={node.id}
className={`group relative overflow-visible shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 h-full flex flex-col ${expiryMeta.accentClassName}`}
className={`group relative overflow-visible shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 h-full flex flex-col ${node.expiryReminderDismissed ? "" : expiryMeta.accentClassName}`}
data-node-card="true"
>
<CardHeader className="pb-3 md:pb-3">
+29 -25
View File
@@ -264,9 +264,9 @@ type MetricType =
const METRICS_MAX_ROWS = 5000;
const DEFAULT_SERVICE_MONITOR_LIMITS: ServiceMonitorLimitsApiData = {
checkerScanIntervalSec: 30,
minIntervalSec: 30,
defaultIntervalSec: 60,
checkerScanIntervalSec: 1,
minIntervalSec: 1,
defaultIntervalSec: 1,
minTimeoutSec: 1,
defaultTimeoutSec: 5,
maxTimeoutSec: 60,
@@ -386,27 +386,31 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
const metric = raw as Record<string, unknown>;
const receivedAt = Date.now();
const normalized: RealtimeNodeMetric = {
receivedAt,
cpuUsage: Number(metric.cpuUsage ?? metric.cpu_usage ?? 0),
memoryUsage: Number(metric.memoryUsage ?? metric.memory_usage ?? 0),
diskUsage: Number(metric.diskUsage ?? metric.disk_usage ?? 0),
netInBytes: Number(metric.netInBytes ?? metric.bytes_received ?? 0),
netOutBytes: Number(metric.netOutBytes ?? metric.bytes_transmitted ?? 0),
netInSpeed: Number(metric.netInSpeed ?? metric.net_in_speed ?? 0),
netOutSpeed: Number(metric.netOutSpeed ?? metric.net_out_speed ?? 0),
load1: Number(metric.load1 ?? 0),
load5: Number(metric.load5 ?? 0),
load15: Number(metric.load15 ?? 0),
tcpConns: Number(metric.tcpConns ?? metric.tcp_conns ?? 0),
udpConns: Number(metric.udpConns ?? metric.udp_conns ?? 0),
uptime: Number(metric.uptime ?? 0),
};
const incomingUptime = Number(metric.uptime ?? 0);
setRealtimeNodeMetrics((prev) => ({
...prev,
[nodeId]: normalized,
}));
setRealtimeNodeMetrics((prev) => {
const normalized: RealtimeNodeMetric = {
receivedAt,
cpuUsage: Number(metric.cpuUsage ?? metric.cpu_usage ?? 0),
memoryUsage: Number(metric.memoryUsage ?? metric.memory_usage ?? 0),
diskUsage: Number(metric.diskUsage ?? metric.disk_usage ?? 0),
netInBytes: Number(metric.netInBytes ?? metric.bytes_received ?? 0),
netOutBytes: Number(metric.netOutBytes ?? metric.bytes_transmitted ?? 0),
netInSpeed: Number(metric.netInSpeed ?? metric.net_in_speed ?? 0),
netOutSpeed: Number(metric.netOutSpeed ?? metric.net_out_speed ?? 0),
load1: Number(metric.load1 ?? 0),
load5: Number(metric.load5 ?? 0),
load15: Number(metric.load15 ?? 0),
tcpConns: Number(metric.tcpConns ?? metric.tcp_conns ?? 0),
udpConns: Number(metric.udpConns ?? metric.udp_conns ?? 0),
uptime: incomingUptime || prev[nodeId]?.uptime || 0,
};
return {
...prev,
[nodeId]: normalized,
};
});
setRealtimeNodeStatus((prev) => ({
...prev,
[nodeId]: "online",
@@ -619,7 +623,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
const timer = window.setInterval(() => {
void loadServiceMonitors({ silent: true });
void loadLatestMonitorResults();
}, 30_000);
}, 1_000);
return () => window.clearInterval(timer);
}, [loadLatestMonitorResults, loadServiceMonitors]);
@@ -1412,7 +1416,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
<div className="flex items-center gap-3 min-w-0 flex-wrap">
<Chip size="sm" color="primary" variant="flat">{resolvedActiveMonitor.type.toUpperCase()}</Chip>
<span className="font-mono text-xs text-default-500">{resolvedActiveMonitor.target}</span>
<span className="text-xs text-default-500">间隔 {resolvedActiveMonitor.intervalSec}s</span>
<span className="text-xs text-default-500">每秒测试,30秒上报</span>
{activeLatestResult && Number.isFinite(activeLatestResult.latencyMs) ? (
<span className="font-mono text-xs font-semibold text-success">{activeLatestResult.latencyMs.toFixed(0)}ms</span>
) : null}
+37 -1
View File
@@ -48,13 +48,49 @@ const parseRawSystemInfo = (messageData: unknown): RawSystemInfo | null => {
return null;
};
/**
* Heuristic to verify the parsed object actually contains system-info fields.
* Prevents non-metric messages (command responses, etc.) from being mistakenly
* treated as system info, which would reset all values to 0 and cause UI flicker.
*/
const SYSTEM_INFO_KEYS = [
"uptime",
"cpu_usage",
"memory_usage",
"disk_usage",
"bytes_received",
"bytes_transmitted",
"net_in_speed",
"net_out_speed",
"tcp_conns",
"udp_conns",
"load1",
"load5",
"load15",
] as const;
const looksLikeSystemInfo = (raw: RawSystemInfo): boolean => {
let matched = 0;
for (const key of SYSTEM_INFO_KEYS) {
if (key in raw && raw[key] !== undefined) {
matched++;
if (matched >= 3) {
return true;
}
}
}
return false;
};
export const buildNodeSystemInfo = (
messageData: unknown,
previous: NodeSystemInfo | null | undefined,
): NodeSystemInfo | null => {
const raw = parseRawSystemInfo(messageData);
if (!raw) {
if (!raw || !looksLikeSystemInfo(raw)) {
return null;
}
@@ -50,7 +50,7 @@ interface TunnelMonitorViewProps {
viewMode?: "list" | "grid";
}
const QUALITY_POLL_INTERVAL = 10_000; // 10 seconds
const QUALITY_POLL_INTERVAL = 1_000; // 1 second
const formatTimestamp = (ts: number, rangeMs?: number): string => {
const date = new Date(ts);
@@ -572,7 +572,7 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
{/* Auto-probe status */}
<div className="flex items-center gap-2 text-xs text-default-500">
<LiveDot />
<span>自动探测中(每10秒)</span>
<span>自动探测中(每秒测试,30秒上报)</span>
{quality?.timestamp && (
<span className="text-default-400">
· 最近更新: {new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
@@ -712,7 +712,7 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
{lastQualityUpdate && (
<div className="flex items-center gap-1.5 text-xs text-default-500">
<LiveDot />
<span>自动探测 · 更新于 {lastQualityUpdate}</span>
<span>每秒探测 · 更新于 {lastQualityUpdate}</span>
</div>
)}
<div className="ml-auto">
-2
View File
@@ -10,7 +10,6 @@ import { Switch } from "@/shadcn-bridge/heroui/switch";
import { reinitializeBaseURL } from "@/api/network";
import { getConfigByName, updateConfig } from "@/api";
import { BackIcon } from "@/components/icons";
import { ThemeSettings } from "@/components/theme-settings";
import {
type UpdateReleaseChannel,
getUpdateReleaseChannel,
@@ -182,7 +181,6 @@ export const SettingsPage = () => {
{/* 内容区域 */}
<div className="max-w-4xl mx-auto px-4 py-6">
<div className="space-y-6">
<ThemeSettings />
<Card className="border border-gray-200 dark:border-gray-700">
<CardBody className="p-6">
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
-6
View File
@@ -29,7 +29,6 @@ import {
getSavedMode,
saveMode,
getEffectiveMode,
initThemeSystem,
registerTheme,
unregisterTheme,
type ThemeMode,
@@ -95,11 +94,6 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
// Re-render whenever register changes
useSyncExternalStore(originalSubscribe, getSnapshot);
// Initialise on mount
useEffect(() => {
initThemeSystem();
}, []);
// Listen for system mode changes
useEffect(() => {
const mq = window.matchMedia("(prefers-color-scheme: dark)");