06/04 Improve system logs UI/UX

This commit is contained in:
2026-06-04 11:58:04 -04:00
parent 1fc33ca60f
commit 48322b609f
5 changed files with 553 additions and 250 deletions
+90 -37
View File
@@ -21,6 +21,8 @@ Self-hosted personal finance web app. Tracks income, expenses, investments. AI a
- Budget utilization per category
- Recent transactions feed (last 8)
- AI daily insight card (Groq-generated, stored in DB)
- **Savings Rate** stat card — net cash flow ÷ income for the selected period; green ≥ 20%, blue > 0%, red negative
- **Schwab expiry warning** — banner shown when Schwab refresh token expires within 2 days
- USD → VND exchange rate widget — reference only, independent of app currency
- Click to expand 30-day history chart
- ↻ refresh button (force-fetches fresh rate without page reload)
@@ -89,6 +91,7 @@ Self-hosted personal finance web app. Tracks income, expenses, investments. AI a
- **Per-account sections** — when investments span multiple accounts (e.g. Individual + Roth IRA), holdings are grouped into one card per account, each showing account name, total value, and holdings table. Allocation sidebar also shows "By Account" breakdown
- `investments.account_id` FK — each Schwab-synced holding is stamped with its source account; same ticker in different accounts (AAPL in Individual vs Roth IRA) stays as separate rows
- Holdings table uses a Jinja2 `{% macro %}` (reused across single and multi-account views)
- **Price history chart** on investment detail page — 1W / 1M / 3M / 6M / 1Y timeframe buttons; fetches from `/investments/api/price-history/<ticker>`
### 2.8 AI Financial Assistant
- Chat UI with SSE streaming (Groq API, word-by-word response)
@@ -124,6 +127,8 @@ Self-hosted personal finance web app. Tracks income, expenses, investments. AI a
- Profile: name, email, timezone, currency, Groq model
- 8 currency options (USD/VND/EUR/GBP/JPY/AUD/CAD/SGD) — auto-updates symbol
- Password change (requires current password)
- **Two-Factor Authentication (TOTP)** — enable/disable TOTP 2FA; setup shows QR code + manual key entry; disable requires password confirmation
- **Audit Log** (`/settings/audit`) — paginated log of login, 2FA, password, and bank-connection events with IP address; filterable by event type
- Recurring rules: CRUD, pause/enable, frequency (daily/weekly/biweekly/monthly/quarterly/yearly)
- "Run Now" button to process due rules immediately
- CSV import: upload → preview with ⚠ warnings → confirm
@@ -175,8 +180,10 @@ Self-hosted personal finance web app. Tracks income, expenses, investments. AI a
- Position zero-quantity and empty-symbol positions skipped; `null` positions array guarded
- **Investments sync**: topbar "Sync Schwab" button on investments page → `POST /investments/sync-schwab` → runs snapshot for all mapped accounts (ignores stale `connection_id` — always uses active connection)
- Token auto-refresh: access token expires 30 min; refreshed automatically before API calls
- **Refresh token expiry**: `refresh_token_expires_at` tracked in DB; reset on every successful token exchange; dashboard warns when ≤ 2 days remain
- **Account type map**: CASH→checking, MARGIN/IRA/ROTH_IRA/ROLLOVER_IRA/TRADITIONAL_IRA/401K/ROTH_401K/BROKERAGE→investment; unknown types fall back to `'other'`
- Config: `SCHWAB_CLIENT_ID`, `SCHWAB_CLIENT_SECRET`, `SCHWAB_REDIRECT_URI`
- Models: `schwab_connections`, `schwab_accounts` (2 new tables)
- Models: `schwab_connections`, `schwab_accounts` (2 tables)
### 2.15 Bank Statement Import
- Sidebar link: "Import Statement" under Money section
@@ -195,9 +202,9 @@ Self-hosted personal finance web app. Tracks income, expenses, investments. AI a
## 3. Database Schema (MySQL)
### All 18 Tables
### All 19 Tables
```
users — single user, hashed password, currency/timezone prefs
users — single user, hashed password, currency/timezone prefs, totp_secret, totp_enabled
accounts — bank/wallet accounts (balance managed per provider rules)
categories — expense/income categories with color/icon
transactions — income/expense/transfer, receipt_id, recurring_rule_id
@@ -211,9 +218,10 @@ investment_transactions — buy/sell/dividend/split log
net_worth_snapshots — monthly snapshots: assets, liabilities, net_worth (JSON)
ai_insights — stored AI responses: daily_summary / chat_response
fx_rates — daily USD/VND rate cache (date UNIQUE, source)
teller_enrollments — Teller enrollment: enrollment_id, access_token, institution_name
audit_logs — security event log: action, description, ip_address, timestamp
teller_enrollments — Teller enrollment: enrollment_id, access_token (TEXT, encrypted), institution_name
teller_accounts — Teller account ↔ PFM account mapping, last_sync_date
schwab_connections — Schwab OAuth tokens: access_token, refresh_token, token_expires_at
schwab_connections — Schwab OAuth tokens (TEXT, encrypted), token_expires_at, refresh_token_expires_at
schwab_accounts — Schwab account ↔ PFM account mapping, account_hash (hashValue)
```
@@ -223,10 +231,14 @@ schwab_accounts — Schwab account ↔ PFM account mapping, account_hash
- `investments.shares` / `avg_cost_basis` — recalculated from `investment_transactions` (FIFO) for manual holdings; overwritten directly by Schwab snapshot for synced holdings
- `transactions.notes` — used to store import source IDs: `Teller:<id>`, `Schwab:<activityId>`, or `import:<fitid>`
- `schwab_accounts.account_hash` — Schwab `hashValue` (encrypted account number), required in all API paths
- `teller_enrollments.access_token` — stored as `TEXT` (widened from VARCHAR(128)); encrypted at rest via `EncryptedText` TypeDecorator
- `schwab_connections.access_token` / `refresh_token``TEXT`, encrypted at rest; `refresh_token_expires_at` reset on every token exchange
- `users.totp_secret` — base32 TOTP secret (VARCHAR 64); NULL when 2FA disabled
### Migration Scripts
```
scripts/add_investment_account.py — adds investments.account_id column (run once after deploy)
scripts/add_investment_account.py — adds investments.account_id column (run once)
scripts/add_security_columns.py — adds totp columns, audit_logs table, widens token columns to TEXT (run once)
```
---
@@ -244,12 +256,12 @@ pfm/ # /home/pfm/web on server
│ └── app.log
├── app/
│ ├── __init__.py
│ ├── config.py # SCHWAB_* vars added
│ ├── extensions.py
│ ├── __init__.py # session idle timeout hook; Sentry init; limiter init
│ ├── config.py # SENTRY_DSN, SESSION_IDLE_MINUTES, RATELIMIT_STORAGE_URI added
│ ├── extensions.py # + limiter (flask-limiter, storage via RATELIMIT_STORAGE_URI)
│ │
│ ├── models/
│ │ ├── user.py
│ │ ├── user.py # + totp_secret, totp_enabled columns
│ │ ├── account.py
│ │ ├── category.py
│ │ ├── transaction.py
@@ -261,23 +273,24 @@ pfm/ # /home/pfm/web on server
│ │ ├── net_worth_snapshot.py
│ │ ├── ai_insight.py
│ │ ├── fx_rate.py
│ │ ├── teller_enrollment.py # TellerEnrollment + TellerAccount
│ │ ── schwab_connection.py # SchwabConnection + SchwabAccount (NEW)
│ │ ├── audit_log.py # AuditLog model (action, description, ip_address, timestamp)
│ │ ── teller_enrollment.py # access_token now EncryptedText (TEXT column)
│ │ └── schwab_connection.py # tokens now EncryptedText; + refresh_token_expires_at
│ │
│ ├── routes/
│ │ ├── auth.py
│ │ ├── dashboard.py # credit card "owed" display; no recalc_all
│ │ ├── auth.py # + TOTP verify/setup/disable routes; rate limits; audit calls
│ │ ├── dashboard.py # + savings_rate; Schwab expiry warning
│ │ ├── accounts.py # teller_map + schwab_map; skip calc_balance for providers
│ │ ├── categories.py
│ │ ├── transactions.py # + set-category AJAX endpoint
│ │ ├── transactions.py # + set-category AJAX; fixed income-form-submits-as-expense bug
│ │ ├── budgets.py
│ │ ├── goals.py
│ │ ├── investments.py # + sync-schwab route
│ │ ├── investments.py # + sync-schwab route; price-history API
│ │ ├── reports.py
│ │ ├── ai.py
│ │ ├── settings.py
│ │ ├── teller.py # balance uses ledger/available correctly; full_resync honours next param
│ │ ├── schwab.py # OAuth, mapping, sync, snapshot (NEW)
│ │ ├── settings.py # + audit_log route; audit calls on password change
│ │ ├── teller.py # balance uses ledger/available correctly
│ │ ├── schwab.py # OAuth, mapping, sync, snapshot; audit calls; fallback type 'other'
│ │ ├── bank_import.py
│ │ └── logs.py
│ │
@@ -294,22 +307,29 @@ pfm/ # /home/pfm/web on server
│ │ ├── recurring_service.py
│ │ ├── report_service.py
│ │ ├── teller_service.py # auto_categorize; correct sign convention; live balance after sync
│ │ ├── schwab_service.py # OAuth, account hash, snapshot sync, position upsert (NEW)
│ │ ├── schwab_service.py # + expanded ACCOUNT_TYPE_MAP; refresh_token_expires_at always reset
│ │ └── bank_import_service.py
│ │
│ ├── templates/
│ │ ├── base.html
│ │ ├── dashboard/index.html # credit card owed display
│ │ ├── base.html # mobile responsive tweaks
│ │ ├── auth/totp_setup.html # QR code + manual key entry for 2FA setup
│ │ ├── auth/totp_verify.html # 6-digit code entry on login
│ │ ├── dashboard/index.html # + savings_rate card; Schwab warning banner
│ │ ├── accounts/index.html # Teller/Schwab badges + action buttons
│ │ ├── transactions/index.html # inline category <select> + AJAX
│ │ ├── investments/index.html # per-account sections; holdings_table macro; Sync Schwab btn
│ │ ├── teller/index.html # connect/disconnect only (sync buttons removed)
│ │ ├── schwab/ # index.html, map_accounts.html, preview.html (NEW)
│ │ ├── transactions/index.html # inline category <select> + AJAX; pagination info
│ │ ├── investments/index.html # per-account sections; Sync Schwab btn
│ │ ├── investments/detail.html # + price history chart (1W/1M/3M/6M/1Y)
│ │ ├── settings/audit.html # audit log viewer with event filter
│ │ ├── settings/index.html # + 2FA section; audit log nav card
│ │ ├── teller/index.html # connect/disconnect only
│ │ ├── schwab/ # index.html, map_accounts.html, preview.html
│ │ └── ... (other templates unchanged)
│ │
│ └── utils/
│ ├── formatters.py
── decorators.py
── decorators.py
│ ├── audit.py # audit() helper — writes AuditLog rows; swallows DB errors
│ └── crypto.py # EncryptedText SQLAlchemy TypeDecorator (Fernet, key=SHA256(SECRET_KEY))
├── scripts/
│ ├── init_db.py
@@ -318,7 +338,9 @@ pfm/ # /home/pfm/web on server
│ ├── fetch_prices.py
│ ├── daily_snapshot.py
│ ├── daily_ai_insight.py
── add_investment_account.py # NEW — adds investments.account_id column
── add_investment_account.py # adds investments.account_id column
│ ├── add_security_columns.py # adds TOTP cols, audit_logs table, widens token cols to TEXT
│ └── sync_schwab.py # daily Schwab auto-sync (balance + positions + transactions)
└── tests/
```
@@ -345,6 +367,12 @@ requests==2.32.3
cryptography==44.0.2
python-dateutil==2.9.0
pdfplumber==0.11.4
# Security
flask-limiter==3.5.0
pyotp==2.9.0
qrcode==7.4.2
# Monitoring
sentry-sdk[flask]==2.7.0
```
---
@@ -452,10 +480,16 @@ pdfplumber==0.11.4
- Single-user, Flask-Login, session-based
- Hashed password (Werkzeug `generate_password_hash`)
- `SESSION_COOKIE_SECURE=True`, `SESSION_COOKIE_HTTPONLY=True`, `SESSION_COOKIE_SAMESITE='Lax'`
- **Session idle timeout** — configurable via `SESSION_IDLE_MINUTES` (default 60); enforced in `before_request` hook
- **TOTP 2FA** — optional TOTP second factor (pyotp); setup via QR code; verify endpoint rate-limited `10/min; 30/hr`; 5 failed attempts clears pending session and forces re-login
- **Rate limiting** — flask-limiter on login (`10/min; 30/hr`), TOTP verify (`10/min; 30/hr`), TOTP setup (`10/min`); storage backend set via `RATELIMIT_STORAGE_URI` (use Redis in production to share limits across Gunicorn workers; defaults to `memory://` per-process if unset)
- **At-rest encryption** — Teller and Schwab OAuth tokens encrypted in DB via `EncryptedText` SQLAlchemy TypeDecorator (Fernet symmetric, key = SHA-256(SECRET_KEY)); columns are `TEXT` not `VARCHAR`
- **Audit log** — security events written to `audit_logs` table via `app/utils/audit.py`; events: `login_success`, `login_success_2fa`, `login_failed`, `login_failed_2fa`, `totp_enabled`, `totp_disabled`, `password_changed`, `schwab_connected`, `schwab_disconnected`
- CSRF protection on all forms (Flask-WTF); meta tag in base.html for AJAX
- SQLAlchemy ORM (no raw SQL)
- Schwab OAuth state parameter validated on callback (CSRF protection)
- `next` redirect params validated to start with `/` (no open redirect)
- **Sentry** (optional) — error monitoring; enable via `SENTRY_DSN` env var; `send_default_pii=False`
---
@@ -468,6 +502,7 @@ pdfplumber==0.11.4
| Fetch investment prices | Mon-Fri 4PM | `fetch_prices.py` | yfinance, all tickers |
| Net worth snapshot | 1st of month 00:05 | `daily_snapshot.py` | Saves to net_worth_snapshots |
| AI daily insight | Daily 00:01 | `daily_ai_insight.py` | Skips if already done today |
| Schwab auto-sync | Daily 7AM | `sync_schwab.py` | Balance + positions + transactions; warns if refresh token expires soon |
| DB backup | Daily 2AM | pfm-backup (systemd) | mysqldump → gzip, keep 30 days |
---
@@ -497,6 +532,10 @@ TELLER_WEBHOOK_SECRET=your-webhook-secret
SCHWAB_CLIENT_ID=your-schwab-client-id
SCHWAB_CLIENT_SECRET=your-schwab-client-secret
SCHWAB_REDIRECT_URI=https://pfm.ngodanguyen.tech/schwab/callback
# Security (optional)
SENTRY_DSN= # leave blank to disable Sentry
SESSION_IDLE_MINUTES=60 # session idle timeout in minutes
RATELIMIT_STORAGE_URI=redis://localhost:6379 # use Redis to share rate limits across Gunicorn workers
```
---
@@ -505,7 +544,7 @@ SCHWAB_REDIRECT_URI=https://pfm.ngodanguyen.tech/schwab/callback
| Blueprint | Prefix | Key routes |
|-----------|--------|------------|
| auth | /auth | login, logout |
| auth | /auth | login, logout, totp/verify, totp/setup, totp/disable |
| dashboard | / | index, api/fx-history, api/fx-refresh |
| accounts | /accounts | CRUD, adjust |
| categories | /categories | CRUD |
@@ -515,7 +554,7 @@ SCHWAB_REDIRECT_URI=https://pfm.ngodanguyen.tech/schwab/callback
| investments | /investments | index, new, detail, edit, delete, add_transaction, refresh-prices, sync-schwab, api/price, api/daychange, api/price-history |
| reports | /reports | monthly, quarterly, yearly, tax, export/csv\|excel\|pdf, snapshot |
| ai | /ai | index, stream (SSE), history, generate-insight |
| settings | /settings | index, profile, password, recurring, import, recalc-balances, upload_receipt, delete_receipt, view_receipt |
| settings | /settings | index, profile, password, audit, recurring, import, recalc-balances, upload_receipt, delete_receipt, view_receipt |
| teller | /teller | callback, map, index, sync, sync/confirm, sync/all, balance, balance/all, resync, disconnect, webhook |
| schwab | /schwab | connect, callback, index, map, sync/`<id>`, sync/confirm, resync, snapshot/`<id>`, disconnect |
| bank_import | /bank-import | index, parse (AJAX), import (AJAX) |
@@ -529,9 +568,13 @@ SCHWAB_REDIRECT_URI=https://pfm.ngodanguyen.tech/schwab/callback
- FX rate widget: `open.er-api.com` may return stale values; yfinance is the reliable primary
- WeasyPrint PDF: requires `libpango*` system libs on server
- Bank statement PDF import: scanned/image PDFs have no text layer; must use digital download
- Schwab: `investments.account_id` column requires migration — run `scripts/add_investment_account.py` once after deploy
- Schwab: after first connect, run "Balance & Positions" to populate investments; then re-sync if holdings were already added manually (they will be updated to link to the account)
- Schwab: run `scripts/add_investment_account.py` then `scripts/add_security_columns.py` once after fresh deploy
- Schwab: after first connect, run "Balance & Positions" to populate investments; then re-sync if holdings were already added manually
- Schwab refresh token: Schwab tokens last ~7 days; `refresh_token_expires_at` is reset on every token exchange (including access-only refreshes); dashboard warns at ≤ 2 days
- Teller: `access_token` column is `TEXT` (widened from VARCHAR(128) to fit Fernet-encrypted values); run `scripts/add_security_columns.py` to apply
- Teller: development environment only; requires cert/key from Teller Dashboard
- Rate limiter: defaults to `memory://` per-process if `RATELIMIT_STORAGE_URI` is not set — effective limit is `stated_limit × num_workers`; set `RATELIMIT_STORAGE_URI=redis://localhost:6379` in production
- `EncryptedText` TypeDecorator: key = SHA-256(SECRET_KEY); changing SECRET_KEY invalidates all stored tokens (requires reconnect)
- MySQL does not support `NULLS LAST`; use `func.isnull(column)` for null-last ordering
---
@@ -553,27 +596,37 @@ SCHWAB_REDIRECT_URI=https://pfm.ngodanguyen.tech/schwab/callback
| 2026-06 | Schwab uses `hashValue` (not raw account number) in API paths | schwab.py, schwab_service.py |
| 2026-06 | `next` redirect params validated to start with `/` (no open redirect) | teller.py, schwab.py |
| 2026-06 | Teller income/expense type corrected (positive = income) | teller_service.py |
| 2026-06 | `EncryptedText.process_bind_param` removed silent plaintext fallback — raises on encrypt failure | crypto.py |
| 2026-06 | Rate limiter storage moved to `RATELIMIT_STORAGE_URI` config (was hardcoded `memory://` per-worker) | extensions.py, config.py |
| 2026-06 | TOTP verify: added hourly rate limit (`30/hr`) + per-session attempt counter (locks out after 5 failures) | auth.py |
| 2026-06 | TOTP setup endpoint: added `@limiter.limit('10 per minute')` | auth.py |
| 2026-06 | `refresh_token_expires_at` now reset on every token exchange, not only when Schwab rotates the token | schwab_service.py |
| 2026-06 | Schwab unknown account type fallback changed back to `'other'` (was incorrectly changed to `'investment'`) | schwab.py |
| 2026-06 | `teller_enrollments.access_token` widened VARCHAR(128) → TEXT to fit Fernet-encrypted values | add_security_columns.py |
| 2026-06 | New income transaction submitted as expense — `form.transaction_type.data` not set on GET | transactions.py |
---
## 19. To-Do / Roadmap
### High priority
- [ ] **Mobile responsiveness pass** — sidebar auto-collapses on mobile; tables scroll horizontally
- [ ] **Mobile responsiveness pass** — sidebar auto-collapses on mobile; tables scroll horizontally *(base.html mobile CSS improved; further work needed)*
- [ ] **Budget alerts** — email/Twilio SMS when category spending hits 80% / 100%
### Medium priority
- [ ] **Pagination info** — show "Page X of Y" on AI history and other paginated pages
- [ ] **PDF export memory** — stream CSV/Excel exports for users with large transaction history
- [ ] **Receipt MIME validation** — validate file magic bytes server-side, not just extension
- [ ] **OCR ownership check** — verify re-extracted filename belongs to current user's transaction
- [ ] **Bank import progress** — show per-row import progress for large statement files
- [ ] **Schwab IRA account type** — map Schwab IRA account type to `investment` in ACCOUNT_TYPE_MAP
### Low priority / future
- [ ] iOS companion app
- [ ] Shared household mode (2 users, row-level isolation)
- [ ] Bank statement PDF: table-extraction fallback (pdfplumber tables API) before Groq call
- [ ] Investment price history chart per holding
- [ ] Dark mode toggle
- [ ] Schwab auto-sync on schedule (currently manual only)
### Completed (removed from backlog)
- [x] **Pagination info** — "Page X of Y" added to transactions, AI history, accounts/payments, audit log
- [x] **Schwab IRA account type** — IRA/ROTH_IRA/401K/BROKERAGE types added to ACCOUNT_TYPE_MAP
- [x] **Investment price history chart** — 1W/1M/3M/6M/1Y chart on investment detail page
- [x] **Schwab auto-sync on schedule**`scripts/sync_schwab.py` (cron at 7AM daily)
+19 -2
View File
@@ -49,13 +49,26 @@ def _parse_line(raw):
@logs_bp.route('/')
@login_required
def index():
import datetime
log_file = current_app.config.get('LOG_FILE_PATH', '')
file_exists = bool(log_file) and os.path.isfile(log_file)
file_size = os.path.getsize(log_file) if file_exists else 0
file_mtime = None
if file_exists:
ts = os.path.getmtime(log_file)
file_mtime = datetime.datetime.fromtimestamp(ts).strftime('%b %d, %H:%M')
def fmt_size(b):
if b < 1024: return f'{b} B'
if b < 1024**2: return f'{b/1024:.1f} KB'
return f'{b/1024**2:.1f} MB'
return render_template('logs/index.html',
log_file=log_file,
file_exists=file_exists,
file_size=file_size)
file_size=file_size,
file_size_fmt=fmt_size(file_size),
file_mtime=file_mtime)
@logs_bp.route('/api')
@@ -87,17 +100,21 @@ def api():
# Most-recent first, capped at limit
entries = list(reversed(entries))[:limit]
# Count per level across ALL unfiltered lines (for the stats bar)
# Count per level + unique modules across ALL unfiltered lines
all_entries = [_parse_line(l) for l in raw_lines if l.strip()]
counts = {}
modules_seen = set()
for e in all_entries:
counts[e['level']] = counts.get(e['level'], 0) + 1
if e['name']:
modules_seen.add(e['name'])
return jsonify({
'entries': entries,
'counts': counts,
'total_raw': len(raw_lines),
'log_file': log_file,
'modules': sorted(modules_seen),
})
+1 -7
View File
@@ -303,15 +303,9 @@
>
<i class="bi bi-tags"></i><span class="lt">Categories</span>
</a>
<a
href="{{ url_for('logs.index') }}"
class="sb-link {% if request.blueprint == 'logs' %}active{% endif %}"
>
<i class="bi bi-terminal"></i><span class="lt">System Logs</span>
</a>
<a
href="{{ url_for('settings.index') }}"
class="sb-link {% if request.blueprint == 'settings' %}active{% endif %}"
class="sb-link {% if request.blueprint in ('settings', 'logs') %}active{% endif %}"
>
<i class="bi bi-gear"></i><span class="lt">Settings</span>
</a>
+392 -162
View File
@@ -4,151 +4,254 @@
{% block extra_css %}
<style>
.log-toolbar {
/* ── Layout ─────────────────────────────────────────────────────────────────── */
.log-header {
display: flex; align-items: center; flex-wrap: wrap; gap: 10px;
margin-bottom: 16px;
}
.log-meta {
display: flex; flex-wrap: wrap; gap: 6px; align-items: center;
}
.log-meta-chip {
display: inline-flex; align-items: center; gap: 4px;
font-size: 11px; color: var(--muted); font-family: 'DM Mono', monospace;
background: #f1f5f9; border-radius: 4px; padding: 2px 8px;
}
.log-meta-chip i { font-size: 10px; }
/* ── Level pills / filter bar ────────────────────────────────────────────────── */
.level-pills {
display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px;
}
.lvl-pill {
display: inline-flex; align-items: center; gap: 5px;
padding: 4px 12px; border-radius: 20px; font-size: 12px; font-weight: 600;
cursor: pointer; border: 1.5px solid transparent; transition: all .15s;
user-select: none;
}
.lvl-pill .cnt { font-size: 11px; opacity: .8; }
.lvl-pill:not(.active) { opacity: .6; }
.lvl-pill:hover { opacity: 1; }
.lvl-pill.active { border-color: currentColor; opacity: 1; }
.lp-ALL { background:#f1f5f9; color:#475569; }
.lp-ERROR { background:#fee2e2; color:#991b1b; }
.lp-CRITICAL { background:#fce7f3; color:#9d174d; }
.lp-WARNING { background:#fef9c3; color:#854d0e; }
.lp-INFO { background:#dbeafe; color:#1e40af; }
.lp-DEBUG { background:#f0fdf4; color:#166534; }
/* ── Toolbar ────────────────────────────────────────────────────────────────── */
.log-toolbar {
display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
background: var(--card-bg); border: 1px solid var(--border);
border-radius: 12px; padding: 14px 16px; margin-bottom: 16px;
}
.log-toolbar .sep { flex: 1; }
border-radius: 10px; padding: 10px 14px; margin-bottom: 14px;
}
.toolbar-sep { flex: 1; min-width: 8px; }
.level-badge {
display: inline-block; font-size: 11px; font-weight: 600;
padding: 2px 7px; border-radius: 4px; font-family: 'DM Mono', monospace;
white-space: nowrap;
}
.level-INFO { background: #dbeafe; color: #1e40af; }
.level-WARNING { background: #fef9c3; color: #854d0e; }
.level-ERROR { background: #fee2e2; color: #991b1b; }
.level-CRITICAL { background: #fce7f3; color: #9d174d; }
.level-DEBUG { background: #f0fdf4; color: #166534; }
.level-RAW { background: #f1f5f9; color: #475569; }
/* ── Auto-refresh indicator ──────────────────────────────────────────────────── */
.ar-wrap {
display: flex; align-items: center; gap: 6px;
font-size: 12px; color: var(--muted); cursor: pointer;
padding: 4px 8px; border-radius: 6px; transition: background .15s;
user-select: none;
}
.ar-wrap:hover { background: #f1f5f9; }
.ar-dot {
width: 8px; height: 8px; border-radius: 50%; background: #cbd5e1;
flex-shrink: 0; transition: background .3s;
}
.ar-dot.on { background: #10b981; animation: pulse-dot 2s infinite; }
@keyframes pulse-dot { 0%,100%{opacity:1} 50%{opacity:.35} }
.ar-countdown { font-family:'DM Mono',monospace; font-size:11px; min-width:16px; }
.stat-pill {
display: inline-flex; align-items: center; gap: 5px;
padding: 3px 10px; border-radius: 20px; font-size: 12px;
font-weight: 600; cursor: pointer; border: 1.5px solid transparent;
transition: all .15s;
}
.stat-pill:hover, .stat-pill.active { border-color: currentColor; opacity: 1; }
.stat-pill { opacity: .75; }
.pill-ALL { background: #f1f5f9; color: #475569; }
.pill-INFO { background: #dbeafe; color: #1e40af; }
.pill-WARNING { background: #fef9c3; color: #854d0e; }
.pill-ERROR { background: #fee2e2; color: #991b1b; }
.pill-CRITICAL { background: #fce7f3; color: #9d174d; }
.pill-DEBUG { background: #f0fdf4; color: #166534; }
#log-table-wrap {
/* ── Log table ───────────────────────────────────────────────────────────────── */
#log-wrap {
background: var(--card-bg); border: 1px solid var(--border);
border-radius: 12px; overflow: hidden;
}
#log-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
#log-table thead th {
font-size: 10px; font-weight: 600; text-transform: uppercase;
letter-spacing: .07em; color: var(--muted); padding: 10px 14px;
border-bottom: 1px solid var(--border); white-space: nowrap;
background: #f8fafc;
}
#log-table tbody td {
padding: 7px 14px; border-bottom: 1px solid var(--border);
vertical-align: top; word-break: break-word;
}
#log-table tbody tr:last-child td { border-bottom: none; }
#log-table tbody tr:hover { background: #f8fafc; }
#log-table tbody tr.row-ERROR td { background: #fff5f5; }
#log-table tbody tr.row-CRITICAL td { background: #fdf2f8; }
#log-table tbody tr.row-WARNING td { background: #fffbeb; }
}
#log-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
#log-table thead th {
font-size: 10px; font-weight: 700; text-transform: uppercase;
letter-spacing: .07em; color: var(--muted); padding: 9px 14px;
border-bottom: 1px solid var(--border); background: #f8fafc;
white-space: nowrap;
}
#log-table tbody tr { cursor: pointer; transition: background .1s; }
#log-table tbody tr:hover > td { background: #f1f5f9 !important; }
#log-table tbody td {
padding: 6px 14px; border-bottom: 1px solid #f1f5f9;
vertical-align: top;
}
#log-table tbody tr:last-child > td { border-bottom: none; }
tr.row-ERROR > td { background: #fff8f8; }
tr.row-CRITICAL > td { background: #fdf4fb; }
tr.row-WARNING > td { background: #fffdf0; }
tr.row-RAW > td { background: #fafafa; }
.ts-col { white-space: nowrap; color: var(--muted); font-family: 'DM Mono', monospace; font-size: 11px; width: 148px; }
.name-col { color: var(--muted); font-family: 'DM Mono', monospace; font-size: 11px; width: 220px; }
.msg-col { font-family: 'DM Mono', monospace; }
/* ── Column widths ───────────────────────────────────────────────────────────── */
.col-ts { width: 145px; white-space: nowrap; }
.col-lvl { width: 86px; }
.col-mod { width: 160px; }
.col-msg { }
#empty-state {
text-align: center; padding: 60px 20px; color: var(--muted);
}
.ts-text { font-family:'DM Mono',monospace; font-size:11px; color:var(--muted); }
.mod-text { font-family:'DM Mono',monospace; font-size:11px; color:#64748b;
max-width:150px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.msg-text { font-family:'DM Mono',monospace; font-size:12px; word-break:break-word; }
.msg-text.nowrap { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:580px; }
.auto-refresh-dot {
width: 8px; height: 8px; border-radius: 50%;
background: #94a3b8; display: inline-block;
transition: background .3s;
}
.auto-refresh-dot.on { background: #10b981; animation: pulse-dot 2s infinite; }
@keyframes pulse-dot {
0%, 100% { opacity: 1; } 50% { opacity: .4; }
}
/* ── Level badge ─────────────────────────────────────────────────────────────── */
.lvl-badge {
display: inline-block; font-size: 10px; font-weight: 700;
padding: 1px 6px; border-radius: 4px; font-family:'DM Mono',monospace;
letter-spacing: .03em; white-space: nowrap;
}
.lb-INFO { background:#dbeafe; color:#1e40af; }
.lb-WARNING { background:#fef9c3; color:#854d0e; }
.lb-ERROR { background:#fee2e2; color:#991b1b; }
.lb-CRITICAL { background:#fce7f3; color:#9d174d; }
.lb-DEBUG { background:#f0fdf4; color:#166534; }
.lb-RAW { background:#f1f5f9; color:#64748b; }
#spinner { display: none; }
#spinner.on { display: inline-block; }
/* ── Expanded row ────────────────────────────────────────────────────────────── */
tr.expanded-row > td {
background: #0f172a !important; padding: 0;
}
.expand-panel {
padding: 12px 16px; font-family:'DM Mono',monospace; font-size: 12px;
color: #e2e8f0; white-space: pre-wrap; word-break: break-all;
line-height: 1.6;
}
.expand-panel .ep-label {
font-size: 10px; font-weight: 700; text-transform: uppercase;
letter-spacing: .08em; color: #64748b; display: block; margin-bottom: 4px;
}
.expand-panel .ep-val { color: #e2e8f0; }
.expand-panel .ep-mod { color: #7dd3fc; }
.expand-panel .ep-ts { color: #a3e635; }
.expand-panel .ep-err { color: #fca5a5; }
.copy-btn {
font-size:11px; padding:2px 8px; border-radius:4px;
background:#1e293b; color:#94a3b8; border:1px solid #334155;
cursor:pointer; transition:all .15s; margin-top:8px;
}
.copy-btn:hover { background:#334155; color:#e2e8f0; }
/* ── Empty / loading states ──────────────────────────────────────────────────── */
.log-empty { text-align:center; padding:56px 20px; color:var(--muted); }
.log-empty i { font-size:2.5rem; opacity:.3; display:block; margin-bottom:10px; }
/* ── Spinner ─────────────────────────────────────────────────────────────────── */
#spinner { display:none; }
#spinner.on { display:inline-block; }
/* ── Wrap toggle ─────────────────────────────────────────────────────────────── */
.wrap-btn.active { background:#e0f2fe; color:#0369a1; border-color:#7dd3fc; }
/* ── Footer bar ──────────────────────────────────────────────────────────────── */
.log-footer {
display:flex; justify-content:space-between; align-items:center;
font-size:11px; color:var(--muted); padding:6px 2px; margin-top:8px;
}
</style>
{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('logs.download') }}" class="btn btn-sm btn-outline-secondary" title="Download log file">
<i class="bi bi-download"></i>
</a>
{% endblock %}
{% block content %}
<div class="d-flex align-items-center gap-2 mb-3">
<h5 class="mb-0 fw-semibold">Application Logs</h5>
<span id="spinner" class="spinner-border spinner-border-sm text-secondary ms-1"></span>
<span class="small text-muted ms-auto mono" id="log-file-path">
{% if file_exists %}{{ log_file }}{% else %}Log file not found{% endif %}
<!-- Header -->
<div class="log-header">
<div>
<a href="{{ url_for('settings.index') }}" class="btn btn-sm btn-outline-secondary me-2" style="font-size:12px;">
<i class="bi bi-arrow-left me-1"></i>Settings
</a>
</div>
<div class="log-meta">
{% if file_exists %}
<span class="log-meta-chip"><i class="bi bi-hdd"></i>{{ file_size_fmt }}</span>
<span class="log-meta-chip"><i class="bi bi-clock"></i>{{ file_mtime }}</span>
<span class="log-meta-chip" style="max-width:360px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="{{ log_file }}">
<i class="bi bi-file-text"></i>{{ log_file }}
</span>
{% else %}
<span class="log-meta-chip" style="color:#ef4444;"><i class="bi bi-exclamation-triangle"></i>Log file not found</span>
{% endif %}
</div>
<div class="ms-auto">
<span id="spinner" class="spinner-border spinner-border-sm text-secondary"></span>
</div>
</div>
<!-- Stats bar -->
<div class="d-flex flex-wrap gap-2 mb-3" id="stats-bar">
<span class="stat-pill pill-ALL active" data-level="ALL">All <span class="ms-1" id="cnt-ALL"></span></span>
<span class="stat-pill pill-ERROR" data-level="ERROR">Error <span class="ms-1" id="cnt-ERROR">0</span></span>
<span class="stat-pill pill-WARNING" data-level="WARNING">Warning <span class="ms-1" id="cnt-WARNING">0</span></span>
<span class="stat-pill pill-INFO" data-level="INFO">Info <span class="ms-1" id="cnt-INFO">0</span></span>
<span class="stat-pill pill-DEBUG" data-level="DEBUG">Debug <span class="ms-1" id="cnt-DEBUG">0</span></span>
<!-- Level filter pills -->
<div class="level-pills" id="level-pills">
<span class="lvl-pill lp-ALL active" data-level="ALL">All <span class="cnt" id="cnt-ALL"></span></span>
<span class="lvl-pill lp-ERROR" data-level="ERROR">Error <span class="cnt" id="cnt-ERROR">0</span></span>
<span class="lvl-pill lp-CRITICAL" data-level="CRITICAL">Critical <span class="cnt" id="cnt-CRITICAL">0</span></span>
<span class="lvl-pill lp-WARNING" data-level="WARNING">Warning <span class="cnt" id="cnt-WARNING">0</span></span>
<span class="lvl-pill lp-INFO" data-level="INFO">Info <span class="cnt" id="cnt-INFO">0</span></span>
<span class="lvl-pill lp-DEBUG" data-level="DEBUG">Debug <span class="cnt" id="cnt-DEBUG">0</span></span>
</div>
<!-- Toolbar -->
<div class="log-toolbar">
<div class="input-group input-group-sm" style="max-width:240px;">
<span class="input-group-text" style="background:#f8fafc;"><i class="bi bi-search" style="font-size:11px;"></i></span>
<input id="search-input" type="text" class="form-control form-control-sm"
placeholder="Search message or module…" style="max-width:240px;">
<input id="module-input" type="text" class="form-control form-control-sm"
placeholder="Module filter (e.g. teller)" style="max-width:180px;">
placeholder="Search message…" autocomplete="off">
</div>
<select id="module-select" class="form-select form-select-sm" style="max-width:180px;">
<option value="">All modules</option>
</select>
<select id="limit-select" class="form-select form-select-sm" style="max-width:110px;">
<option value="100">Last 100</option>
<option value="200" selected>Last 200</option>
<option value="500">Last 500</option>
<option value="1000">Last 1000</option>
<option value="1000">Last 1 000</option>
<option value="2000">Last 2 000</option>
</select>
<div class="sep"></div>
<label class="d-flex align-items-center gap-2 small text-muted mb-0" style="cursor:pointer;">
<span class="auto-refresh-dot" id="ar-dot"></span>
<input type="checkbox" id="auto-refresh" class="d-none"> Auto-refresh
</label>
<button class="btn btn-sm btn-outline-secondary wrap-btn" id="wrap-btn" title="Toggle message wrapping">
<i class="bi bi-text-wrap"></i>
</button>
<div class="toolbar-sep"></div>
<div class="ar-wrap" id="ar-toggle" title="Toggle auto-refresh every 5 seconds">
<span class="ar-dot" id="ar-dot"></span>
<span>Auto-refresh</span>
<span class="ar-countdown" id="ar-countdown"></span>
</div>
<a href="{{ url_for('logs.download') }}" class="btn btn-sm btn-outline-secondary" title="Download full log file">
<i class="bi bi-download me-1"></i><span class="d-none d-sm-inline">Download</span>
</a>
<button class="btn btn-sm btn-outline-danger" id="clear-btn" title="Clear log file">
<i class="bi bi-trash"></i> Clear
<i class="bi bi-trash me-1"></i><span class="d-none d-sm-inline">Clear</span>
</button>
</div>
<!-- Log table -->
<div id="log-table-wrap">
<div id="log-wrap">
<table id="log-table">
<thead>
<tr>
<th class="ts-col">Timestamp</th>
<th style="width:90px">Level</th>
<th class="name-col">Module</th>
<th>Message</th>
<th class="col-ts">Timestamp</th>
<th class="col-lvl">Level</th>
<th class="col-mod d-mob-none">Module</th>
<th class="col-msg">Message</th>
</tr>
</thead>
<tbody id="log-body">
<tr><td colspan="4" id="empty-state">
<i class="bi bi-hourglass-split fs-3 d-block mb-2 opacity-25"></i>
Loading…
</td></tr>
<tr><td colspan="4"><div class="log-empty">
<i class="bi bi-hourglass-split"></i>Loading…
</div></td></tr>
</tbody>
</table>
</div>
<div class="d-flex justify-content-between align-items-center mt-2 small text-muted px-1">
<!-- Footer -->
<div class="log-footer">
<span id="result-count"></span>
<span id="last-refresh"></span>
</div>
@@ -157,103 +260,217 @@
{% block extra_js %}
<script>
(function () {
const CSRF = document.querySelector('meta[name="csrf-token"]')?.content || '';
let activeLevel = 'ALL';
let arTimer = null;
'use strict';
const CSRF = document.querySelector('meta[name="csrf-token"]')?.content || '';
// ── fetch & render ────────────────────────────────────────────────────────
function load() {
let activeLevel = 'ALL';
let wrapMessages = false;
let arEnabled = false;
let arTimer = null;
let arCountdown = 0;
let arTick = null;
let allModules = [];
let expandedRows = new Set(); // track which row indices are expanded
// ── Fetch & render ──────────────────────────────────────────────────────────
function load() {
const search = document.getElementById('search-input').value.trim();
const module = document.getElementById('module-input').value.trim();
const module = document.getElementById('module-select').value;
const limit = document.getElementById('limit-select').value;
const spinner = document.getElementById('spinner');
spinner.classList.add('on');
document.getElementById('spinner').classList.add('on');
const params = new URLSearchParams({ level: activeLevel, search, module, limit });
fetch(`{{ url_for('logs.api') }}?${params}`)
.then(r => r.json())
.then(data => {
spinner.classList.remove('on');
document.getElementById('spinner').classList.remove('on');
expandedRows.clear();
renderTable(data.entries || []);
renderCounts(data.counts || {});
populateModules(data.modules || []);
document.getElementById('last-refresh').textContent =
'Updated ' + new Date().toLocaleTimeString();
'Refreshed ' + new Date().toLocaleTimeString();
const shown = (data.entries || []).length;
const total = data.total_raw || 0;
document.getElementById('result-count').textContent =
`Showing ${(data.entries||[]).length} of ${data.total_raw || 0} lines`;
shown + ' entr' + (shown === 1 ? 'y' : 'ies') +
(total ? ' · ' + total + ' lines in file' : '');
})
.catch(() => spinner.classList.remove('on'));
}
.catch(() => document.getElementById('spinner').classList.remove('on'));
}
function renderTable(entries) {
// ── Table rendering ─────────────────────────────────────────────────────────
function esc(t) {
return (t || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function lvlBadge(lvl) {
const cls = { INFO:'lb-INFO', WARNING:'lb-WARNING', ERROR:'lb-ERROR',
CRITICAL:'lb-CRITICAL', DEBUG:'lb-DEBUG', RAW:'lb-RAW' }[lvl] || 'lb-RAW';
return `<span class="lvl-badge ${cls}">${esc(lvl)}</span>`;
}
function shortMod(name) {
// Show last two segments: app.services.teller_service → services.teller_service
const parts = (name || '').split('.');
return parts.length > 2 ? parts.slice(-2).join('.') : name;
}
function renderTable(entries) {
const tbody = document.getElementById('log-body');
if (!entries.length) {
tbody.innerHTML = `<tr><td colspan="4" id="empty-state">
<i class="bi bi-inbox fs-3 d-block mb-2 opacity-25"></i>
No log entries match your filters.
</td></tr>`;
tbody.innerHTML = `<tr><td colspan="4"><div class="log-empty">
<i class="bi bi-inbox"></i>No entries match your filters.
</div></td></tr>`;
return;
}
const rows = entries.map(e => {
const wrapCls = wrapMessages ? '' : ' nowrap';
const rows = entries.map((e, i) => {
const lvl = e.level || 'RAW';
const safe = t => (t||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
return `<tr class="row-${lvl}">
<td class="ts-col">${safe(e.ts)}</td>
<td><span class="level-badge level-${lvl}">${lvl}</span></td>
<td class="name-col" title="${safe(e.name)}">${safe(e.name.split('.').pop())}</td>
<td class="msg-col">${safe(e.message)}</td>
return `<tr class="row-${esc(lvl)}" data-idx="${i}" data-ts="${esc(e.ts)}" data-lvl="${esc(lvl)}" data-name="${esc(e.name)}" data-msg="${esc(e.message).replace(/"/g,'&quot;')}">
<td class="col-ts"><span class="ts-text">${esc(e.ts)}</span></td>
<td class="col-lvl">${lvlBadge(lvl)}</td>
<td class="col-mod d-mob-none"><span class="mod-text" title="${esc(e.name)}">${esc(shortMod(e.name))}</span></td>
<td class="col-msg"><span class="msg-text${wrapCls}">${esc(e.message)}</span></td>
</tr>`;
});
tbody.innerHTML = rows.join('');
}
// ── Expand row on click ─────────────────────────────────────────────────────
document.getElementById('log-body').addEventListener('click', function(e) {
const row = e.target.closest('tr[data-idx]');
if (!row) return;
// If clicking copy button inside an expanded panel, let it work
if (e.target.closest('.copy-btn')) return;
const idx = row.dataset.idx;
const next = row.nextElementSibling;
// Collapse if already expanded
if (next && next.classList.contains('expanded-row')) {
next.remove();
row.classList.remove('_expanded');
return;
}
function renderCounts(counts) {
const total = Object.values(counts).reduce((s,v)=>s+v, 0);
document.getElementById('cnt-ALL').textContent = total;
// Collapse any other open row first
document.querySelectorAll('.expanded-row').forEach(r => r.remove());
document.querySelectorAll('._expanded').forEach(r => r.classList.remove('_expanded'));
// Build expanded panel
const ts = row.dataset.ts;
const lvl = row.dataset.lvl;
const name = row.dataset.name;
const msg = row.dataset.msg.replace(/&quot;/g, '"');
const raw = `${ts}|${lvl}|${name}|${msg}`;
const panel = document.createElement('tr');
panel.className = 'expanded-row';
panel.innerHTML = `<td colspan="4">
<div class="expand-panel">
<span class="ep-label">Timestamp</span><span class="ep-ts ep-val">${esc(ts)}</span>
<span class="ep-label" style="margin-top:8px;">Module</span><span class="ep-mod ep-val">${esc(name) || '—'}</span>
<span class="ep-label" style="margin-top:8px;">Message</span><span class="${lvl === 'ERROR' || lvl === 'CRITICAL' ? 'ep-err' : ''} ep-val">${esc(msg)}</span>
<button class="copy-btn" onclick="copyRaw(this, ${JSON.stringify(raw)})">
<i class="bi bi-clipboard me-1"></i>Copy raw line
</button>
</div>
</td>`;
row.after(panel);
row.classList.add('_expanded');
});
window.copyRaw = function(btn, text) {
navigator.clipboard.writeText(text).then(() => {
btn.innerHTML = '<i class="bi bi-check2 me-1"></i>Copied!';
setTimeout(() => { btn.innerHTML = '<i class="bi bi-clipboard me-1"></i>Copy raw line'; }, 1500);
});
};
// ── Counts & module dropdown ────────────────────────────────────────────────
function renderCounts(counts) {
const total = Object.values(counts).reduce((s, v) => s + v, 0);
document.getElementById('cnt-ALL').textContent = total || 0;
document.getElementById('cnt-ERROR').textContent = counts['ERROR'] || 0;
document.getElementById('cnt-CRITICAL').textContent = counts['CRITICAL'] || 0;
document.getElementById('cnt-WARNING').textContent = counts['WARNING'] || 0;
document.getElementById('cnt-INFO').textContent = counts['INFO'] || 0;
document.getElementById('cnt-DEBUG').textContent = counts['DEBUG'] || 0;
}
}
// ── level pills ───────────────────────────────────────────────────────────
document.getElementById('stats-bar').addEventListener('click', function (e) {
const pill = e.target.closest('.stat-pill');
function populateModules(modules) {
if (JSON.stringify(modules) === JSON.stringify(allModules)) return;
allModules = modules;
const sel = document.getElementById('module-select');
const cur = sel.value;
sel.innerHTML = '<option value="">All modules</option>' +
modules.map(m => `<option value="${esc(m)}"${m === cur ? ' selected' : ''}>${esc(m)}</option>`).join('');
}
// ── Level pill clicks ───────────────────────────────────────────────────────
document.getElementById('level-pills').addEventListener('click', function(e) {
const pill = e.target.closest('.lvl-pill');
if (!pill) return;
document.querySelectorAll('.stat-pill').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.lvl-pill').forEach(p => p.classList.remove('active'));
pill.classList.add('active');
activeLevel = pill.dataset.level;
load();
});
});
// ── search / filter inputs ────────────────────────────────────────────────
let debounce;
['search-input','module-input','limit-select'].forEach(id => {
document.getElementById(id).addEventListener('input', function () {
// ── Search / filter / limit ─────────────────────────────────────────────────
let debounce;
['search-input', 'module-select', 'limit-select'].forEach(id => {
const el = document.getElementById(id);
const ev = id === 'limit-select' || id === 'module-select' ? 'change' : 'input';
el.addEventListener(ev, () => {
clearTimeout(debounce);
debounce = setTimeout(load, 300);
debounce = setTimeout(load, 280);
});
});
// ── Wrap toggle ─────────────────────────────────────────────────────────────
document.getElementById('wrap-btn').addEventListener('click', function() {
wrapMessages = !wrapMessages;
this.classList.toggle('active', wrapMessages);
document.querySelectorAll('.msg-text').forEach(el => {
el.classList.toggle('nowrap', !wrapMessages);
});
});
// ── auto-refresh ──────────────────────────────────────────────────────────
const arCheckbox = document.getElementById('auto-refresh');
const arDot = document.getElementById('ar-dot');
document.querySelector('label[for="auto-refresh"]') ||
document.querySelector('label').addEventListener; // noop
// ── Auto-refresh ────────────────────────────────────────────────────────────
const AR_INTERVAL = 5;
// toggle by clicking the label area
document.querySelector('.auto-refresh-dot').parentElement.addEventListener('click', function () {
arCheckbox.checked = !arCheckbox.checked;
arDot.classList.toggle('on', arCheckbox.checked);
if (arCheckbox.checked) {
arTimer = setInterval(load, 5000);
} else {
clearInterval(arTimer);
function startAR() {
arEnabled = true;
document.getElementById('ar-dot').classList.add('on');
arCountdown = AR_INTERVAL;
document.getElementById('ar-countdown').textContent = arCountdown + 's';
arTick = setInterval(() => {
arCountdown--;
if (arCountdown <= 0) {
arCountdown = AR_INTERVAL;
load();
}
});
document.getElementById('ar-countdown').textContent = arCountdown + 's';
}, 1000);
}
// ── clear ─────────────────────────────────────────────────────────────────
document.getElementById('clear-btn').addEventListener('click', function () {
if (!confirm('Clear all log entries from the file? This cannot be undone.')) return;
function stopAR() {
arEnabled = false;
clearInterval(arTick);
document.getElementById('ar-dot').classList.remove('on');
document.getElementById('ar-countdown').textContent = '';
}
document.getElementById('ar-toggle').addEventListener('click', () => {
arEnabled ? stopAR() : startAR();
});
// ── Clear ───────────────────────────────────────────────────────────────────
document.getElementById('clear-btn').addEventListener('click', function() {
if (!confirm('Clear all log entries from the file?\nThis cannot be undone.')) return;
fetch('{{ url_for("logs.clear") }}', {
method: 'POST',
headers: { 'X-CSRFToken': CSRF, 'Content-Type': 'application/json' },
@@ -263,10 +480,23 @@
if (d.status === 'ok') load();
else alert('Clear failed: ' + d.error);
});
});
});
// ── init ──────────────────────────────────────────────────────────────────
load();
// ── Keyboard shortcuts ──────────────────────────────────────────────────────
document.addEventListener('keydown', function(e) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
if (e.key === 'r' || e.key === 'R') load();
if (e.key === 'a' || e.key === 'A') {
arEnabled ? stopAR() : startAR();
}
if (e.key === 'Escape') {
document.querySelectorAll('.expanded-row').forEach(r => r.remove());
document.querySelectorAll('._expanded').forEach(r => r.classList.remove('_expanded'));
}
});
// ── Init ────────────────────────────────────────────────────────────────────
load();
})();
</script>
{% endblock %}
+10 -1
View File
@@ -61,7 +61,7 @@
</div>
</div>
<!-- Audit Log nav card -->
<!-- Audit Log + System Logs nav cards -->
<div class="row g-3 mt-0">
<div class="col-12 col-sm-6 col-lg-3">
<a href="{{ url_for('settings.audit_log') }}" class="text-decoration-none">
@@ -72,6 +72,15 @@
</div>
</a>
</div>
<div class="col-12 col-sm-6 col-lg-3">
<a href="{{ url_for('logs.index') }}" class="text-decoration-none">
<div class="pcard text-center py-4" style="transition:all .15s;" onmouseover="this.style.borderColor='#0f172a'" onmouseout="this.style.borderColor='var(--border)'">
<i class="bi bi-terminal" style="font-size:2rem;color:#0f172a;"></i>
<div style="font-size:14px;font-weight:600;margin-top:10px;">System Logs</div>
<div style="font-size:12px;color:var(--muted);margin-top:4px;">Application log viewer</div>
</div>
</a>
</div>
</div>
<!-- Two-Factor Authentication -->