Add temp thresholds, bad block threshold, editable system settings, check for updates, history completed time
- config.py: add temp_warn_c (46°C), temp_crit_c (55°C), bad_block_threshold (0), app_version - settings_store.py: expose all new fields + system settings (truenas_base_url, api_key, poll_interval, etc.) as editable; save to JSON for persistence; add validation for log_level, poll/stale intervals, temp range - renderer.py: _temp_class() now reads temp_warn_c/temp_crit_c from settings instead of hardcoded 40/50 - burnin.py: precheck uses settings.temp_crit_c; fix NameError bug (_execute_stages referenced 'profile' that was not in scope) - routes.py: add GET /api/v1/updates/check (Forgejo releases API); settings_page passes new editable fields; save_settings skips empty truenas_api_key like smtp_password - settings.html: move system settings from read-only card into editable form; add temp/bad-block fields to Burn-In Behavior; add Check for Updates button; restart-required indicator on save - history.html: add Completed (finished_at) column next to Started - app.css: toast container shifts up when drawer is open (body.drawer-open) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c0f9098779
commit
4ab54d7ed8
8 changed files with 224 additions and 66 deletions
|
|
@ -339,7 +339,7 @@ async def _execute_stages(job_id: int, stages: list[str], devname: str, drive_id
|
||||||
await _cancel_stage(job_id, stage_name)
|
await _cancel_stage(job_id, stage_name)
|
||||||
else:
|
else:
|
||||||
await _finish_stage(job_id, stage_name, success=ok)
|
await _finish_stage(job_id, stage_name, success=ok)
|
||||||
await _recalculate_progress(job_id, profile)
|
await _recalculate_progress(job_id)
|
||||||
_push_update()
|
_push_update()
|
||||||
|
|
||||||
if not ok:
|
if not ok:
|
||||||
|
|
@ -385,8 +385,8 @@ async def _stage_precheck(job_id: int, drive_id: int) -> bool:
|
||||||
await _set_stage_error(job_id, "precheck", "Drive SMART health is FAILED — refusing to burn in")
|
await _set_stage_error(job_id, "precheck", "Drive SMART health is FAILED — refusing to burn in")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if temp and temp > 60:
|
if temp and temp > settings.temp_crit_c:
|
||||||
await _set_stage_error(job_id, "precheck", f"Drive temperature {temp}°C exceeds 60°C limit")
|
await _set_stage_error(job_id, "precheck", f"Drive temperature {temp}°C exceeds {settings.temp_crit_c}°C limit")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
await asyncio.sleep(1) # Simulate brief check
|
await asyncio.sleep(1) # Simulate brief check
|
||||||
|
|
|
||||||
|
|
@ -51,5 +51,16 @@ class Settings(BaseSettings):
|
||||||
# Stuck-job detection: jobs running longer than this are marked 'unknown'
|
# Stuck-job detection: jobs running longer than this are marked 'unknown'
|
||||||
stuck_job_hours: int = 24
|
stuck_job_hours: int = 24
|
||||||
|
|
||||||
|
# Temperature thresholds (°C) — drives table colouring + precheck gate
|
||||||
|
temp_warn_c: int = 46 # orange warning
|
||||||
|
temp_crit_c: int = 55 # red critical (precheck refuses to start above this)
|
||||||
|
|
||||||
|
# Bad-block tolerance — surface_validate fails if bad blocks exceed this
|
||||||
|
# (applies to real badblocks in Stage 7; ignored by mock simulation)
|
||||||
|
bad_block_threshold: int = 0
|
||||||
|
|
||||||
|
# Application version — used by the /api/v1/updates/check endpoint
|
||||||
|
app_version: str = "1.0.0-6d"
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,10 @@ def _format_eta(seconds: int | None) -> str:
|
||||||
def _temp_class(celsius: int | None) -> str:
|
def _temp_class(celsius: int | None) -> str:
|
||||||
if celsius is None:
|
if celsius is None:
|
||||||
return ""
|
return ""
|
||||||
if celsius < 40:
|
from app.config import settings
|
||||||
|
if celsius < settings.temp_warn_c:
|
||||||
return "temp-cool"
|
return "temp-cool"
|
||||||
if celsius < 50:
|
if celsius < settings.temp_crit_c:
|
||||||
return "temp-warm"
|
return "temp-warm"
|
||||||
return "temp-hot"
|
return "temp-hot"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -791,18 +791,9 @@ async def settings_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
db: aiosqlite.Connection = Depends(get_db),
|
db: aiosqlite.Connection = Depends(get_db),
|
||||||
):
|
):
|
||||||
# Read-only display values (require container restart to change)
|
# Editable values — real values for form fields (secrets excluded)
|
||||||
readonly = {
|
|
||||||
"truenas_base_url": settings.truenas_base_url,
|
|
||||||
"truenas_verify_tls": settings.truenas_verify_tls,
|
|
||||||
"poll_interval_seconds": settings.poll_interval_seconds,
|
|
||||||
"stale_threshold_seconds": settings.stale_threshold_seconds,
|
|
||||||
"allowed_ips": settings.allowed_ips or "(allow all)",
|
|
||||||
"log_level": settings.log_level,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Editable values — real values for form fields (password excluded)
|
|
||||||
editable = {
|
editable = {
|
||||||
|
# SMTP
|
||||||
"smtp_host": settings.smtp_host,
|
"smtp_host": settings.smtp_host,
|
||||||
"smtp_port": settings.smtp_port,
|
"smtp_port": settings.smtp_port,
|
||||||
"smtp_ssl_mode": settings.smtp_ssl_mode or "starttls",
|
"smtp_ssl_mode": settings.smtp_ssl_mode or "starttls",
|
||||||
|
|
@ -814,17 +805,30 @@ async def settings_page(
|
||||||
"smtp_daily_report_enabled": settings.smtp_daily_report_enabled,
|
"smtp_daily_report_enabled": settings.smtp_daily_report_enabled,
|
||||||
"smtp_alert_on_fail": settings.smtp_alert_on_fail,
|
"smtp_alert_on_fail": settings.smtp_alert_on_fail,
|
||||||
"smtp_alert_on_pass": settings.smtp_alert_on_pass,
|
"smtp_alert_on_pass": settings.smtp_alert_on_pass,
|
||||||
|
# Webhook
|
||||||
"webhook_url": settings.webhook_url,
|
"webhook_url": settings.webhook_url,
|
||||||
|
# Burn-in behaviour
|
||||||
"stuck_job_hours": settings.stuck_job_hours,
|
"stuck_job_hours": settings.stuck_job_hours,
|
||||||
"max_parallel_burnins": settings.max_parallel_burnins,
|
"max_parallel_burnins": settings.max_parallel_burnins,
|
||||||
|
"temp_warn_c": settings.temp_warn_c,
|
||||||
|
"temp_crit_c": settings.temp_crit_c,
|
||||||
|
"bad_block_threshold": settings.bad_block_threshold,
|
||||||
|
# System settings (restart required to fully apply)
|
||||||
|
"truenas_base_url": settings.truenas_base_url,
|
||||||
|
"truenas_verify_tls": settings.truenas_verify_tls,
|
||||||
|
"poll_interval_seconds": settings.poll_interval_seconds,
|
||||||
|
"stale_threshold_seconds": settings.stale_threshold_seconds,
|
||||||
|
"allowed_ips": settings.allowed_ips,
|
||||||
|
"log_level": settings.log_level,
|
||||||
|
# Note: truenas_api_key intentionally omitted from display (sensitive)
|
||||||
}
|
}
|
||||||
|
|
||||||
ps = poller.get_state()
|
ps = poller.get_state()
|
||||||
return templates.TemplateResponse("settings.html", {
|
return templates.TemplateResponse("settings.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"readonly": readonly,
|
|
||||||
"editable": editable,
|
"editable": editable,
|
||||||
"smtp_enabled": bool(settings.smtp_host),
|
"smtp_enabled": bool(settings.smtp_host),
|
||||||
|
"app_version": settings.app_version,
|
||||||
"poller": ps,
|
"poller": ps,
|
||||||
**_stale_context(ps),
|
**_stale_context(ps),
|
||||||
})
|
})
|
||||||
|
|
@ -832,10 +836,11 @@ async def settings_page(
|
||||||
|
|
||||||
@router.post("/api/v1/settings")
|
@router.post("/api/v1/settings")
|
||||||
async def save_settings(body: dict):
|
async def save_settings(body: dict):
|
||||||
"""Save editable runtime settings. Password is only updated if non-empty."""
|
"""Save editable runtime settings. Secrets are only updated if non-empty."""
|
||||||
# Don't overwrite password if client sent empty string
|
# Don't overwrite secrets if client sent empty string
|
||||||
if "smtp_password" in body and body["smtp_password"] == "":
|
for secret_field in ("smtp_password", "truenas_api_key"):
|
||||||
del body["smtp_password"]
|
if secret_field in body and body[secret_field] == "":
|
||||||
|
del body[secret_field]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
saved = settings_store.save(body)
|
saved = settings_store.save(body)
|
||||||
|
|
@ -854,6 +859,38 @@ async def test_smtp():
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/updates/check")
|
||||||
|
async def check_updates():
|
||||||
|
"""Check for a newer release on Forgejo."""
|
||||||
|
import httpx
|
||||||
|
current = settings.app_version
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=8.0) as client:
|
||||||
|
r = await client.get(
|
||||||
|
"https://git.hellocomputer.xyz/api/v1/repos/brandon/truenas-burnin/releases/latest",
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
if r.status_code == 200:
|
||||||
|
data = r.json()
|
||||||
|
latest = data.get("tag_name", "").lstrip("v")
|
||||||
|
up_to_date = not latest or latest == current
|
||||||
|
return {
|
||||||
|
"current": current,
|
||||||
|
"latest": latest or None,
|
||||||
|
"update_available": not up_to_date,
|
||||||
|
"message": None,
|
||||||
|
}
|
||||||
|
elif r.status_code == 404:
|
||||||
|
return {"current": current, "latest": None, "update_available": False,
|
||||||
|
"message": "No releases published yet"}
|
||||||
|
else:
|
||||||
|
return {"current": current, "latest": None, "update_available": False,
|
||||||
|
"message": f"Forgejo API returned {r.status_code}"}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"current": current, "latest": None, "update_available": False,
|
||||||
|
"message": f"Could not reach update server: {exc}"}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Print view (must be BEFORE /{job_id} int route)
|
# Print view (must be BEFORE /{job_id} int route)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ Runtime settings store — persists editable settings to /data/settings_override
|
||||||
Changes take effect immediately (in-memory setattr on the global Settings object)
|
Changes take effect immediately (in-memory setattr on the global Settings object)
|
||||||
and survive restarts (JSON file is loaded in main.py lifespan).
|
and survive restarts (JSON file is loaded in main.py lifespan).
|
||||||
|
|
||||||
Settings that require a container restart (TrueNAS URL, poll interval, allowed IPs, etc.)
|
System settings (TrueNAS URL, poll interval, etc.) are saved to JSON but require
|
||||||
are NOT included here and are display-only on the settings page.
|
a container restart to fully take effect (clients/middleware are initialized at boot).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
@ -18,6 +18,7 @@ log = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Field name → coerce function. Only fields listed here are accepted by save().
|
# Field name → coerce function. Only fields listed here are accepted by save().
|
||||||
_EDITABLE: dict[str, type] = {
|
_EDITABLE: dict[str, type] = {
|
||||||
|
# Email / SMTP
|
||||||
"smtp_host": str,
|
"smtp_host": str,
|
||||||
"smtp_ssl_mode": str,
|
"smtp_ssl_mode": str,
|
||||||
"smtp_timeout": int,
|
"smtp_timeout": int,
|
||||||
|
|
@ -29,12 +30,26 @@ _EDITABLE: dict[str, type] = {
|
||||||
"smtp_report_hour": int,
|
"smtp_report_hour": int,
|
||||||
"smtp_alert_on_fail": bool,
|
"smtp_alert_on_fail": bool,
|
||||||
"smtp_alert_on_pass": bool,
|
"smtp_alert_on_pass": bool,
|
||||||
|
# Webhook
|
||||||
"webhook_url": str,
|
"webhook_url": str,
|
||||||
|
# Burn-in behaviour
|
||||||
"stuck_job_hours": int,
|
"stuck_job_hours": int,
|
||||||
"max_parallel_burnins": int,
|
"max_parallel_burnins": int,
|
||||||
|
"temp_warn_c": int,
|
||||||
|
"temp_crit_c": int,
|
||||||
|
"bad_block_threshold": int,
|
||||||
|
# System settings — saved to JSON; require container restart to fully apply
|
||||||
|
"truenas_base_url": str,
|
||||||
|
"truenas_api_key": str,
|
||||||
|
"truenas_verify_tls": bool,
|
||||||
|
"poll_interval_seconds": int,
|
||||||
|
"stale_threshold_seconds": int,
|
||||||
|
"allowed_ips": str,
|
||||||
|
"log_level": str,
|
||||||
}
|
}
|
||||||
|
|
||||||
_VALID_SSL_MODES = {"starttls", "ssl", "plain"}
|
_VALID_SSL_MODES = {"starttls", "ssl", "plain"}
|
||||||
|
_VALID_LOG_LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
|
||||||
|
|
||||||
|
|
||||||
def _overrides_path() -> Path:
|
def _overrides_path() -> Path:
|
||||||
|
|
@ -63,6 +78,18 @@ def _apply(data: dict) -> None:
|
||||||
if key == "smtp_report_hour" and not (0 <= int(val) <= 23):
|
if key == "smtp_report_hour" and not (0 <= int(val) <= 23):
|
||||||
log.warning("settings_store: smtp_report_hour out of range — ignoring")
|
log.warning("settings_store: smtp_report_hour out of range — ignoring")
|
||||||
continue
|
continue
|
||||||
|
if key == "log_level" and val not in _VALID_LOG_LEVELS:
|
||||||
|
log.warning("settings_store: invalid log_level %r — ignoring", val)
|
||||||
|
continue
|
||||||
|
if key in ("poll_interval_seconds", "stale_threshold_seconds") and int(val) < 1:
|
||||||
|
log.warning("settings_store: %s must be >= 1 — ignoring", key)
|
||||||
|
continue
|
||||||
|
if key in ("temp_warn_c", "temp_crit_c") and not (20 <= int(val) <= 80):
|
||||||
|
log.warning("settings_store: %s out of range (20–80) — ignoring", key)
|
||||||
|
continue
|
||||||
|
if key == "bad_block_threshold" and int(val) < 0:
|
||||||
|
log.warning("settings_store: bad_block_threshold must be >= 0 — ignoring")
|
||||||
|
continue
|
||||||
setattr(settings, key, val)
|
setattr(settings, key, val)
|
||||||
except (ValueError, TypeError) as exc:
|
except (ValueError, TypeError) as exc:
|
||||||
log.warning("settings_store: invalid value for %s: %s", key, exc)
|
log.warning("settings_store: invalid value for %s: %s", key, exc)
|
||||||
|
|
|
||||||
|
|
@ -755,6 +755,11 @@ tr:hover td {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
transition: bottom 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.drawer-open #toast-container {
|
||||||
|
bottom: calc(45vh + 16px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast {
|
.toast {
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@
|
||||||
<th>State</th>
|
<th>State</th>
|
||||||
<th>Operator</th>
|
<th>Operator</th>
|
||||||
<th>Started</th>
|
<th>Started</th>
|
||||||
|
<th>Completed</th>
|
||||||
<th>Duration</th>
|
<th>Duration</th>
|
||||||
<th>Error</th>
|
<th>Error</th>
|
||||||
<th class="col-actions"></th>
|
<th class="col-actions"></th>
|
||||||
|
|
@ -54,6 +55,7 @@
|
||||||
</td>
|
</td>
|
||||||
<td class="text-muted">{{ j.operator or '—' }}</td>
|
<td class="text-muted">{{ j.operator or '—' }}</td>
|
||||||
<td class="mono text-muted">{{ j.started_at | format_dt_full }}</td>
|
<td class="mono text-muted">{{ j.started_at | format_dt_full }}</td>
|
||||||
|
<td class="mono text-muted">{{ j.finished_at | format_dt_full }}</td>
|
||||||
<td class="mono text-muted">{{ j.duration_seconds | format_duration }}</td>
|
<td class="mono text-muted">{{ j.duration_seconds | format_duration }}</td>
|
||||||
<td class="error-cell">
|
<td class="error-cell">
|
||||||
{% if j.error_text %}
|
{% if j.error_text %}
|
||||||
|
|
@ -67,7 +69,7 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="9" class="empty-state">No burn-in jobs found.</td>
|
<td colspan="10" class="empty-state">No burn-in jobs found.</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,14 @@
|
||||||
<div class="page-toolbar">
|
<div class="page-toolbar">
|
||||||
<h1 class="page-title">Settings</h1>
|
<h1 class="page-title">Settings</h1>
|
||||||
<div class="toolbar-right">
|
<div class="toolbar-right">
|
||||||
<a class="btn-export" href="/docs" target="_blank" rel="noopener">API Docs</a>
|
<button type="button" id="check-updates-btn" class="btn-secondary">Check for Updates</button>
|
||||||
|
<span id="update-result" class="settings-test-result" style="display:none;margin-left:8px"></span>
|
||||||
|
<a class="btn-export" href="/docs" target="_blank" rel="noopener" style="margin-left:8px">API Docs</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="page-subtitle">
|
<p class="page-subtitle">
|
||||||
Changes take effect immediately. Settings marked
|
Changes take effect immediately. Settings marked
|
||||||
<span class="badge-restart">restart required</span> must be changed in <code>.env</code>.
|
<span class="badge-restart">restart required</span> are saved but need a container restart to fully apply.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form id="settings-form" autocomplete="off">
|
<form id="settings-form" autocomplete="off">
|
||||||
|
|
@ -167,11 +169,87 @@
|
||||||
type="number" min="1" max="168" value="{{ editable.stuck_job_hours }}">
|
type="number" min="1" max="168" value="{{ editable.stuck_job_hours }}">
|
||||||
<span class="sf-hint">Jobs running longer than this → auto-marked unknown</span>
|
<span class="sf-hint">Jobs running longer than this → auto-marked unknown</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="sf-divider"></div>
|
||||||
|
|
||||||
|
<div class="sf-row">
|
||||||
|
<label class="sf-label" for="temp_warn_c">Temp Warning (°C)</label>
|
||||||
|
<input class="sf-input sf-input-xs" id="temp_warn_c" name="temp_warn_c"
|
||||||
|
type="number" min="20" max="80" value="{{ editable.temp_warn_c }}">
|
||||||
|
<span class="sf-hint">Show orange above this temperature</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sf-row">
|
||||||
|
<label class="sf-label" for="temp_crit_c">Temp Critical (°C)</label>
|
||||||
|
<input class="sf-input sf-input-xs" id="temp_crit_c" name="temp_crit_c"
|
||||||
|
type="number" min="20" max="80" value="{{ editable.temp_crit_c }}">
|
||||||
|
<span class="sf-hint">Show red + block burn-in start above this temperature</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sf-row">
|
||||||
|
<label class="sf-label" for="bad_block_threshold">Bad Block Threshold</label>
|
||||||
|
<input class="sf-input sf-input-xs" id="bad_block_threshold" name="bad_block_threshold"
|
||||||
|
type="number" min="0" max="9999" value="{{ editable.bad_block_threshold }}">
|
||||||
|
<span class="sf-hint">Max bad blocks before surface validate fails (Stage 7)</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div><!-- /right col -->
|
</div><!-- /right col -->
|
||||||
</div><!-- /two-col -->
|
</div><!-- /two-col -->
|
||||||
|
|
||||||
|
<!-- System settings (restart required) -->
|
||||||
|
<div class="settings-card" style="margin-top:16px">
|
||||||
|
<div class="settings-card-header">
|
||||||
|
<span class="settings-card-title">System</span>
|
||||||
|
<span class="badge-restart">restart required to apply</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-two-col" style="gap:16px">
|
||||||
|
<div class="sf-fields">
|
||||||
|
|
||||||
|
<label for="truenas_base_url">TrueNAS URL</label>
|
||||||
|
<input class="sf-input" id="truenas_base_url" name="truenas_base_url" type="text"
|
||||||
|
value="{{ editable.truenas_base_url }}" placeholder="http://10.0.0.x">
|
||||||
|
|
||||||
|
<label for="truenas_api_key">API Key</label>
|
||||||
|
<input class="sf-input" id="truenas_api_key" name="truenas_api_key" type="password"
|
||||||
|
placeholder="leave blank to keep existing" autocomplete="new-password">
|
||||||
|
|
||||||
|
<label for="truenas_verify_tls">Verify TLS</label>
|
||||||
|
<label class="toggle" style="margin-top:2px">
|
||||||
|
<input type="checkbox" id="truenas_verify_tls" name="truenas_verify_tls"
|
||||||
|
{% if editable.truenas_verify_tls %}checked{% endif %}>
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="sf-fields">
|
||||||
|
|
||||||
|
<label for="poll_interval_seconds">Poll Interval (s)</label>
|
||||||
|
<input class="sf-input sf-input-xs" id="poll_interval_seconds" name="poll_interval_seconds"
|
||||||
|
type="number" min="1" max="300" value="{{ editable.poll_interval_seconds }}">
|
||||||
|
|
||||||
|
<label for="stale_threshold_seconds">Stale Threshold (s)</label>
|
||||||
|
<input class="sf-input sf-input-xs" id="stale_threshold_seconds" name="stale_threshold_seconds"
|
||||||
|
type="number" min="1" max="600" value="{{ editable.stale_threshold_seconds }}">
|
||||||
|
|
||||||
|
<label for="log_level">Log Level</label>
|
||||||
|
<select class="sf-select" id="log_level" name="log_level">
|
||||||
|
{% for lvl in ['DEBUG','INFO','WARNING','ERROR','CRITICAL'] %}
|
||||||
|
<option value="{{ lvl }}" {% if editable.log_level == lvl %}selected{% endif %}>{{ lvl }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label for="allowed_ips">IP Allowlist</label>
|
||||||
|
<div>
|
||||||
|
<input class="sf-input" id="allowed_ips" name="allowed_ips" type="text"
|
||||||
|
value="{{ editable.allowed_ips }}" placeholder="10.0.0.0/24,127.0.0.1 (empty = allow all)">
|
||||||
|
<span class="sf-hint" style="margin-top:3px">Comma-separated IPs/CIDRs. Empty = allow all.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Save row -->
|
<!-- Save row -->
|
||||||
<div class="settings-save-bar">
|
<div class="settings-save-bar">
|
||||||
<button type="submit" class="btn-primary" id="save-btn">Save Settings</button>
|
<button type="submit" class="btn-primary" id="save-btn">Save Settings</button>
|
||||||
|
|
@ -180,40 +258,6 @@
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- System (read-only) -->
|
|
||||||
<div class="settings-card settings-card-readonly">
|
|
||||||
<div class="settings-card-header">
|
|
||||||
<span class="settings-card-title">System</span>
|
|
||||||
<span class="badge-restart">restart required to change</span>
|
|
||||||
</div>
|
|
||||||
<div class="sf-readonly-grid">
|
|
||||||
<div class="sf-ro-row">
|
|
||||||
<span class="sf-ro-label">TrueNAS URL</span>
|
|
||||||
<span class="sf-ro-value mono">{{ readonly.truenas_base_url }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="sf-ro-row">
|
|
||||||
<span class="sf-ro-label">Verify TLS</span>
|
|
||||||
<span class="sf-ro-value">{{ 'Yes' if readonly.truenas_verify_tls else 'No' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="sf-ro-row">
|
|
||||||
<span class="sf-ro-label">Poll Interval</span>
|
|
||||||
<span class="sf-ro-value mono">{{ readonly.poll_interval_seconds }}s</span>
|
|
||||||
</div>
|
|
||||||
<div class="sf-ro-row">
|
|
||||||
<span class="sf-ro-label">Stale Threshold</span>
|
|
||||||
<span class="sf-ro-value mono">{{ readonly.stale_threshold_seconds }}s</span>
|
|
||||||
</div>
|
|
||||||
<div class="sf-ro-row">
|
|
||||||
<span class="sf-ro-label">IP Allowlist</span>
|
|
||||||
<span class="sf-ro-value mono">{{ readonly.allowed_ips }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="sf-ro-row">
|
|
||||||
<span class="sf-ro-label">Log Level</span>
|
|
||||||
<span class="sf-ro-value mono">{{ readonly.log_level }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
// Dim report-hour when daily report disabled
|
// Dim report-hour when daily report disabled
|
||||||
|
|
@ -260,7 +304,12 @@
|
||||||
});
|
});
|
||||||
var data = await resp.json();
|
var data = await resp.json();
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
showResult(saveResult, true, 'Saved');
|
// Show restart notice if any system settings were saved
|
||||||
|
var systemFields = ['truenas_base_url','truenas_api_key','truenas_verify_tls',
|
||||||
|
'poll_interval_seconds','stale_threshold_seconds','allowed_ips','log_level'];
|
||||||
|
var savedKeys = data.keys || [];
|
||||||
|
var needsRestart = savedKeys.some(function(k) { return systemFields.indexOf(k) >= 0; });
|
||||||
|
showResult(saveResult, true, needsRestart ? 'Saved — restart container to apply system changes' : 'Saved');
|
||||||
} else {
|
} else {
|
||||||
showResult(saveResult, false, data.detail || 'Save failed');
|
showResult(saveResult, false, data.detail || 'Save failed');
|
||||||
}
|
}
|
||||||
|
|
@ -298,6 +347,32 @@
|
||||||
testBtn.textContent = 'Test Connection';
|
testBtn.textContent = 'Test Connection';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check for Updates
|
||||||
|
var updBtn = document.getElementById('check-updates-btn');
|
||||||
|
var updResult = document.getElementById('update-result');
|
||||||
|
updBtn.addEventListener('click', async function () {
|
||||||
|
updBtn.disabled = true;
|
||||||
|
updBtn.textContent = 'Checking…';
|
||||||
|
updResult.style.display = 'none';
|
||||||
|
try {
|
||||||
|
var resp = await fetch('/api/v1/updates/check');
|
||||||
|
var data = await resp.json();
|
||||||
|
if (data.update_available) {
|
||||||
|
showResult(updResult, false, 'Update available: v' + data.latest + ' (current: v' + data.current + ')');
|
||||||
|
} else if (data.latest) {
|
||||||
|
showResult(updResult, true, 'Up to date (v' + data.current + ')');
|
||||||
|
} else {
|
||||||
|
var msg = data.message || ('v' + data.current + ' — no releases found');
|
||||||
|
showResult(updResult, true, msg);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showResult(updResult, false, 'Network error');
|
||||||
|
} finally {
|
||||||
|
updBtn.disabled = false;
|
||||||
|
updBtn.textContent = 'Check for Updates';
|
||||||
|
}
|
||||||
|
});
|
||||||
}());
|
}());
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue