new changes

This commit is contained in:
Niranjan
2026-04-07 13:30:25 +05:30
parent 6dea3b4307
commit 7b394d6446
10 changed files with 197 additions and 7 deletions

View File

@@ -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"],
},
}

View File

@@ -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),

View File

@@ -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))

View File

@@ -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 |

View File

@@ -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' })
}

View File

@@ -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 (
<>
<PageHeader />
{ssl.expired.length > 0 ? (
<AdminAlert variant="danger" className="mb-3">
<strong>Expired certificates:</strong>{' '}
{ssl.expired.map((s) => `${s.site} (${s.primary})`).join('; ')}.{' '}
<Link to="/ssl_domain" className="alert-link">
Renew in Domains &amp; SSL
</Link>
</AdminAlert>
) : null}
{ssl.expiring.length > 0 ? (
<AdminAlert variant="warning" className="mb-3">
<strong>Certificates expiring within 14 days:</strong>{' '}
{ssl.expiring.map((s) => `${s.site} (${s.days_left ?? '?'}d)`).join('; ')}.{' '}
<Link to="/ssl_domain" className="alert-link">
Open Domains &amp; SSL
</Link>
</AdminAlert>
) : null}
{inodeWarn ? (
<AdminAlert variant="warning" className="mb-3">
Root filesystem inode usage is high ({inodePct}%). Large numbers of small files can exhaust inodes before
disk space consider cleanup or archiving.
</AdminAlert>
) : null}
<div className="row g-3 mb-4">
<div className="col-md-4 d-flex">
<StatCard iconClass="ti ti-world" title="Websites" value={stats.site_count} />
@@ -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`
}
/>
</div>
</div>

View File

@@ -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<FirewallRule[]>([])
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<FirewallBackendStatus | null>(null)
const loadRules = () => {
setLoading(true)
apiRequest<FirewallRule[]>('/firewall/list')
.then(setRules)
Promise.all([
apiRequest<FirewallRule[]>('/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 ? <AdminAlert className="mb-3">{error}</AdminAlert> : null}
{backend ? (
<div className="card border-0 shadow-sm mb-3">
<div className="card-body py-3">
<h6 className="text-muted text-uppercase small mb-2">Host firewall (live)</h6>
<div className="d-flex flex-wrap gap-3 align-items-center">
<div>
<span className="text-muted small me-2">UFW</span>
{!backend.ufw.detected ? (
<span className="badge text-bg-secondary">Not detected</span>
) : backend.ufw.active === true ? (
<span className="badge text-bg-success">Active</span>
) : backend.ufw.active === false ? (
<span className="badge text-bg-warning text-dark">Inactive</span>
) : (
<span className="badge text-bg-secondary">Unknown</span>
)}
{backend.ufw.summary_line ? (
<span className="small text-muted ms-2 font-monospace">{backend.ufw.summary_line}</span>
) : null}
</div>
<div>
<span className="text-muted small me-2">firewalld</span>
{!backend.firewalld.detected ? (
<span className="badge text-bg-secondary">Not detected</span>
) : backend.firewalld.running ? (
<span className="badge text-bg-info text-dark">Running</span>
) : (
<span className="badge text-bg-secondary">Not running</span>
)}
</div>
</div>
<p className="small text-muted mb-0 mt-2">
Panel &quot;Apply to UFW&quot; runs <code>ufw</code> only. If you use firewalld, sync rules there separately.
</p>
</div>
</div>
) : null}
<div className="alert alert-warning small mb-3">
Rules are stored in the panel. Click &quot;Apply to UFW&quot; to run <code className="font-monospace">ufw allow/deny</code> for each rule.
</div>