new changes
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -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"],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
Binary file not shown.
@@ -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))
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|
||||||
|
|||||||
@@ -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' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 & 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 & 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>
|
||||||
|
|||||||
@@ -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 "Apply to UFW" 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 "Apply to UFW" to run <code className="font-monospace">ufw allow/deny</code> for each rule.
|
Rules are stored in the panel. Click "Apply to UFW" to run <code className="font-monospace">ufw allow/deny</code> for each rule.
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user