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 (
<>
+ Panel "Apply to UFW" runs ufw only. If you use firewalld, sync rules there separately.
+
ufw allow/deny for each rule.