mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-05-31 12:34:48 +00:00
Update AppImage 1.2.1.3
This commit is contained in:
@@ -117,6 +117,14 @@ export function SystemLogs() {
|
|||||||
const [customDays, setCustomDays] = useState("1")
|
const [customDays, setCustomDays] = useState("1")
|
||||||
const [refreshCounter, setRefreshCounter] = useState(0)
|
const [refreshCounter, setRefreshCounter] = useState(0)
|
||||||
|
|
||||||
|
// Real on-host counts for the selected date range. /api/logs caps
|
||||||
|
// the entries it returns at 10 000 for performance, but the Total
|
||||||
|
// / Errors / Warnings cards must show the actual counts in the
|
||||||
|
// selected window — otherwise on a busy host the user sees "10 000"
|
||||||
|
// when the host really has 438 000 entries. Fetched separately from
|
||||||
|
// /api/logs/counts which runs three lightweight `wc -l` queries.
|
||||||
|
const [logsCounts, setLogsCounts] = useState<{ total: number; errors: number; warnings: number; info: number } | null>(null)
|
||||||
|
|
||||||
// Single unified useEffect for all data loading
|
// Single unified useEffect for all data loading
|
||||||
// Fires on mount, when filters change, or when refresh is triggered
|
// Fires on mount, when filters change, or when refresh is triggered
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -125,17 +133,21 @@ export function SystemLogs() {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const [logsRes, backupsRes, eventsRes, notificationsRes] = await Promise.all([
|
const daysAgo = dateFilter === "custom" ? Number.parseInt(customDays) : Number.parseInt(dateFilter)
|
||||||
|
const clampedDays = Math.max(1, Math.min(daysAgo || 1, 90))
|
||||||
|
const [logsRes, backupsRes, eventsRes, notificationsRes, countsRes] = await Promise.all([
|
||||||
fetchSystemLogs(dateFilter, customDays),
|
fetchSystemLogs(dateFilter, customDays),
|
||||||
fetchApi("/api/backups"),
|
fetchApi<{ backups?: Backup[] }>("/api/backups"),
|
||||||
fetchApi("/api/events?limit=50"),
|
fetchApi<{ events?: Event[] }>("/api/events?limit=50"),
|
||||||
fetchApi("/api/notifications"),
|
fetchApi<{ notifications?: Notification[] }>("/api/notifications"),
|
||||||
|
fetchApi<{ total: number; errors: number; warnings: number; info: number }>(`/api/logs/counts?since_days=${clampedDays}`),
|
||||||
])
|
])
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setLogs(logsRes)
|
setLogs(logsRes)
|
||||||
setBackups(backupsRes.backups || [])
|
setBackups(backupsRes.backups || [])
|
||||||
setEvents(eventsRes.events || [])
|
setEvents(eventsRes.events || [])
|
||||||
setNotifications(notificationsRes.notifications || [])
|
setNotifications(notificationsRes.notifications || [])
|
||||||
|
setLogsCounts(countsRes)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setError("Failed to connect to server")
|
setError("Failed to connect to server")
|
||||||
@@ -162,9 +174,8 @@ export function SystemLogs() {
|
|||||||
const clampedDays = Math.max(1, Math.min(daysAgo || 1, 90))
|
const clampedDays = Math.max(1, Math.min(daysAgo || 1, 90))
|
||||||
const apiUrl = `/api/logs?since_days=${clampedDays}`
|
const apiUrl = `/api/logs?since_days=${clampedDays}`
|
||||||
|
|
||||||
const data = await fetchApi(apiUrl)
|
const data = await fetchApi<{ logs?: SystemLog[] } | SystemLog[]>(apiUrl)
|
||||||
const logsArray = Array.isArray(data) ? data : data.logs || []
|
return Array.isArray(data) ? data : data.logs || []
|
||||||
return logsArray
|
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to load logs. Please try again.")
|
setError("Failed to load logs. Please try again.")
|
||||||
return []
|
return []
|
||||||
@@ -588,9 +599,9 @@ export function SystemLogs() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold text-foreground">
|
<div className="text-2xl font-bold text-foreground">
|
||||||
{filteredCombinedLogs.length.toLocaleString("fr-FR")}
|
{(logsCounts?.total ?? 0).toLocaleString("fr-FR")}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-2">Filtered</p>
|
<p className="text-xs text-muted-foreground mt-2">In selected range</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -600,7 +611,7 @@ export function SystemLogs() {
|
|||||||
<XCircle className="h-4 w-4 text-red-500" />
|
<XCircle className="h-4 w-4 text-red-500" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold text-red-500">{logCounts.error.toLocaleString("fr-FR")}</div>
|
<div className="text-2xl font-bold text-red-500">{(logsCounts?.errors ?? 0).toLocaleString("fr-FR")}</div>
|
||||||
<p className="text-xs text-muted-foreground mt-2">Requires attention</p>
|
<p className="text-xs text-muted-foreground mt-2">Requires attention</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -611,7 +622,7 @@ export function SystemLogs() {
|
|||||||
<AlertTriangle className="h-4 w-4 text-yellow-500" />
|
<AlertTriangle className="h-4 w-4 text-yellow-500" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold text-yellow-500">{logCounts.warning.toLocaleString("fr-FR")}</div>
|
<div className="text-2xl font-bold text-yellow-500">{(logsCounts?.warnings ?? 0).toLocaleString("fr-FR")}</div>
|
||||||
<p className="text-xs text-muted-foreground mt-2">Monitor closely</p>
|
<p className="text-xs text-muted-foreground mt-2">Monitor closely</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -9606,40 +9606,120 @@ def api_node_metrics():
|
|||||||
|
|
||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/api/logs/counts', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def api_logs_counts():
|
||||||
|
"""Return exact total / errors / warnings / info counts for a
|
||||||
|
date range, without loading the actual log entries.
|
||||||
|
|
||||||
|
Used by the dashboard so the "Total Entries / Errors / Warnings"
|
||||||
|
cards reflect the REAL counts on the host even when /api/logs
|
||||||
|
only loads the first 10 000 entries (cap-for-performance).
|
||||||
|
|
||||||
|
Implementation: three `journalctl --quiet ... | wc -l` calls, one
|
||||||
|
per priority bucket. Each runs in well under a second even on a
|
||||||
|
busy host because there's no JSON output and no per-entry parsing.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
since_days = request.args.get('since_days', '1')
|
||||||
|
try:
|
||||||
|
days = max(1, min(int(since_days), 90))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
days = 1
|
||||||
|
|
||||||
|
def _count(extra_args):
|
||||||
|
cmd = ['journalctl', '--quiet', '--no-pager',
|
||||||
|
'--since', f'{days} days ago'] + extra_args
|
||||||
|
try:
|
||||||
|
out = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||||
|
if out.returncode != 0:
|
||||||
|
return 0
|
||||||
|
# `journalctl --quiet` emits one line per entry; the
|
||||||
|
# trailing newline produces an extra empty split, so
|
||||||
|
# filter it.
|
||||||
|
return sum(1 for ln in out.stdout.split('\n') if ln)
|
||||||
|
except (subprocess.TimeoutExpired, OSError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
total = _count([])
|
||||||
|
errors = _count(['-p', '0..3']) # emerg..err
|
||||||
|
warnings = _count(['-p', '4..4']) # warning only
|
||||||
|
info = max(0, total - errors - warnings)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'since_days': days,
|
||||||
|
'total': total,
|
||||||
|
'errors': errors,
|
||||||
|
'warnings': warnings,
|
||||||
|
'info': info,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({
|
||||||
|
'error': f'Unable to compute log counts: {str(e)}',
|
||||||
|
'since_days': 1,
|
||||||
|
'total': 0,
|
||||||
|
'errors': 0,
|
||||||
|
'warnings': 0,
|
||||||
|
'info': 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/logs', methods=['GET'])
|
@app.route('/api/logs', methods=['GET'])
|
||||||
@require_auth
|
@require_auth
|
||||||
def api_logs():
|
def api_logs():
|
||||||
"""Get system logs"""
|
"""Get system logs.
|
||||||
|
|
||||||
|
Hard-capped at MAX_ENTRIES results per request to keep the JSON
|
||||||
|
response under a few MB. Without this cap, a busy host (e.g. .55
|
||||||
|
with 438k entries in a single day driven by an SSL handshake
|
||||||
|
loop) returned ~50 MB of JSON, which took 15-20 s to fetch and
|
||||||
|
parse on the client. The cap returns the MOST RECENT entries
|
||||||
|
(journalctl -n) — the dashboard pairs this with /api/logs/counts
|
||||||
|
to show accurate Total/Errors/Warnings cards and a "Load more"
|
||||||
|
button when the user reaches the bottom of the loaded slice.
|
||||||
|
"""
|
||||||
|
MAX_ENTRIES = 10000
|
||||||
try:
|
try:
|
||||||
limit = request.args.get('limit', '200')
|
limit = request.args.get('limit', '200')
|
||||||
priority = request.args.get('priority', None) # 0-7 (0=emerg, 3=err, 4=warning, 6=info)
|
priority = request.args.get('priority', None) # 0-7 (0=emerg, 3=err, 4=warning, 6=info)
|
||||||
service = request.args.get('service', None)
|
service = request.args.get('service', None)
|
||||||
since_days = request.args.get('since_days', None)
|
since_days = request.args.get('since_days', None)
|
||||||
|
|
||||||
if since_days:
|
if since_days:
|
||||||
try:
|
try:
|
||||||
days = int(since_days)
|
days = int(since_days)
|
||||||
# Cap at 90 days to prevent excessive queries
|
# Cap at 90 days to prevent excessive queries
|
||||||
days = min(days, 90)
|
days = min(days, 90)
|
||||||
# No -n limit when using --since: the time range already bounds the query.
|
# Both `--since` AND `-n MAX_ENTRIES`: the date range
|
||||||
# A hard -n 10000 was masking differences between date ranges on busy servers.
|
# bounds the query, and -n caps how many of those are
|
||||||
cmd = ['journalctl', '--since', f'{days} days ago', '--output', 'json', '--no-pager']
|
# actually returned to the client. journalctl applies
|
||||||
|
# -n to the END of the matching range, so we get the
|
||||||
|
# most-recent entries within the window. Without -n a
|
||||||
|
# 438k-entry day produced a 50 MB JSON response.
|
||||||
|
cmd = ['journalctl', '--since', f'{days} days ago', '-n', str(MAX_ENTRIES),
|
||||||
|
'--output', 'json', '--no-pager']
|
||||||
except ValueError:
|
except ValueError:
|
||||||
cmd = ['journalctl', '-n', limit, '--output', 'json', '--no-pager']
|
cmd = ['journalctl', '-n', limit, '--output', 'json', '--no-pager']
|
||||||
else:
|
else:
|
||||||
|
# Cap the user-supplied limit too — a misbehaving client
|
||||||
|
# could ask for limit=1000000 otherwise.
|
||||||
|
try:
|
||||||
|
limit = str(min(int(limit), MAX_ENTRIES))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
limit = str(MAX_ENTRIES)
|
||||||
cmd = ['journalctl', '-n', limit, '--output', 'json', '--no-pager']
|
cmd = ['journalctl', '-n', limit, '--output', 'json', '--no-pager']
|
||||||
|
|
||||||
# Add priority filter if specified
|
# Add priority filter if specified
|
||||||
if priority:
|
if priority:
|
||||||
cmd.extend(['-p', priority])
|
cmd.extend(['-p', priority])
|
||||||
|
|
||||||
# Add service filter by SYSLOG_IDENTIFIER (not -u which filters by systemd unit)
|
# Add service filter by SYSLOG_IDENTIFIER (not -u which filters by systemd unit)
|
||||||
# We filter after fetching since journalctl doesn't have a direct SYSLOG_IDENTIFIER flag
|
# We filter after fetching since journalctl doesn't have a direct SYSLOG_IDENTIFIER flag
|
||||||
service_filter = service
|
service_filter = service
|
||||||
|
|
||||||
# Longer timeout for date-range queries which may return many entries
|
# Longer timeout for date-range queries which may return many entries
|
||||||
query_timeout = 120 if since_days else 30
|
query_timeout = 120 if since_days else 30
|
||||||
|
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=query_timeout)
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=query_timeout)
|
||||||
|
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
@@ -9648,23 +9728,25 @@ def api_logs():
|
|||||||
'0': 'emergency', '1': 'alert', '2': 'critical', '3': 'error',
|
'0': 'emergency', '1': 'alert', '2': 'critical', '3': 'error',
|
||||||
'4': 'warning', '5': 'notice', '6': 'info', '7': 'debug'
|
'4': 'warning', '5': 'notice', '6': 'info', '7': 'debug'
|
||||||
}
|
}
|
||||||
for line in result.stdout.strip().split('\n'):
|
raw_lines = result.stdout.strip().split('\n')
|
||||||
|
total_seen = sum(1 for ln in raw_lines if ln)
|
||||||
|
for line in raw_lines:
|
||||||
if line:
|
if line:
|
||||||
try:
|
try:
|
||||||
log_entry = json.loads(line)
|
log_entry = json.loads(line)
|
||||||
timestamp_us = int(log_entry.get('__REALTIME_TIMESTAMP', '0'))
|
timestamp_us = int(log_entry.get('__REALTIME_TIMESTAMP', '0'))
|
||||||
timestamp = datetime.fromtimestamp(timestamp_us / 1000000).strftime('%Y-%m-%d %H:%M:%S')
|
timestamp = datetime.fromtimestamp(timestamp_us / 1000000).strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
priority_num = str(log_entry.get('PRIORITY', '6'))
|
priority_num = str(log_entry.get('PRIORITY', '6'))
|
||||||
level = priority_map.get(priority_num, 'info')
|
level = priority_map.get(priority_num, 'info')
|
||||||
|
|
||||||
syslog_id = log_entry.get('SYSLOG_IDENTIFIER', '')
|
syslog_id = log_entry.get('SYSLOG_IDENTIFIER', '')
|
||||||
systemd_unit = log_entry.get('_SYSTEMD_UNIT', '')
|
systemd_unit = log_entry.get('_SYSTEMD_UNIT', '')
|
||||||
service_name = syslog_id or systemd_unit or 'system'
|
service_name = syslog_id or systemd_unit or 'system'
|
||||||
|
|
||||||
if service_filter and service_name != service_filter:
|
if service_filter and service_name != service_filter:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
logs.append({
|
logs.append({
|
||||||
'timestamp': timestamp,
|
'timestamp': timestamp,
|
||||||
'level': level,
|
'level': level,
|
||||||
@@ -9677,8 +9759,16 @@ def api_logs():
|
|||||||
})
|
})
|
||||||
except (json.JSONDecodeError, ValueError):
|
except (json.JSONDecodeError, ValueError):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
return jsonify({'logs': logs, 'total': len(logs)})
|
# `truncated` is true when journalctl hit the -n cap.
|
||||||
|
# `total_seen` is what we asked journalctl for; the real
|
||||||
|
# underlying count (post-filter on disk) may be larger.
|
||||||
|
return jsonify({
|
||||||
|
'logs': logs,
|
||||||
|
'total': len(logs),
|
||||||
|
'truncated': total_seen >= MAX_ENTRIES,
|
||||||
|
'max_entries': MAX_ENTRIES,
|
||||||
|
})
|
||||||
else:
|
else:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'error': 'journalctl not available or failed',
|
'error': 'journalctl not available or failed',
|
||||||
@@ -11772,6 +11862,46 @@ if __name__ == '__main__':
|
|||||||
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||||
ssl_context.load_cert_chain(ssl_cert, ssl_key)
|
ssl_context.load_cert_chain(ssl_cert, ssl_key)
|
||||||
|
|
||||||
|
# Defensive: silence the ~30-line traceback that gevent
|
||||||
|
# prints whenever a client sends plain HTTP against this
|
||||||
|
# https endpoint. We observed Home-Assistant integrations
|
||||||
|
# with `http://host:8008/...` URLs and iPad Safari opening
|
||||||
|
# ws:// (instead of wss://) generating ~4 errors/sec on
|
||||||
|
# .55, which inflated journal volume and pushed RSS into
|
||||||
|
# the gigabytes — on one occasion to 4.4 GB before OOM.
|
||||||
|
# Earlier attempts to fix this by subclassing WSGIServer
|
||||||
|
# and catching SSLError in `wrap_socket_and_handle` broke
|
||||||
|
# the legitimate wss handshake path (SSLWantReadError
|
||||||
|
# propagation tangled with the script_runner read thread).
|
||||||
|
# This monkey-patch is the surgical alternative: it
|
||||||
|
# intercepts ONLY the traceback printer of `Greenlet`,
|
||||||
|
# filters by exception type + message text, and leaves
|
||||||
|
# everything else (sockets, gevent's event loop,
|
||||||
|
# SSLWantRead/Write flow-control) entirely untouched.
|
||||||
|
try:
|
||||||
|
import gevent.hub as _gh
|
||||||
|
_orig_handle_error = _gh.Hub.handle_error
|
||||||
|
def _quiet_ssl_handle_error(self, context, type_, value, tb):
|
||||||
|
try:
|
||||||
|
if isinstance(value, ssl.SSLError):
|
||||||
|
msg = str(value)
|
||||||
|
# Drop only "hard" client-side errors —
|
||||||
|
# NEVER touch SSLWantReadError /
|
||||||
|
# SSLWantWriteError / SSLZeroReturnError
|
||||||
|
# which are normal control flow.
|
||||||
|
if ('HTTP_REQUEST' in msg or
|
||||||
|
'RECORD_LAYER_FAILURE' in msg or
|
||||||
|
'WRONG_VERSION_NUMBER' in msg or
|
||||||
|
'UNKNOWN_PROTOCOL' in msg):
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return _orig_handle_error(self, context, type_, value, tb)
|
||||||
|
_gh.Hub.handle_error = _quiet_ssl_handle_error
|
||||||
|
print("[ProxMenux] SSL handshake-error traceback suppression installed", flush=True)
|
||||||
|
except Exception as _e:
|
||||||
|
print(f"[ProxMenux] WARN: could not install SSL traceback filter ({_e}); journals may grow under misconfigured clients", flush=True)
|
||||||
|
|
||||||
print("[ProxMenux] Starting gevent server with SSL/WSS support...")
|
print("[ProxMenux] Starting gevent server with SSL/WSS support...")
|
||||||
# IMPORTANT: do NOT pass `handler_class=WebSocketHandler`
|
# IMPORTANT: do NOT pass `handler_class=WebSocketHandler`
|
||||||
# from geventwebsocket. flask-sock (the library wiring our
|
# from geventwebsocket. flask-sock (the library wiring our
|
||||||
|
|||||||
Reference in New Issue
Block a user