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""" """YakPanel - Dashboard API"""
import os
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func from sqlalchemy import select, func
@@ -10,6 +12,24 @@ from app.models.user import User
router = APIRouter(prefix="/dashboard", tags=["dashboard"]) 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") @router.get("/stats")
async def get_stats( async def get_stats(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
@@ -18,10 +38,10 @@ async def get_stats(
"""Get dashboard statistics""" """Get dashboard statistics"""
import psutil 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.ftp import Ftp
from app.models.database import Database from app.models.database import Database
from sqlalchemy import select, func
site_count = await get_site_count(db) site_count = await get_site_count(db)
ftp_result = await db.execute(select(func.count()).select_from(Ftp)) ftp_result = await db.execute(select(func.count()).select_from(Ftp))
ftp_count = ftp_result.scalar() or 0 ftp_count = ftp_result.scalar() or 0
@@ -32,11 +52,14 @@ async def get_stats(
cpu_percent = psutil.cpu_percent(interval=1) cpu_percent = psutil.cpu_percent(interval=1)
memory = psutil.virtual_memory() memory = psutil.virtual_memory()
disk = psutil.disk_usage("/") disk = psutil.disk_usage("/")
inodes = _root_inode_usage()
ssl_alerts = await ssl_alert_summary(db)
return { return {
"site_count": site_count, "site_count": site_count,
"ftp_count": ftp_count, "ftp_count": ftp_count,
"database_count": database_count, "database_count": database_count,
"ssl_alerts": ssl_alerts,
"system": { "system": {
"cpu_percent": cpu_percent, "cpu_percent": cpu_percent,
"memory_percent": memory.percent, "memory_percent": memory.percent,
@@ -45,5 +68,8 @@ async def get_stats(
"disk_percent": disk.percent, "disk_percent": disk.percent,
"disk_used_gb": round(disk.used / 1024 / 1024 / 1024, 2), "disk_used_gb": round(disk.used / 1024 / 1024 / 1024, 2),
"disk_total_gb": round(disk.total / 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 = "" 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") @router.get("/list")
async def firewall_list( async def firewall_list(
current_user: User = Depends(get_current_user), 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 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: async def get_site_with_domains(db: AsyncSession, site_id: int) -> dict | None:
"""Get site with domain list for editing.""" """Get site with domain list for editing."""
result = await db.execute(select(Site).where(Site.id == site_id)) 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` | | FTP logs (tail) | Done | `GET /ftp/logs` |
| Cron job templates (YakPanel JSON) | Done | `GET /crontab/templates`, `data/cron_templates.json` | | 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 | | 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 | — | | Mail server | Not planned | — |
| WordPress one-click | Not planned | plugin later | | 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() { export async function getDashboardStats() {
return apiRequest<{ return apiRequest<{
site_count: number site_count: number
ftp_count: number ftp_count: number
database_count: number database_count: number
ssl_alerts: { expired: SslAlertSite[]; expiring: SslAlertSite[] }
system: { system: {
cpu_percent: number cpu_percent: number
memory_percent: number memory_percent: number
@@ -536,10 +539,20 @@ export async function getDashboardStats() {
disk_percent: number disk_percent: number
disk_used_gb: number disk_used_gb: number
disk_total_gb: number disk_total_gb: number
inode_percent: number | null
inode_used: number | null
inode_total: number | null
} }
}>('/dashboard/stats') }>('/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() { export async function applyCrontab() {
return apiRequest<{ status: boolean; msg: string; count: number }>('/crontab/apply', { method: 'POST' }) return apiRequest<{ status: boolean; msg: string; count: number }>('/crontab/apply', { method: 'POST' })
} }

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { getDashboardStats } from '../api/client' import { getDashboardStats } from '../api/client'
import { PageHeader, AdminCard, AdminAlert } from '../components/admin' import { PageHeader, AdminCard, AdminAlert } from '../components/admin'
@@ -6,6 +7,7 @@ interface Stats {
site_count: number site_count: number
ftp_count: number ftp_count: number
database_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: { system: {
cpu_percent: number cpu_percent: number
memory_percent: number memory_percent: number
@@ -14,6 +16,9 @@ interface Stats {
disk_percent: number disk_percent: number
disk_used_gb: number disk_used_gb: number
disk_total_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 ( return (
<> <>
<PageHeader /> <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="row g-3 mb-4">
<div className="col-md-4 d-flex"> <div className="col-md-4 d-flex">
<StatCard iconClass="ti ti-world" title="Websites" value={stats.site_count} /> <StatCard iconClass="ti ti-world" title="Websites" value={stats.site_count} />
@@ -78,7 +113,11 @@ export function DashboardPage() {
iconClass="ti ti-database-export" iconClass="ti ti-database-export"
title="Disk" title="Disk"
value={`${stats.system.disk_percent}%`} 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>
</div> </div>

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import Modal from 'react-bootstrap/Modal' 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' import { PageHeader, AdminButton, AdminAlert, AdminTable, EmptyState } from '../components/admin'
interface FirewallRule { interface FirewallRule {
@@ -11,6 +11,11 @@ interface FirewallRule {
ps: string ps: string
} }
interface FirewallBackendStatus {
ufw: { detected: boolean; active: boolean | null; summary_line: string }
firewalld: { detected: boolean; running: boolean; state: string | null }
}
export function FirewallPage() { export function FirewallPage() {
const [rules, setRules] = useState<FirewallRule[]>([]) const [rules, setRules] = useState<FirewallRule[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -19,11 +24,18 @@ export function FirewallPage() {
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const [creatingError, setCreatingError] = useState('') const [creatingError, setCreatingError] = useState('')
const [applying, setApplying] = useState(false) const [applying, setApplying] = useState(false)
const [backend, setBackend] = useState<FirewallBackendStatus | null>(null)
const loadRules = () => { const loadRules = () => {
setLoading(true) setLoading(true)
apiRequest<FirewallRule[]>('/firewall/list') Promise.all([
.then(setRules) apiRequest<FirewallRule[]>('/firewall/list'),
getFirewallBackendStatus().catch(() => null),
])
.then(([list, st]) => {
setRules(list)
setBackend(st)
})
.catch((err) => setError(err.message)) .catch((err) => setError(err.message))
.finally(() => setLoading(false)) .finally(() => setLoading(false))
} }
@@ -107,6 +119,44 @@ export function FirewallPage() {
{error ? <AdminAlert className="mb-3">{error}</AdminAlert> : null} {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"> <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. 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> </div>