diff --git a/YakPanel-server/backend/app/api/__pycache__/dashboard.cpython-314.pyc b/YakPanel-server/backend/app/api/__pycache__/dashboard.cpython-314.pyc index b2764e05..b6b030e4 100644 Binary files a/YakPanel-server/backend/app/api/__pycache__/dashboard.cpython-314.pyc and b/YakPanel-server/backend/app/api/__pycache__/dashboard.cpython-314.pyc differ diff --git a/YakPanel-server/backend/app/api/__pycache__/firewall.cpython-314.pyc b/YakPanel-server/backend/app/api/__pycache__/firewall.cpython-314.pyc index f03629d7..292c2d3f 100644 Binary files a/YakPanel-server/backend/app/api/__pycache__/firewall.cpython-314.pyc and b/YakPanel-server/backend/app/api/__pycache__/firewall.cpython-314.pyc differ diff --git a/YakPanel-server/backend/app/api/dashboard.py b/YakPanel-server/backend/app/api/dashboard.py index 9b9cea72..33675569 100644 --- a/YakPanel-server/backend/app/api/dashboard.py +++ b/YakPanel-server/backend/app/api/dashboard.py @@ -1,4 +1,6 @@ """YakPanel - Dashboard API""" +import os + from fastapi import APIRouter, Depends from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func @@ -10,6 +12,24 @@ from app.models.user import User router = APIRouter(prefix="/dashboard", tags=["dashboard"]) +def _root_inode_usage() -> dict: + """Inode use on filesystem root (Linux). Returns percent or null if unavailable.""" + try: + sv = os.statvfs("/") + except (AttributeError, OSError): + return {"percent": None, "used": None, "total": None} + total = int(sv.f_files) + free = int(sv.f_ffree) + if total <= 0: + return {"percent": None, "used": None, "total": None} + used = max(0, total - free) + return { + "percent": round(100.0 * used / total, 1), + "used": used, + "total": total, + } + + @router.get("/stats") async def get_stats( current_user: User = Depends(get_current_user), @@ -18,10 +38,10 @@ async def get_stats( """Get dashboard statistics""" import psutil - from app.services.site_service import get_site_count + from app.services.site_service import get_site_count, ssl_alert_summary from app.models.ftp import Ftp from app.models.database import Database - from sqlalchemy import select, func + site_count = await get_site_count(db) ftp_result = await db.execute(select(func.count()).select_from(Ftp)) ftp_count = ftp_result.scalar() or 0 @@ -32,11 +52,14 @@ async def get_stats( cpu_percent = psutil.cpu_percent(interval=1) memory = psutil.virtual_memory() disk = psutil.disk_usage("/") + inodes = _root_inode_usage() + ssl_alerts = await ssl_alert_summary(db) return { "site_count": site_count, "ftp_count": ftp_count, "database_count": database_count, + "ssl_alerts": ssl_alerts, "system": { "cpu_percent": cpu_percent, "memory_percent": memory.percent, @@ -45,5 +68,8 @@ async def get_stats( "disk_percent": disk.percent, "disk_used_gb": round(disk.used / 1024 / 1024 / 1024, 2), "disk_total_gb": round(disk.total / 1024 / 1024 / 1024, 2), + "inode_percent": inodes["percent"], + "inode_used": inodes["used"], + "inode_total": inodes["total"], }, } diff --git a/YakPanel-server/backend/app/api/firewall.py b/YakPanel-server/backend/app/api/firewall.py index 36135669..2e6628f7 100644 --- a/YakPanel-server/backend/app/api/firewall.py +++ b/YakPanel-server/backend/app/api/firewall.py @@ -20,6 +20,38 @@ class CreateFirewallRuleRequest(BaseModel): ps: str = "" +@router.get("/status") +async def firewall_backend_status(current_user: User = Depends(get_current_user)): + """UFW and firewalld presence/state for the Security UI (read-only).""" + ufw_out, _ = exec_shell_sync("ufw status 2>/dev/null", timeout=5) + ufw_text = (ufw_out or "").strip() + ufw_detected = bool(ufw_text) and "Status:" in ufw_text + ufw_active: bool | None = None + if ufw_detected: + if "Status: active" in ufw_text: + ufw_active = True + elif "Status: inactive" in ufw_text: + ufw_active = False + + fw_state_out, _ = exec_shell_sync("firewall-cmd --state 2>/dev/null", timeout=5) + fw_line = (fw_state_out or "").strip().lower() + firewalld_running = fw_line == "running" + firewalld_detected = fw_line in ("running", "not running") + + return { + "ufw": { + "detected": ufw_detected, + "active": ufw_active, + "summary_line": ufw_text.split("\n")[0] if ufw_text else "", + }, + "firewalld": { + "detected": firewalld_detected, + "running": firewalld_running, + "state": fw_line or None, + }, + } + + @router.get("/list") async def firewall_list( current_user: User = Depends(get_current_user), diff --git a/YakPanel-server/backend/app/services/__pycache__/site_service.cpython-314.pyc b/YakPanel-server/backend/app/services/__pycache__/site_service.cpython-314.pyc index 95af3f2f..67f5c97b 100644 Binary files a/YakPanel-server/backend/app/services/__pycache__/site_service.cpython-314.pyc and b/YakPanel-server/backend/app/services/__pycache__/site_service.cpython-314.pyc differ diff --git a/YakPanel-server/backend/app/services/site_service.py b/YakPanel-server/backend/app/services/site_service.py index 11ca7936..b06d23ea 100644 --- a/YakPanel-server/backend/app/services/site_service.py +++ b/YakPanel-server/backend/app/services/site_service.py @@ -626,6 +626,34 @@ async def get_site_count(db: AsyncSession) -> int: return result.scalar() or 0 +async def ssl_alert_summary(db: AsyncSession) -> dict: + """Sites with LE certs expiring soon or expired (for dashboard banners).""" + result = await db.execute(select(Site).order_by(Site.id)) + sites = result.scalars().all() + expired: list[dict] = [] + expiring: list[dict] = [] + for s in sites: + domain_result = await db.execute(select(Domain).where(Domain.pid == s.id).order_by(Domain.id)) + domain_rows = domain_result.scalars().all() + hostnames = [d.name for d in domain_rows] + if not hostnames: + continue + ssl = _best_ssl_for_hostnames(hostnames) + if ssl["status"] == "expired": + expired.append({ + "site": s.name, + "primary": hostnames[0], + "days_left": ssl.get("days_left"), + }) + elif ssl["status"] == "expiring": + expiring.append({ + "site": s.name, + "primary": hostnames[0], + "days_left": ssl.get("days_left"), + }) + return {"expired": expired, "expiring": expiring} + + async def get_site_with_domains(db: AsyncSession, site_id: int) -> dict | None: """Get site with domain list for editing.""" result = await db.execute(select(Site).where(Site.id == site_id)) diff --git a/YakPanel-server/docs/FEATURE-PARITY.md b/YakPanel-server/docs/FEATURE-PARITY.md index d4cc4d05..0d3311f8 100644 --- a/YakPanel-server/docs/FEATURE-PARITY.md +++ b/YakPanel-server/docs/FEATURE-PARITY.md @@ -17,7 +17,9 @@ Internal checklist against common hosting-panel capabilities used as a product r | FTP logs (tail) | Done | `GET /ftp/logs` | | Cron job templates (YakPanel JSON) | Done | `GET /crontab/templates`, `data/cron_templates.json` | | Backup plans + optional S3 upload | Done | `backup.py`, `BackupPlan` S3 fields, `boto3` optional | -| Database / FTP / firewall / monitor | Partial (pre-existing) | respective `api/*.py` | +| Dashboard SSL expiry / inode alerts | Done | `GET /dashboard/stats` โ†’ `ssl_alerts`, `system.inode_*` | +| Firewall UFW + firewalld status in UI | Done | `GET /firewall/status`, `FirewallPage.tsx` | +| Database / FTP / firewall rules engine | Partial (pre-existing) | respective `api/*.py` | | Mail server | Not planned | โ€” | | WordPress one-click | Not planned | plugin later | diff --git a/YakPanel-server/frontend/src/api/client.ts b/YakPanel-server/frontend/src/api/client.ts index 08ca6e83..2f43f5b3 100644 --- a/YakPanel-server/frontend/src/api/client.ts +++ b/YakPanel-server/frontend/src/api/client.ts @@ -523,11 +523,14 @@ export async function updateDatabasePassword(dbId: number, password: string) { }) } +export type SslAlertSite = { site: string; primary: string; days_left: number | null | undefined } + export async function getDashboardStats() { return apiRequest<{ site_count: number ftp_count: number database_count: number + ssl_alerts: { expired: SslAlertSite[]; expiring: SslAlertSite[] } system: { cpu_percent: number memory_percent: number @@ -536,10 +539,20 @@ export async function getDashboardStats() { disk_percent: number disk_used_gb: number disk_total_gb: number + inode_percent: number | null + inode_used: number | null + inode_total: number | null } }>('/dashboard/stats') } +export async function getFirewallBackendStatus() { + return apiRequest<{ + ufw: { detected: boolean; active: boolean | null; summary_line: string } + firewalld: { detected: boolean; running: boolean; state: string | null } + }>('/firewall/status') +} + export async function applyCrontab() { return apiRequest<{ status: boolean; msg: string; count: number }>('/crontab/apply', { method: 'POST' }) } diff --git a/YakPanel-server/frontend/src/pages/DashboardPage.tsx b/YakPanel-server/frontend/src/pages/DashboardPage.tsx index 5cb066ec..51ef8db8 100644 --- a/YakPanel-server/frontend/src/pages/DashboardPage.tsx +++ b/YakPanel-server/frontend/src/pages/DashboardPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' import { getDashboardStats } from '../api/client' import { PageHeader, AdminCard, AdminAlert } from '../components/admin' @@ -6,6 +7,7 @@ interface Stats { site_count: number ftp_count: number database_count: number + ssl_alerts?: { expired: { site: string; primary: string; days_left?: number | null }[]; expiring: { site: string; primary: string; days_left?: number | null }[] } system: { cpu_percent: number memory_percent: number @@ -14,6 +16,9 @@ interface Stats { disk_percent: number disk_used_gb: number disk_total_gb: number + inode_percent?: number | null + inode_used?: number | null + inode_total?: number | null } } @@ -47,9 +52,39 @@ export function DashboardPage() { ) } + const ssl = stats.ssl_alerts || { expired: [], expiring: [] } + const inodePct = stats.system.inode_percent + const inodeWarn = typeof inodePct === 'number' && inodePct >= 85 + return ( <> + + {ssl.expired.length > 0 ? ( + + Expired certificates:{' '} + {ssl.expired.map((s) => `${s.site} (${s.primary})`).join('; ')}.{' '} + + Renew in Domains & SSL + + + ) : null} + {ssl.expiring.length > 0 ? ( + + Certificates expiring within 14 days:{' '} + {ssl.expiring.map((s) => `${s.site} (${s.days_left ?? '?'}d)`).join('; ')}.{' '} + + Open Domains & SSL + + + ) : null} + {inodeWarn ? ( + + Root filesystem inode usage is high ({inodePct}%). Large numbers of small files can exhaust inodes before + disk space โ€” consider cleanup or archiving. + + ) : null} +
@@ -78,7 +113,11 @@ export function DashboardPage() { iconClass="ti ti-database-export" title="Disk" value={`${stats.system.disk_percent}%`} - subtitle={`${stats.system.disk_used_gb} / ${stats.system.disk_total_gb} GB`} + subtitle={ + typeof stats.system.inode_percent === 'number' + ? `${stats.system.disk_used_gb} / ${stats.system.disk_total_gb} GB ยท inodes ${stats.system.inode_percent}%` + : `${stats.system.disk_used_gb} / ${stats.system.disk_total_gb} GB` + } />
diff --git a/YakPanel-server/frontend/src/pages/FirewallPage.tsx b/YakPanel-server/frontend/src/pages/FirewallPage.tsx index 76f2a61a..10966469 100644 --- a/YakPanel-server/frontend/src/pages/FirewallPage.tsx +++ b/YakPanel-server/frontend/src/pages/FirewallPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import Modal from 'react-bootstrap/Modal' -import { apiRequest, applyFirewallRules } from '../api/client' +import { apiRequest, applyFirewallRules, getFirewallBackendStatus } from '../api/client' import { PageHeader, AdminButton, AdminAlert, AdminTable, EmptyState } from '../components/admin' interface FirewallRule { @@ -11,6 +11,11 @@ interface FirewallRule { ps: string } +interface FirewallBackendStatus { + ufw: { detected: boolean; active: boolean | null; summary_line: string } + firewalld: { detected: boolean; running: boolean; state: string | null } +} + export function FirewallPage() { const [rules, setRules] = useState([]) const [loading, setLoading] = useState(true) @@ -19,11 +24,18 @@ export function FirewallPage() { const [creating, setCreating] = useState(false) const [creatingError, setCreatingError] = useState('') const [applying, setApplying] = useState(false) + const [backend, setBackend] = useState(null) const loadRules = () => { setLoading(true) - apiRequest('/firewall/list') - .then(setRules) + Promise.all([ + apiRequest('/firewall/list'), + getFirewallBackendStatus().catch(() => null), + ]) + .then(([list, st]) => { + setRules(list) + setBackend(st) + }) .catch((err) => setError(err.message)) .finally(() => setLoading(false)) } @@ -107,6 +119,44 @@ export function FirewallPage() { {error ? {error} : null} + {backend ? ( +
+
+
Host firewall (live)
+
+
+ UFW + {!backend.ufw.detected ? ( + Not detected + ) : backend.ufw.active === true ? ( + Active + ) : backend.ufw.active === false ? ( + Inactive + ) : ( + Unknown + )} + {backend.ufw.summary_line ? ( + {backend.ufw.summary_line} + ) : null} +
+
+ firewalld + {!backend.firewalld.detected ? ( + Not detected + ) : backend.firewalld.running ? ( + Running + ) : ( + Not running + )} +
+
+

+ Panel "Apply to UFW" runs ufw only. If you use firewalld, sync rules there separately. +

+
+
+ ) : null} +
Rules are stored in the panel. Click "Apply to UFW" to run ufw allow/deny for each rule.