Compare commits
2 Commits
6fc617d3e5
...
4451b3e45a
| Author | SHA1 | Date | |
|---|---|---|---|
| 4451b3e45a | |||
| a507e3c4bc |
@@ -406,12 +406,12 @@ async def update_security_settings(
|
|||||||
# ==================== Notification Channel Settings API ====================
|
# ==================== Notification Channel Settings API ====================
|
||||||
|
|
||||||
class SMTPSettingsRequest(BaseModel):
|
class SMTPSettingsRequest(BaseModel):
|
||||||
smtp_host: Optional[str] = Field(None, max_length=255)
|
host: Optional[str] = Field(None, max_length=255)
|
||||||
smtp_port: Optional[int] = Field(None, ge=1, le=65535)
|
port: Optional[int] = Field(None, ge=1, le=65535)
|
||||||
smtp_user: Optional[str] = Field(None, max_length=255)
|
username: Optional[str] = Field(None, max_length=255)
|
||||||
smtp_password: Optional[str] = Field(None, max_length=255)
|
password: Optional[str] = Field(None, max_length=255)
|
||||||
smtp_from: Optional[str] = Field(None, max_length=255)
|
from_email: Optional[str] = Field(None, max_length=255)
|
||||||
smtp_tls: Optional[bool] = None
|
tls: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
class TelegramSettingsRequest(BaseModel):
|
class TelegramSettingsRequest(BaseModel):
|
||||||
@@ -435,9 +435,9 @@ async def get_notification_channel_settings(
|
|||||||
"smtp": {
|
"smtp": {
|
||||||
"host": get_setting(db, "smtp_host", ""),
|
"host": get_setting(db, "smtp_host", ""),
|
||||||
"port": int(get_setting(db, "smtp_port", "587") or "587"),
|
"port": int(get_setting(db, "smtp_port", "587") or "587"),
|
||||||
"user": get_setting(db, "smtp_user", ""),
|
"username": get_setting(db, "smtp_user", ""),
|
||||||
"password": "***" if get_setting(db, "smtp_password") else "",
|
"password": "***" if get_setting(db, "smtp_password") else "",
|
||||||
"from": get_setting(db, "smtp_from", ""),
|
"from_email": get_setting(db, "smtp_from", ""),
|
||||||
"tls": get_setting(db, "smtp_tls", "true").lower() == "true"
|
"tls": get_setting(db, "smtp_tls", "true").lower() == "true"
|
||||||
},
|
},
|
||||||
"telegram": {
|
"telegram": {
|
||||||
@@ -463,28 +463,28 @@ async def update_smtp_settings(
|
|||||||
|
|
||||||
updated_fields = []
|
updated_fields = []
|
||||||
|
|
||||||
if data.smtp_host is not None:
|
if data.host is not None:
|
||||||
set_setting(db, "smtp_host", data.smtp_host, "SMTP сервер")
|
set_setting(db, "smtp_host", data.host, "SMTP сервер")
|
||||||
updated_fields.append("smtp_host")
|
updated_fields.append("smtp_host")
|
||||||
|
|
||||||
if data.smtp_port is not None:
|
if data.port is not None:
|
||||||
set_setting(db, "smtp_port", str(data.smtp_port), "SMTP порт")
|
set_setting(db, "smtp_port", str(data.port), "SMTP порт")
|
||||||
updated_fields.append("smtp_port")
|
updated_fields.append("smtp_port")
|
||||||
|
|
||||||
if data.smtp_user is not None:
|
if data.username is not None:
|
||||||
set_setting(db, "smtp_user", data.smtp_user, "SMTP пользователь")
|
set_setting(db, "smtp_user", data.username, "SMTP пользователь")
|
||||||
updated_fields.append("smtp_user")
|
updated_fields.append("smtp_user")
|
||||||
|
|
||||||
if data.smtp_password is not None:
|
if data.password is not None:
|
||||||
set_setting(db, "smtp_password", data.smtp_password, "SMTP пароль")
|
set_setting(db, "smtp_password", data.password, "SMTP пароль")
|
||||||
updated_fields.append("smtp_password")
|
updated_fields.append("smtp_password")
|
||||||
|
|
||||||
if data.smtp_from is not None:
|
if data.from_email is not None:
|
||||||
set_setting(db, "smtp_from", data.smtp_from, "Email отправителя")
|
set_setting(db, "smtp_from", data.from_email, "Email отправителя")
|
||||||
updated_fields.append("smtp_from")
|
updated_fields.append("smtp_from")
|
||||||
|
|
||||||
if data.smtp_tls is not None:
|
if data.tls is not None:
|
||||||
set_setting(db, "smtp_tls", str(data.smtp_tls).lower(), "Использовать TLS")
|
set_setting(db, "smtp_tls", str(data.tls).lower(), "Использовать TLS")
|
||||||
updated_fields.append("smtp_tls")
|
updated_fields.append("smtp_tls")
|
||||||
|
|
||||||
if updated_fields:
|
if updated_fields:
|
||||||
|
|||||||
@@ -168,6 +168,37 @@ class VmMixin:
|
|||||||
"""Получить статистику VM (CPU, память, диск)"""
|
"""Получить статистику VM (CPU, память, диск)"""
|
||||||
return self.get_vm_status(node, vmid)
|
return self.get_vm_status(node, vmid)
|
||||||
|
|
||||||
|
def get_vm_fsinfo(self, node: str, vmid: int) -> list:
|
||||||
|
"""Return per-filesystem info via QEMU guest agent (get-fsinfo).
|
||||||
|
Returns [] if agent is not installed or unreachable."""
|
||||||
|
if not self.proxmox:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
result = self.proxmox.nodes(node).qemu(vmid).agent('get-fsinfo').get()
|
||||||
|
fslist = result if isinstance(result, list) else result.get('result', [])
|
||||||
|
disks = []
|
||||||
|
seen_disks: set = set()
|
||||||
|
for fs in fslist:
|
||||||
|
if not isinstance(fs, dict):
|
||||||
|
continue
|
||||||
|
total = fs.get('total-bytes', 0) or 0
|
||||||
|
if total == 0:
|
||||||
|
continue
|
||||||
|
# Deduplicate by disk+partition key to avoid pseudo-filesystems
|
||||||
|
disk_key = fs.get('name', '') or fs.get('mountpoint', '')
|
||||||
|
if disk_key in seen_disks:
|
||||||
|
continue
|
||||||
|
seen_disks.add(disk_key)
|
||||||
|
disks.append({
|
||||||
|
'name': fs.get('name', ''),
|
||||||
|
'mountpoint': fs.get('mountpoint', '/'),
|
||||||
|
'used': fs.get('used-bytes', 0) or 0,
|
||||||
|
'total': total,
|
||||||
|
})
|
||||||
|
return disks
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
def get_vm_rrddata(self, node: str, vmid: int, timeframe: str = "hour") -> Dict:
|
def get_vm_rrddata(self, node: str, vmid: int, timeframe: str = "hour") -> Dict:
|
||||||
"""
|
"""
|
||||||
Получить исторические данные VM для графиков (CPU, Memory, Network, Disk IO)
|
Получить исторические данные VM для графиков (CPU, Memory, Network, Disk IO)
|
||||||
|
|||||||
@@ -34,6 +34,16 @@ _metrics_cache: Dict[str, Any] = {}
|
|||||||
_rrd_cache: Dict[str, Tuple[float, list]] = {}
|
_rrd_cache: Dict[str, Tuple[float, list]] = {}
|
||||||
_RRD_TTL = 30.0 # seconds between actual RRD fetches from Proxmox
|
_RRD_TTL = 30.0 # seconds between actual RRD fetches from Proxmox
|
||||||
|
|
||||||
|
# I/O rate tracking for per-instance detail channel
|
||||||
|
_io_prev: Dict[str, Tuple[float, Dict[str, float]]] = {}
|
||||||
|
|
||||||
|
# Per-disk info cache: channel → (monotonic_ts, disks_list); refreshed every 30 s
|
||||||
|
_disk_cache: Dict[str, Tuple[float, list]] = {}
|
||||||
|
_DISK_CACHE_TTL = 30.0
|
||||||
|
|
||||||
|
# I/O rate tracking for instances-list channel: key = "server_id:vmid"
|
||||||
|
_list_io_prev: Dict[str, Tuple[float, Dict[str, float]]] = {}
|
||||||
|
|
||||||
|
|
||||||
def get_cached_metrics(channel: str) -> Optional[Any]:
|
def get_cached_metrics(channel: str) -> Optional[Any]:
|
||||||
"""Return the last cached payload for a channel, or None."""
|
"""Return the last cached payload for a channel, or None."""
|
||||||
@@ -195,8 +205,30 @@ def _fetch_instance_status(db_factory, server_id: int, vmid: int, inst_type: str
|
|||||||
if not client.is_connected():
|
if not client.is_connected():
|
||||||
return None
|
return None
|
||||||
if inst_type == "qemu":
|
if inst_type == "qemu":
|
||||||
return client.get_vm_status(node, vmid)
|
status = client.get_vm_status(node, vmid)
|
||||||
return client.get_container_status(node, vmid)
|
else:
|
||||||
|
status = client.get_container_status(node, vmid)
|
||||||
|
if status is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Attach per-disk filesystem info
|
||||||
|
channel = f"instance_metrics:{server_id}_{vmid}_{inst_type}_{node}"
|
||||||
|
now = time.monotonic()
|
||||||
|
cached = _disk_cache.get(channel)
|
||||||
|
if cached and (now - cached[0]) < _DISK_CACHE_TTL:
|
||||||
|
status["disks"] = cached[1]
|
||||||
|
else:
|
||||||
|
if inst_type == "qemu":
|
||||||
|
disks = client.get_vm_fsinfo(node, vmid)
|
||||||
|
else:
|
||||||
|
# LXC: rootfs usage is in status itself
|
||||||
|
disk_used = status.get("disk", 0) or 0
|
||||||
|
disk_total = status.get("maxdisk", 0) or 0
|
||||||
|
disks = [{"name": "rootfs", "mountpoint": "/", "used": disk_used, "total": disk_total}] if disk_total else []
|
||||||
|
_disk_cache[channel] = (now, disks)
|
||||||
|
status["disks"] = disks
|
||||||
|
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
def _fetch_rrd(db_factory, server_id: int, action: str, params: dict):
|
def _fetch_rrd(db_factory, server_id: int, action: str, params: dict):
|
||||||
@@ -316,12 +348,13 @@ async def server_metrics_loop(db_factory) -> None:
|
|||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def instance_metrics_loop(db_factory) -> None:
|
async def instance_metrics_loop(db_factory) -> None:
|
||||||
"""Push VM/LXC status to 'instance_metrics:{server_id}_{vmid}_{type}_{node}'."""
|
"""Push VM/LXC status to 'instance_metrics:{server_id}_{vmid}_{type}_{node}' (~1 s)."""
|
||||||
wsman = _get_ws_manager()
|
wsman = _get_ws_manager()
|
||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(2)
|
cycle_start = time.monotonic()
|
||||||
channels = wsman.get_channels_matching("instance_metrics:")
|
channels = wsman.get_channels_matching("instance_metrics:")
|
||||||
if not channels:
|
if not channels:
|
||||||
|
await asyncio.sleep(1)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
async def _push_instance(channel: str) -> None:
|
async def _push_instance(channel: str) -> None:
|
||||||
@@ -340,6 +373,21 @@ async def instance_metrics_loop(db_factory) -> None:
|
|||||||
)
|
)
|
||||||
if not status:
|
if not status:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Compute I/O rates (bytes/sec) from cumulative counters
|
||||||
|
now = time.monotonic()
|
||||||
|
prev = _io_prev.get(channel)
|
||||||
|
if prev is not None:
|
||||||
|
dt = now - prev[0]
|
||||||
|
if dt > 0:
|
||||||
|
for key in ("diskread", "diskwrite", "netin", "netout"):
|
||||||
|
cur_val = float(status.get(key) or 0)
|
||||||
|
prv_val = float(prev[1].get(key) or 0)
|
||||||
|
delta = cur_val - prv_val
|
||||||
|
# Guard against resets (VM restart resets counters to 0)
|
||||||
|
status[f"{key}_rate"] = max(0.0, delta / dt)
|
||||||
|
_io_prev[channel] = (now, {k: float(status.get(k) or 0) for k in ("diskread", "diskwrite", "netin", "netout")})
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"type": "metrics_update",
|
"type": "metrics_update",
|
||||||
"channel": channel,
|
"channel": channel,
|
||||||
@@ -353,36 +401,56 @@ async def instance_metrics_loop(db_factory) -> None:
|
|||||||
logger.debug(f"[metrics_broadcaster] instance metrics error on '{channel}': {exc}")
|
logger.debug(f"[metrics_broadcaster] instance metrics error on '{channel}': {exc}")
|
||||||
|
|
||||||
await asyncio.gather(*[_push_instance(ch) for ch in channels], return_exceptions=True)
|
await asyncio.gather(*[_push_instance(ch) for ch in channels], return_exceptions=True)
|
||||||
|
# Pace to a 1 s cadence (sleep only the remainder after the work)
|
||||||
|
await asyncio.sleep(max(0.0, 1.0 - (time.monotonic() - cycle_start)))
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# Instances list metrics loop (push every 5 s)
|
# Instances list metrics loop (push ~every 2 s)
|
||||||
# Pushes a slim list of live VM/LXC metrics from cluster/resources for the
|
# Pushes a list of live VM/LXC metrics for the instances table page. One channel
|
||||||
# instances table page. One channel for all clients: 'instances_list_metrics'.
|
# for all clients: 'instances_list_metrics'.
|
||||||
|
#
|
||||||
|
# Data source strategy:
|
||||||
|
# • cluster/resources is cheap (one call per server) but pvestatd-backed, so its
|
||||||
|
# counters only refresh every ~10 s — too coarse for live I/O rates.
|
||||||
|
# • status/current (per VM) refreshes every ~1 s — true real-time.
|
||||||
|
# So we enumerate VMs via cluster/resources, then refresh every RUNNING VM with
|
||||||
|
# status/current (bounded concurrency) to get live cpu/mem/net/disk counters.
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _fetch_all_instances_metrics(db_factory) -> list:
|
# Max concurrent status/current calls per cycle (avoids hammering Proxmox API).
|
||||||
"""Pull live metrics for all VMs/LXC across all online servers via cluster/resources."""
|
# Higher concurrency keeps the per-cycle work under the 1 s cadence target even
|
||||||
|
# with dozens of running VMs.
|
||||||
|
_LIST_LIVE_CONCURRENCY = 16
|
||||||
|
|
||||||
|
|
||||||
|
def _enumerate_instances_for_list(db_factory):
|
||||||
|
"""Enumerate VMs/LXC across online servers via cluster/resources (one call per
|
||||||
|
server). Returns (items, clients) where `clients` maps server_id → ProxmoxClient
|
||||||
|
so the loop can refresh running VMs with live status/current."""
|
||||||
from ..models import ProxmoxServer
|
from ..models import ProxmoxServer
|
||||||
from ..api.proxmox._helpers import _get_proxmox_client
|
from ..api.proxmox._helpers import _get_proxmox_client
|
||||||
db = db_factory()
|
db = db_factory()
|
||||||
try:
|
try:
|
||||||
servers = db.query(ProxmoxServer).filter(ProxmoxServer.is_online == True).all()
|
servers = db.query(ProxmoxServer).filter(ProxmoxServer.is_online == True).all()
|
||||||
|
# Build clients while the session is open (from_server reads server attrs)
|
||||||
|
server_clients = [(s.id, _get_proxmox_client(s)) for s in servers]
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
out: list = []
|
items: list = []
|
||||||
for server in servers:
|
clients: dict = {}
|
||||||
|
for server_id, client in server_clients:
|
||||||
|
clients[server_id] = client
|
||||||
try:
|
try:
|
||||||
client = _get_proxmox_client(server)
|
|
||||||
# Skip is_connected() check (makes extra HTTP call); just try and catch
|
|
||||||
for res in client.get_cluster_resources(type_='vm'):
|
for res in client.get_cluster_resources(type_='vm'):
|
||||||
vmid = res.get('vmid')
|
vmid = res.get('vmid')
|
||||||
if vmid is None:
|
if vmid is None:
|
||||||
continue
|
continue
|
||||||
out.append({
|
items.append({
|
||||||
"server_id": server.id,
|
"server_id": server_id,
|
||||||
"vmid": vmid,
|
"vmid": vmid,
|
||||||
|
"type": res.get('type'),
|
||||||
"node": res.get('node'),
|
"node": res.get('node'),
|
||||||
"status": res.get('status'),
|
"status": res.get('status'),
|
||||||
"cpu": res.get('cpu'),
|
"cpu": res.get('cpu'),
|
||||||
@@ -393,24 +461,82 @@ def _fetch_all_instances_metrics(db_factory) -> list:
|
|||||||
"uptime": res.get('uptime'),
|
"uptime": res.get('uptime'),
|
||||||
"netin": res.get('netin'),
|
"netin": res.get('netin'),
|
||||||
"netout": res.get('netout'),
|
"netout": res.get('netout'),
|
||||||
|
"diskread": res.get('diskread'),
|
||||||
|
"diskwrite": res.get('diskwrite'),
|
||||||
})
|
})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug(f"[metrics_broadcaster] instances list metrics error for server {server.id}: {exc}")
|
logger.debug(f"[metrics_broadcaster] instances list enumerate error for server {server_id}: {exc}")
|
||||||
return out
|
return items, clients
|
||||||
|
|
||||||
|
|
||||||
|
def _live_instance_status(client, node: str, vmid: int, inst_type: str):
|
||||||
|
"""Fetch live status/current for one VM/LXC. Returns dict or None on error."""
|
||||||
|
try:
|
||||||
|
if inst_type == "lxc":
|
||||||
|
return client.get_container_status(node, vmid)
|
||||||
|
return client.get_vm_status(node, vmid)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Fields refreshed from the live status/current call (override stale cluster/resources)
|
||||||
|
_LIVE_FIELDS = ("cpu", "mem", "maxmem", "disk", "maxdisk", "uptime",
|
||||||
|
"netin", "netout", "diskread", "diskwrite")
|
||||||
|
|
||||||
|
|
||||||
async def instances_list_metrics_loop(db_factory) -> None:
|
async def instances_list_metrics_loop(db_factory) -> None:
|
||||||
"""Push live VM/LXC metrics list to 'instances_list_metrics' subscribers (~1 s)."""
|
"""Push live VM/LXC metrics list to 'instances_list_metrics' subscribers (~2 s)."""
|
||||||
wsman = _get_ws_manager()
|
wsman = _get_ws_manager()
|
||||||
channel = "instances_list_metrics"
|
channel = "instances_list_metrics"
|
||||||
|
sem = asyncio.Semaphore(_LIST_LIVE_CONCURRENCY)
|
||||||
|
|
||||||
|
async def _refresh_running(item: dict, clients: dict) -> None:
|
||||||
|
if item.get("status") != "running":
|
||||||
|
return
|
||||||
|
client = clients.get(item["server_id"])
|
||||||
|
if client is None or not item.get("node"):
|
||||||
|
return
|
||||||
|
async with sem:
|
||||||
|
live = await asyncio.to_thread(
|
||||||
|
_live_instance_status, client, item["node"], item["vmid"], item.get("type") or "qemu"
|
||||||
|
)
|
||||||
|
if live:
|
||||||
|
for field in _LIVE_FIELDS:
|
||||||
|
val = live.get(field)
|
||||||
|
if val is not None:
|
||||||
|
item[field] = val
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(1)
|
cycle_start = time.monotonic()
|
||||||
if not wsman.has_channel_subscribers(channel):
|
if not wsman.has_channel_subscribers(channel):
|
||||||
|
await asyncio.sleep(1)
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
items = await asyncio.to_thread(_fetch_all_instances_metrics, db_factory)
|
items, clients = await asyncio.to_thread(_enumerate_instances_for_list, db_factory)
|
||||||
if not items:
|
if not items:
|
||||||
|
await asyncio.sleep(1)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Refresh every running VM with live status/current (1 s-granular counters)
|
||||||
|
await asyncio.gather(
|
||||||
|
*[_refresh_running(it, clients) for it in items],
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Compute per-item I/O rates from the (now live) cumulative counters
|
||||||
|
now = time.monotonic()
|
||||||
|
for item in items:
|
||||||
|
key = f"{item['server_id']}:{item['vmid']}"
|
||||||
|
prev = _list_io_prev.get(key)
|
||||||
|
if prev is not None:
|
||||||
|
dt = now - prev[0]
|
||||||
|
if dt > 0:
|
||||||
|
for field in ("diskread", "diskwrite", "netin", "netout"):
|
||||||
|
cur = float(item.get(field) or 0)
|
||||||
|
prv = float(prev[1].get(field) or 0)
|
||||||
|
item[f"{field}_rate"] = max(0.0, (cur - prv) / dt)
|
||||||
|
_list_io_prev[key] = (now, {f: float(item.get(f) or 0) for f in ("diskread", "diskwrite", "netin", "netout")})
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"type": "metrics_update",
|
"type": "metrics_update",
|
||||||
"channel": channel,
|
"channel": channel,
|
||||||
@@ -422,6 +548,8 @@ async def instances_list_metrics_loop(db_factory) -> None:
|
|||||||
break
|
break
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(f"[metrics_broadcaster] instances list loop error: {exc}")
|
logger.warning(f"[metrics_broadcaster] instances list loop error: {exc}")
|
||||||
|
# Pace to a 1 s cadence (sleep only the remainder after the work)
|
||||||
|
await asyncio.sleep(max(0.0, 1.0 - (time.monotonic() - cycle_start)))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { getTasksWebSocket } from '@/lib/websocket';
|
import { getTasksWebSocket } from '@/lib/websocket';
|
||||||
|
import { useInstancesMetricsSync } from '@/hooks/use-instances';
|
||||||
|
|
||||||
// ==================== Stat Card ====================
|
// ==================== Stat Card ====================
|
||||||
interface StatCardProps {
|
interface StatCardProps {
|
||||||
@@ -145,6 +146,8 @@ function formatBytes(bytes: number): string {
|
|||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
// Live per-VM metrics (realtime ~2 s) — overlaid onto the top-loaded list below
|
||||||
|
const liveMetrics = useInstancesMetricsSync();
|
||||||
|
|
||||||
// Fetch all resources
|
// Fetch all resources
|
||||||
const { data: resources } = useQuery({
|
const { data: resources } = useQuery({
|
||||||
@@ -185,7 +188,11 @@ export default function DashboardPage() {
|
|||||||
const allVMs = serverList.flatMap((s) => [
|
const allVMs = serverList.flatMap((s) => [
|
||||||
...((s.vms as Array<Record<string, unknown>>) ?? []),
|
...((s.vms as Array<Record<string, unknown>>) ?? []),
|
||||||
...((s.containers as Array<Record<string, unknown>>) ?? []),
|
...((s.containers as Array<Record<string, unknown>>) ?? []),
|
||||||
]);
|
].map((v) => {
|
||||||
|
// Overlay realtime cpu/mem from the live instances channel (falls back to WS snapshot)
|
||||||
|
const live = liveMetrics.get(`${s.id}:${v.vmid}`);
|
||||||
|
return live ? { ...v, cpu: live.cpu ?? v.cpu, mem: live.mem ?? v.mem, status: live.status ?? v.status } : v;
|
||||||
|
}));
|
||||||
|
|
||||||
const nodesOnline = serverList.filter((s) => !s.error).length;
|
const nodesOnline = serverList.filter((s) => !s.error).length;
|
||||||
const qemuVMs = allVMs.filter((v) => v.type === 'qemu');
|
const qemuVMs = allVMs.filter((v) => v.type === 'qemu');
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ import {
|
|||||||
KeyRound,
|
KeyRound,
|
||||||
FileText,
|
FileText,
|
||||||
Disc,
|
Disc,
|
||||||
|
ArrowDown,
|
||||||
|
ArrowUp,
|
||||||
|
HardDrive,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import InstanceActionDialogs, { PowerConfirmDialog, type InstanceAction, type PowerAction } from './InstanceActionDialogs';
|
import InstanceActionDialogs, { PowerConfirmDialog, type InstanceAction, type PowerAction } from './InstanceActionDialogs';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
@@ -349,6 +352,12 @@ export default function InstancesPage() {
|
|||||||
uptime: live.uptime ?? vm.uptime,
|
uptime: live.uptime ?? vm.uptime,
|
||||||
netin: live.netin ?? vm.netin,
|
netin: live.netin ?? vm.netin,
|
||||||
netout: live.netout ?? vm.netout,
|
netout: live.netout ?? vm.netout,
|
||||||
|
diskread: live.diskread ?? vm.diskread,
|
||||||
|
diskwrite: live.diskwrite ?? vm.diskwrite,
|
||||||
|
diskread_rate: live.diskread_rate,
|
||||||
|
diskwrite_rate: live.diskwrite_rate,
|
||||||
|
netin_rate: live.netin_rate,
|
||||||
|
netout_rate: live.netout_rate,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [allRows, metricsMap]);
|
}, [allRows, metricsMap]);
|
||||||
@@ -594,7 +603,9 @@ export default function InstancesPage() {
|
|||||||
header: 'Disk',
|
header: 'Disk',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const vm = row.original;
|
const vm = row.original;
|
||||||
if (vm._deployTaskId || (!vm.disk && !vm.maxdisk)) return <span className="text-xs text-muted-foreground">—</span>;
|
if (vm._deployTaskId) return <span className="text-xs text-muted-foreground">—</span>;
|
||||||
|
// For QEMU, cluster/resources 'disk' = disk I/O bytes (not usage) → not meaningful as %
|
||||||
|
if (vm.type !== 'lxc' || (!vm.disk && !vm.maxdisk)) return <span className="text-xs text-muted-foreground">—</span>;
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
@@ -606,6 +617,67 @@ export default function InstancesPage() {
|
|||||||
},
|
},
|
||||||
size: 120,
|
size: 120,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'net_io',
|
||||||
|
header: () => (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<ArrowDown className="h-3 w-3 text-blue-500" />
|
||||||
|
<ArrowUp className="h-3 w-3 text-violet-500" />
|
||||||
|
Net
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const vm = row.original;
|
||||||
|
if (vm._deployTaskId || vm.status !== 'running') return <span className="text-xs text-muted-foreground">—</span>;
|
||||||
|
const inRate = vm.netin_rate;
|
||||||
|
const outRate = vm.netout_rate;
|
||||||
|
if (inRate == null && outRate == null) return <span className="text-xs text-muted-foreground">—</span>;
|
||||||
|
const fmt = (v?: number) => v != null ? `${formatBytes(v)}/s` : '—';
|
||||||
|
return (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<ArrowDown className="h-3 w-3 shrink-0 text-blue-500" />
|
||||||
|
<span className="text-xs tabular-nums">{fmt(inRate)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<ArrowUp className="h-3 w-3 shrink-0 text-violet-500" />
|
||||||
|
<span className="text-xs tabular-nums">{fmt(outRate)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
size: 110,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'disk_io',
|
||||||
|
header: () => (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<HardDrive className="h-3 w-3 text-muted-foreground" />
|
||||||
|
Disk I/O
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const vm = row.original;
|
||||||
|
if (vm._deployTaskId || vm.status !== 'running') return <span className="text-xs text-muted-foreground">—</span>;
|
||||||
|
const readRate = vm.diskread_rate;
|
||||||
|
const writeRate = vm.diskwrite_rate;
|
||||||
|
if (readRate == null && writeRate == null) return <span className="text-xs text-muted-foreground">—</span>;
|
||||||
|
const fmt = (v?: number) => v != null ? `${formatBytes(v)}/s` : '—';
|
||||||
|
return (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-[10px] text-cyan-500 font-medium w-3">R</span>
|
||||||
|
<span className="text-xs tabular-nums">{fmt(readRate)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-[10px] text-orange-500 font-medium w-3">W</span>
|
||||||
|
<span className="text-xs tabular-nums">{fmt(writeRate)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
size: 110,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'uptime',
|
id: 'uptime',
|
||||||
header: 'Uptime',
|
header: 'Uptime',
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Cpu, MemoryStick, HardDrive, Clock, Wifi, Globe, Server } from 'lucide-react';
|
import { Cpu, MemoryStick, HardDrive, Clock, Wifi, Globe, Server, ArrowDown, ArrowUp } from 'lucide-react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { useVMStatus, useVMConfig, useVMInterfaces } from '@/hooks/use-instances';
|
import { useVMLiveMetrics, useVMConfig, useVMInterfaces } from '@/hooks/use-instances';
|
||||||
import { formatBytes, formatUptime, formatPercent } from '@/lib/format';
|
import { formatBytes, formatUptime, formatPercent } from '@/lib/format';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { DiskInfo } from '@/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
serverId: number;
|
serverId: number;
|
||||||
@@ -55,6 +56,84 @@ function MetricCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function NetworkCard({ label, inRate, outRate }: { label: string; inRate?: number; outRate?: number }) {
|
||||||
|
const fmt = (v?: number) => (v != null ? `${formatBytes(v)}/s` : '—');
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Globe className="h-4 w-4" />
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider">{label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<ArrowDown className="h-3.5 w-3.5 text-blue-500" />
|
||||||
|
<span className="text-base font-bold tabular-nums">{fmt(inRate)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<ArrowUp className="h-3.5 w-3.5 text-violet-500" />
|
||||||
|
<span className="text-base font-bold tabular-nums">{fmt(outRate)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiskCard({ disk }: { disk: DiskInfo }) {
|
||||||
|
const pct = disk.total > 0 ? (disk.used / disk.total) * 100 : 0;
|
||||||
|
const barColor = pct >= 90 ? 'bg-red-500' : pct >= 70 ? 'bg-amber-500' : 'bg-primary';
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="mb-1 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<HardDrive className="h-4 w-4" />
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider">{disk.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground font-mono">{disk.mountpoint}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xl font-bold tabular-nums">{pct.toFixed(1)}%</p>
|
||||||
|
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all ${barColor}`}
|
||||||
|
style={{ width: `${Math.min(pct, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{formatBytes(disk.used)} / {formatBytes(disk.total)}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IoRateCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
icon: Icon,
|
||||||
|
color,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value?: number;
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
color: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Icon className={`h-4 w-4 ${color}`} />
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider">{label}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xl font-bold tabular-nums">
|
||||||
|
{value != null ? `${formatBytes(value)}/s` : '—'}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function InfoRow({ label, value }: { label: string; value?: string | number | null }) {
|
function InfoRow({ label, value }: { label: string; value?: string | number | null }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-between py-2 border-b border-border/50 last:border-0">
|
<div className="flex justify-between py-2 border-b border-border/50 last:border-0">
|
||||||
@@ -66,21 +145,22 @@ function InfoRow({ label, value }: { label: string; value?: string | number | nu
|
|||||||
|
|
||||||
export default function OverviewTab({ serverId, vmid, type, node }: Props) {
|
export default function OverviewTab({ serverId, vmid, type, node }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data: status } = useVMStatus(serverId, vmid, type, node);
|
const status = useVMLiveMetrics(serverId, vmid, type, node);
|
||||||
const { data: config } = useVMConfig(serverId, vmid, type, node);
|
const { data: config } = useVMConfig(serverId, vmid, type, node);
|
||||||
const { data: ifaces } = useVMInterfaces(serverId, vmid, type, node);
|
const { data: ifaces } = useVMInterfaces(serverId, vmid, type, node);
|
||||||
|
|
||||||
const cpuPercent = status ? (status.cpu || 0) * 100 : 0;
|
const cpuPercent = status ? (status.cpu || 0) * 100 : 0;
|
||||||
const memPercent = status && status.maxmem ? (status.mem / status.maxmem) * 100 : 0;
|
const memPercent = status && status.maxmem ? (status.mem / status.maxmem) * 100 : 0;
|
||||||
const diskPercent = status && status.maxdisk ? (status.disk / status.maxdisk) * 100 : 0;
|
|
||||||
|
|
||||||
const primaryIP = ifaces?.interfaces
|
const primaryIP = ifaces?.interfaces
|
||||||
?.flatMap((i) => i.ips || [])
|
?.flatMap((i) => i.ips || [])
|
||||||
.find((ip) => ip.type === 'ipv4' && !ip.address.startsWith('127.'));
|
.find((ip) => ip.type === 'ipv4' && !ip.address.startsWith('127.'));
|
||||||
|
|
||||||
|
const disks: DiskInfo[] = status?.disks ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Metric cards */}
|
{/* Row 1: Main metric cards */}
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label={t('common.cpu')}
|
label={t('common.cpu')}
|
||||||
@@ -96,22 +176,77 @@ export default function OverviewTab({ serverId, vmid, type, node }: Props) {
|
|||||||
percent={memPercent}
|
percent={memPercent}
|
||||||
icon={MemoryStick}
|
icon={MemoryStick}
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
|
||||||
label={t('common.disk')}
|
|
||||||
value={status ? formatBytes(status.disk) : '—'}
|
|
||||||
subtitle={status ? `${t('common.of')} ${formatBytes(status.maxdisk)}` : undefined}
|
|
||||||
percent={diskPercent}
|
|
||||||
icon={HardDrive}
|
|
||||||
/>
|
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label={t('common.uptime')}
|
label={t('common.uptime')}
|
||||||
value={status?.uptime ? formatUptime(status.uptime) : '—'}
|
value={status?.uptime ? formatUptime(status.uptime) : '—'}
|
||||||
icon={Clock}
|
icon={Clock}
|
||||||
/>
|
/>
|
||||||
|
<NetworkCard
|
||||||
|
label={t('common.network_io')}
|
||||||
|
inRate={status?.netin_rate}
|
||||||
|
outRate={status?.netout_rate}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Storage section — per-disk breakdown */}
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-3 text-sm font-semibold flex items-center gap-2">
|
||||||
|
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||||
|
{t('common.storage')}
|
||||||
|
</h3>
|
||||||
|
{disks.length > 0 ? (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{disks.map((disk) => (
|
||||||
|
<DiskCard key={`${disk.name}-${disk.mountpoint}`} disk={disk} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4 text-sm text-muted-foreground">
|
||||||
|
{type === 'qemu'
|
||||||
|
? t('common.guest_agent_required')
|
||||||
|
: '—'}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* I/O Rates section */}
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-3 text-sm font-semibold flex items-center gap-2">
|
||||||
|
<ArrowDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
{t('common.io_rates')}
|
||||||
|
</h3>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<IoRateCard
|
||||||
|
label={t('common.disk_read_rate')}
|
||||||
|
value={status?.diskread_rate}
|
||||||
|
icon={HardDrive}
|
||||||
|
color="text-cyan-500"
|
||||||
|
/>
|
||||||
|
<IoRateCard
|
||||||
|
label={t('common.disk_write_rate')}
|
||||||
|
value={status?.diskwrite_rate}
|
||||||
|
icon={HardDrive}
|
||||||
|
color="text-orange-500"
|
||||||
|
/>
|
||||||
|
<IoRateCard
|
||||||
|
label={t('common.net_in_rate')}
|
||||||
|
value={status?.netin_rate}
|
||||||
|
icon={ArrowDown}
|
||||||
|
color="text-blue-500"
|
||||||
|
/>
|
||||||
|
<IoRateCard
|
||||||
|
label={t('common.net_out_rate')}
|
||||||
|
value={status?.netout_rate}
|
||||||
|
icon={ArrowUp}
|
||||||
|
color="text-violet-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info + Network details */}
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
{/* Info card */}
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||||
@@ -132,7 +267,6 @@ export default function OverviewTab({ serverId, vmid, type, node }: Props) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Network card */}
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||||
|
|||||||
@@ -80,7 +80,10 @@ function ProfileTab() {
|
|||||||
<div><Label>{t('settings.username')}</Label><Input value={profile?.username || ''} disabled className="mt-1" /></div>
|
<div><Label>{t('settings.username')}</Label><Input value={profile?.username || ''} disabled className="mt-1" /></div>
|
||||||
<div><Label>{t('settings.display_name')}</Label><Input value={displayName} onChange={e => setDisplayName(e.target.value)} className="mt-1" /></div>
|
<div><Label>{t('settings.display_name')}</Label><Input value={displayName} onChange={e => setDisplayName(e.target.value)} className="mt-1" /></div>
|
||||||
<div><Label>Email</Label><Input value={email} onChange={e => setEmail(e.target.value)} className="mt-1" /></div>
|
<div><Label>Email</Label><Input value={email} onChange={e => setEmail(e.target.value)} className="mt-1" /></div>
|
||||||
<Button size="sm" onClick={() => updateProfile.mutate({ full_name: displayName, email })}>{t('common.save')}</Button>
|
<Button size="sm" onClick={() => updateProfile.mutate({ full_name: displayName, email }, {
|
||||||
|
onSuccess: (r) => toast.success(r.message),
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
})}>{t('common.save')}</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
@@ -91,8 +94,10 @@ function ProfileTab() {
|
|||||||
<div><Label>{t('settings.confirm_password')}</Label><Input type="password" value={confirmPwd} onChange={e => setConfirmPwd(e.target.value)} className="mt-1" /></div>
|
<div><Label>{t('settings.confirm_password')}</Label><Input type="password" value={confirmPwd} onChange={e => setConfirmPwd(e.target.value)} className="mt-1" /></div>
|
||||||
<Button size="sm" onClick={() => {
|
<Button size="sm" onClick={() => {
|
||||||
if (newPwd !== confirmPwd) { toast.error(t('settings.password_mismatch')); return; }
|
if (newPwd !== confirmPwd) { toast.error(t('settings.password_mismatch')); return; }
|
||||||
changePassword.mutate({ current_password: currentPwd, new_password: newPwd, confirm_password: confirmPwd });
|
changePassword.mutate({ current_password: currentPwd, new_password: newPwd, confirm_password: confirmPwd }, {
|
||||||
setCurrentPwd(''); setNewPwd(''); setConfirmPwd('');
|
onSuccess: (r) => { toast.success(r.message); setCurrentPwd(''); setNewPwd(''); setConfirmPwd(''); },
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
}}>{t('settings.change_password')}</Button>
|
}}>{t('settings.change_password')}</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -143,8 +148,8 @@ function SecurityTab() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (security) {
|
if (security) {
|
||||||
setMaxAttempts(String(security.max_login_attempts ?? 5));
|
setMaxAttempts(String(security.max_login_attempts ?? 5));
|
||||||
setLockoutDuration(String(security.lockout_duration ?? 300));
|
setLockoutDuration(String(security.lockout_duration_minutes ?? 30));
|
||||||
setSessionTimeout(String(security.session_timeout ?? 3600));
|
setSessionTimeout(String(security.session_timeout_minutes ?? 60));
|
||||||
}
|
}
|
||||||
}, [security]);
|
}, [security]);
|
||||||
|
|
||||||
@@ -153,12 +158,15 @@ function SecurityTab() {
|
|||||||
<CardHeader><CardTitle className="text-sm">{t('settings.security_settings')}</CardTitle></CardHeader>
|
<CardHeader><CardTitle className="text-sm">{t('settings.security_settings')}</CardTitle></CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<div><Label>{t('settings.max_attempts')}</Label><Input type="number" value={maxAttempts} onChange={e => setMaxAttempts(e.target.value)} className="mt-1" /></div>
|
<div><Label>{t('settings.max_attempts')}</Label><Input type="number" value={maxAttempts} onChange={e => setMaxAttempts(e.target.value)} className="mt-1" /></div>
|
||||||
<div><Label>{t('settings.lockout_duration')}</Label><Input type="number" value={lockoutDuration} onChange={e => setLockoutDuration(e.target.value)} className="mt-1" /><p className="text-xs text-muted-foreground mt-1">{t('settings.seconds')}</p></div>
|
<div><Label>{t('settings.lockout_duration')}</Label><Input type="number" value={lockoutDuration} onChange={e => setLockoutDuration(e.target.value)} className="mt-1" /><p className="text-xs text-muted-foreground mt-1">{t('settings.minutes')}</p></div>
|
||||||
<div><Label>{t('settings.session_timeout')}</Label><Input type="number" value={sessionTimeout} onChange={e => setSessionTimeout(e.target.value)} className="mt-1" /><p className="text-xs text-muted-foreground mt-1">{t('settings.seconds')}</p></div>
|
<div><Label>{t('settings.session_timeout')}</Label><Input type="number" value={sessionTimeout} onChange={e => setSessionTimeout(e.target.value)} className="mt-1" /><p className="text-xs text-muted-foreground mt-1">{t('settings.minutes')}</p></div>
|
||||||
<Button size="sm" onClick={() => updateSecurity.mutate({
|
<Button size="sm" onClick={() => updateSecurity.mutate({
|
||||||
max_login_attempts: Number(maxAttempts),
|
max_login_attempts: Number(maxAttempts),
|
||||||
lockout_duration: Number(lockoutDuration),
|
lockout_duration_minutes: Number(lockoutDuration),
|
||||||
session_timeout: Number(sessionTimeout),
|
session_timeout_minutes: Number(sessionTimeout),
|
||||||
|
}, {
|
||||||
|
onSuccess: () => toast.success(t('settings.security_saved')),
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
})}>{t('common.save')}</Button>
|
})}>{t('common.save')}</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -228,7 +236,10 @@ function NotificationsTab() {
|
|||||||
<div><Label>Password</Label><Input type="password" value={smtpPass} onChange={e => setSmtpPass(e.target.value)} className="mt-1" /></div>
|
<div><Label>Password</Label><Input type="password" value={smtpPass} onChange={e => setSmtpPass(e.target.value)} className="mt-1" /></div>
|
||||||
<div><Label>From Email</Label><Input value={smtpFrom} onChange={e => setSmtpFrom(e.target.value)} className="mt-1" /></div>
|
<div><Label>From Email</Label><Input value={smtpFrom} onChange={e => setSmtpFrom(e.target.value)} className="mt-1" /></div>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" onClick={() => updateSmtp.mutate({ host: smtpHost, port: Number(smtpPort), username: smtpUser, password: smtpPass || undefined, from_email: smtpFrom })}>{t('common.save')}</Button>
|
<Button size="sm" onClick={() => updateSmtp.mutate({ host: smtpHost, port: Number(smtpPort), username: smtpUser, password: smtpPass || undefined, from_email: smtpFrom }, {
|
||||||
|
onSuccess: (r) => toast.success(r.message),
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
})}>{t('common.save')}</Button>
|
||||||
<div className="flex items-center gap-2 border-t pt-3">
|
<div className="flex items-center gap-2 border-t pt-3">
|
||||||
<Input value={testEmail} onChange={e => setTestEmail(e.target.value)} className="max-w-xs" placeholder={t('settings.test_email_placeholder')} />
|
<Input value={testEmail} onChange={e => setTestEmail(e.target.value)} className="max-w-xs" placeholder={t('settings.test_email_placeholder')} />
|
||||||
<Button size="sm" variant="outline" onClick={handleTestSmtp} disabled={testSmtp.isPending}>{t('settings.send_test')}</Button>
|
<Button size="sm" variant="outline" onClick={handleTestSmtp} disabled={testSmtp.isPending}>{t('settings.send_test')}</Button>
|
||||||
@@ -246,7 +257,10 @@ function NotificationsTab() {
|
|||||||
<div><Label>Bot Token</Label><Input value={tgToken} onChange={e => setTgToken(e.target.value)} className="mt-1" /></div>
|
<div><Label>Bot Token</Label><Input value={tgToken} onChange={e => setTgToken(e.target.value)} className="mt-1" /></div>
|
||||||
<div><Label>Chat ID</Label><Input value={tgChatId} onChange={e => setTgChatId(e.target.value)} className="mt-1" /></div>
|
<div><Label>Chat ID</Label><Input value={tgChatId} onChange={e => setTgChatId(e.target.value)} className="mt-1" /></div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button size="sm" onClick={() => updateTg.mutate({ telegram_bot_token: tgToken })}>{t('common.save')}</Button>
|
<Button size="sm" onClick={() => updateTg.mutate({ telegram_bot_token: tgToken }, {
|
||||||
|
onSuccess: (r) => toast.success(r.message),
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
})}>{t('common.save')}</Button>
|
||||||
<Button size="sm" variant="outline" onClick={handleTestTg} disabled={testTg.isPending}>{t('settings.send_test')}</Button>
|
<Button size="sm" variant="outline" onClick={handleTestTg} disabled={testTg.isPending}>{t('settings.send_test')}</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { getTasksWebSocket } from '@/lib/websocket';
|
import { getTasksWebSocket } from '@/lib/websocket';
|
||||||
import type { VMInstance, Snapshot, VMConfig } from '@/types';
|
import type { VMInstance, Snapshot, VMConfig, DiskInfo } from '@/types';
|
||||||
|
|
||||||
// ==================== Query Keys ====================
|
// ==================== Query Keys ====================
|
||||||
export const vmKeys = {
|
export const vmKeys = {
|
||||||
@@ -68,9 +68,12 @@ export function useVMStatusSync() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type LiveInstanceMetrics = {
|
export type LiveInstanceMetrics = {
|
||||||
server_id: number; vmid: number; node?: string; status?: string;
|
server_id: number; vmid: number; node?: string; status?: string; type?: string;
|
||||||
cpu?: number; mem?: number; maxmem?: number; disk?: number; maxdisk?: number;
|
cpu?: number; mem?: number; maxmem?: number; disk?: number; maxdisk?: number;
|
||||||
uptime?: number; netin?: number; netout?: number;
|
uptime?: number; netin?: number; netout?: number;
|
||||||
|
diskread?: number; diskwrite?: number;
|
||||||
|
diskread_rate?: number; diskwrite_rate?: number;
|
||||||
|
netin_rate?: number; netout_rate?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -120,6 +123,14 @@ interface VMStatusResponse {
|
|||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VMLiveStatus extends VMStatusResponse {
|
||||||
|
diskread_rate?: number;
|
||||||
|
diskwrite_rate?: number;
|
||||||
|
netin_rate?: number;
|
||||||
|
netout_rate?: number;
|
||||||
|
disks?: DiskInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
export function useVMStatus(serverId: number, vmid: number, type: string, node: string, enabled = true) {
|
export function useVMStatus(serverId: number, vmid: number, type: string, node: string, enabled = true) {
|
||||||
const prefix = type === 'lxc' ? 'container' : 'vm';
|
const prefix = type === 'lxc' ? 'container' : 'vm';
|
||||||
return useQuery<VMStatusResponse>({
|
return useQuery<VMStatusResponse>({
|
||||||
@@ -130,6 +141,34 @@ export function useVMStatus(serverId: number, vmid: number, type: string, node:
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes to the live per-instance WebSocket channel and returns real-time
|
||||||
|
* VM/LXC metrics including I/O rates and per-disk info.
|
||||||
|
* Falls back to the HTTP-polled status until the first WS message arrives.
|
||||||
|
*/
|
||||||
|
export function useVMLiveMetrics(
|
||||||
|
serverId: number,
|
||||||
|
vmid: number,
|
||||||
|
type: string,
|
||||||
|
node: string,
|
||||||
|
): VMLiveStatus | null {
|
||||||
|
const [live, setLive] = useState<VMLiveStatus | null>(null);
|
||||||
|
const { data: http } = useVMStatus(serverId, vmid, type, node);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const ws = getTasksWebSocket();
|
||||||
|
ws.connect();
|
||||||
|
const channel = `instance_metrics:${serverId}_${vmid}_${type}_${node}`;
|
||||||
|
const unsub = ws.subscribe(channel, (msg: unknown) => {
|
||||||
|
const payload = msg as { data?: VMLiveStatus };
|
||||||
|
if (payload?.data) setLive(payload.data);
|
||||||
|
});
|
||||||
|
return () => unsub();
|
||||||
|
}, [serverId, vmid, type, node]);
|
||||||
|
|
||||||
|
return live ?? (http as VMLiveStatus) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
export function useVMConfig(serverId: number, vmid: number, type: string, node: string, enabled = true) {
|
export function useVMConfig(serverId: number, vmid: number, type: string, node: string, enabled = true) {
|
||||||
const prefix = type === 'lxc' ? 'container' : 'vm';
|
const prefix = type === 'lxc' ? 'container' : 'vm';
|
||||||
return useQuery<VMConfig>({
|
return useQuery<VMConfig>({
|
||||||
|
|||||||
@@ -90,18 +90,31 @@ export function useUpdatePanelSettings() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SecuritySettings {
|
||||||
|
max_login_attempts: number;
|
||||||
|
lockout_duration_minutes: number;
|
||||||
|
session_timeout_minutes: number;
|
||||||
|
ip_block_threshold: number;
|
||||||
|
ip_block_duration_minutes: number;
|
||||||
|
password_min_length: number;
|
||||||
|
password_require_uppercase: boolean;
|
||||||
|
password_require_lowercase: boolean;
|
||||||
|
password_require_numbers: boolean;
|
||||||
|
password_require_special: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export function useSecuritySettings() {
|
export function useSecuritySettings() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: settingsKeys.security,
|
queryKey: settingsKeys.security,
|
||||||
queryFn: () => apiClient.get<{ single_session_enabled: boolean; max_login_attempts?: number; lockout_duration?: number; session_timeout?: number }>('/settings/api/security-settings'),
|
queryFn: () => apiClient.get<SecuritySettings>('/admin/api/security/settings'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUpdateSecuritySettings() {
|
export function useUpdateSecuritySettings() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data: { single_session_enabled?: boolean; max_login_attempts?: number; lockout_duration?: number; session_timeout?: number }) =>
|
mutationFn: (data: Partial<SecuritySettings>) =>
|
||||||
apiClient.put<{ message: string }>('/settings/api/security-settings', data),
|
apiClient.put<{ message: string }>('/admin/api/security/settings', data),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: settingsKeys.security }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: settingsKeys.security }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,6 +127,15 @@
|
|||||||
"primary_ip": "Primary IP",
|
"primary_ip": "Primary IP",
|
||||||
"network_in": "Network In",
|
"network_in": "Network In",
|
||||||
"network_out": "Network Out",
|
"network_out": "Network Out",
|
||||||
|
"network_io": "Network I/O",
|
||||||
|
"storage": "Storage",
|
||||||
|
"disk_read_rate": "Disk Read",
|
||||||
|
"disk_write_rate": "Disk Write",
|
||||||
|
"net_in_rate": "Net In",
|
||||||
|
"net_out_rate": "Net Out",
|
||||||
|
"io_rates": "I/O Rates",
|
||||||
|
"guest_agent_required": "Install qemu-guest-agent for disk usage details",
|
||||||
|
"used_of": "used of",
|
||||||
"rollback": "Rollback",
|
"rollback": "Rollback",
|
||||||
"description": "Description",
|
"description": "Description",
|
||||||
"os_type": "OS Type",
|
"os_type": "OS Type",
|
||||||
@@ -671,6 +680,8 @@
|
|||||||
"lockout_duration": "Lockout Duration",
|
"lockout_duration": "Lockout Duration",
|
||||||
"session_timeout": "Session Timeout",
|
"session_timeout": "Session Timeout",
|
||||||
"seconds": "seconds",
|
"seconds": "seconds",
|
||||||
|
"minutes": "minutes",
|
||||||
|
"security_saved": "Security settings saved",
|
||||||
"version": "Version",
|
"version": "Version",
|
||||||
"check_updates": "Check for Updates",
|
"check_updates": "Check for Updates",
|
||||||
"update_available": "Update available",
|
"update_available": "Update available",
|
||||||
|
|||||||
@@ -127,6 +127,15 @@
|
|||||||
"primary_ip": "Основной IP",
|
"primary_ip": "Основной IP",
|
||||||
"network_in": "Входящий трафик",
|
"network_in": "Входящий трафик",
|
||||||
"network_out": "Исходящий трафик",
|
"network_out": "Исходящий трафик",
|
||||||
|
"network_io": "Сеть I/O",
|
||||||
|
"storage": "Хранилище",
|
||||||
|
"disk_read_rate": "Чтение диска",
|
||||||
|
"disk_write_rate": "Запись диска",
|
||||||
|
"net_in_rate": "Вх. трафик",
|
||||||
|
"net_out_rate": "Исх. трафик",
|
||||||
|
"io_rates": "Скорость I/O",
|
||||||
|
"guest_agent_required": "Установите qemu-guest-agent для данных об использовании диска",
|
||||||
|
"used_of": "из",
|
||||||
"rollback": "Откатить",
|
"rollback": "Откатить",
|
||||||
"description": "Описание",
|
"description": "Описание",
|
||||||
"os_type": "Тип ОС",
|
"os_type": "Тип ОС",
|
||||||
@@ -671,6 +680,8 @@
|
|||||||
"lockout_duration": "Время блокировки",
|
"lockout_duration": "Время блокировки",
|
||||||
"session_timeout": "Таймаут сессии",
|
"session_timeout": "Таймаут сессии",
|
||||||
"seconds": "секунд",
|
"seconds": "секунд",
|
||||||
|
"minutes": "минут",
|
||||||
|
"security_saved": "Настройки безопасности сохранены",
|
||||||
"version": "Версия",
|
"version": "Версия",
|
||||||
"check_updates": "Проверить обновления",
|
"check_updates": "Проверить обновления",
|
||||||
"update_available": "Доступно обновление",
|
"update_available": "Доступно обновление",
|
||||||
|
|||||||
@@ -63,6 +63,13 @@ export interface ProxmoxServerCreate {
|
|||||||
|
|
||||||
// ==================== VM / Container Types ====================
|
// ==================== VM / Container Types ====================
|
||||||
|
|
||||||
|
export interface DiskInfo {
|
||||||
|
name: string;
|
||||||
|
mountpoint: string;
|
||||||
|
used: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
export type VMType = 'qemu' | 'lxc';
|
export type VMType = 'qemu' | 'lxc';
|
||||||
export type VMStatus = 'running' | 'stopped' | 'paused' | 'suspended' | 'unknown' | 'creating';
|
export type VMStatus = 'running' | 'stopped' | 'paused' | 'suspended' | 'unknown' | 'creating';
|
||||||
|
|
||||||
@@ -82,6 +89,12 @@ export interface VMInstance {
|
|||||||
uptime?: number;
|
uptime?: number;
|
||||||
netin?: number;
|
netin?: number;
|
||||||
netout?: number;
|
netout?: number;
|
||||||
|
diskread?: number;
|
||||||
|
diskwrite?: number;
|
||||||
|
diskread_rate?: number;
|
||||||
|
diskwrite_rate?: number;
|
||||||
|
netin_rate?: number;
|
||||||
|
netout_rate?: number;
|
||||||
ip_address?: string;
|
ip_address?: string;
|
||||||
tags?: string;
|
tags?: string;
|
||||||
template?: boolean;
|
template?: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user