Initial commit: Veeam multi-instance monitoring dashboard

- FastAPI backend with CRUD for Veeam instances
- Veeam REST API client supporting v9.5-v13 (legacy + OAuth2)
- Multi-version detection and auth fallback
- SQLite caching layer
- Web dashboard with summary cards, instance list, detail tabs
- Docker packaging
This commit is contained in:
2026-06-09 17:10:35 +07:00
parent b48c200571
commit ba8a413e85
10 changed files with 1783 additions and 0 deletions

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
__pycache__
*.pyc
.env
.git
veeam_dashboard.db

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
__pycache__/
*.pyc
.venv/
venv/
*.db
.env
.DS_Store
*.tar.gz

18
Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN python -c "from main import app; print('App loaded')" && \
rm -rf __pycache__ */__pycache__
EXPOSE 8080
ENV DB_URL=sqlite+aiosqlite:////data/veeam_dashboard.db
ENV PYTHONUNBUFFERED=1
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]

20
config.py Normal file
View File

@@ -0,0 +1,20 @@
from pydantic_settings import BaseSettings
from pathlib import Path
class Settings(BaseSettings):
app_name: str = "Veeam Dashboard"
db_url: str = "sqlite+aiosqlite:///./veeam_dashboard.db"
cache_ttl: int = 60
request_timeout: int = 30
host: str = "0.0.0.0"
port: int = 8080
secret_key: str = "change-me-in-production"
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
settings = Settings()
BASE_DIR = Path(__file__).parent

133
database.py Normal file
View File

@@ -0,0 +1,133 @@
from datetime import datetime
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy import String, Integer, Boolean, DateTime, Text, select, delete as sa_delete
from config import settings
import json
engine = create_async_engine(settings.db_url, echo=False)
async_session = async_sessionmaker(engine, expire_on_commit=False)
class Base(DeclarativeBase):
pass
class Instance(Base):
__tablename__ = "instances"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
host: Mapped[str] = mapped_column(String(255), nullable=False)
port: Mapped[int] = mapped_column(Integer, nullable=False)
username: Mapped[str] = mapped_column(String(255), nullable=False)
password: Mapped[str] = mapped_column(String(255), nullable=False)
version: Mapped[str] = mapped_column(String(32), default="auto")
notes: Mapped[str] = mapped_column(Text, default="")
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class CacheEntry(Base):
__tablename__ = "cache"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
instance_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
key: Mapped[str] = mapped_column(String(64), nullable=False)
data: Mapped[str] = mapped_column(Text, nullable=False)
fetched_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
async def init_db():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def get_instances() -> list[Instance]:
async with async_session() as session:
result = await session.execute(select(Instance).order_by(Instance.name))
return list(result.scalars().all())
async def get_instance(instance_id: int) -> Instance | None:
async with async_session() as session:
result = await session.execute(select(Instance).where(Instance.id == instance_id))
return result.scalar_one_or_none()
async def add_instance(name: str, host: str, port: int, username: str, password: str,
version: str = "auto", notes: str = "") -> Instance:
async with async_session() as session:
inst = Instance(
name=name, host=host, port=port, username=username,
password=password, version=version, notes=notes
)
session.add(inst)
await session.commit()
await session.refresh(inst)
return inst
async def update_instance(instance_id: int, **kwargs) -> Instance | None:
async with async_session() as session:
result = await session.execute(select(Instance).where(Instance.id == instance_id))
inst = result.scalar_one_or_none()
if not inst:
return None
for key, value in kwargs.items():
if hasattr(inst, key):
setattr(inst, key, value)
inst.updated_at = datetime.utcnow()
await session.commit()
await session.refresh(inst)
return inst
async def delete_instance(instance_id: int) -> bool:
async with async_session() as session:
result = await session.execute(select(Instance).where(Instance.id == instance_id))
inst = result.scalar_one_or_none()
if not inst:
return False
await session.delete(inst)
await session.execute(sa_delete(CacheEntry).where(CacheEntry.instance_id == instance_id))
await session.commit()
return True
async def set_cache(instance_id: int, key: str, data: dict):
async with async_session() as session:
result = await session.execute(
select(CacheEntry).where(
CacheEntry.instance_id == instance_id,
CacheEntry.key == key
)
)
entry = result.scalar_one_or_none()
if entry:
entry.data = json.dumps(data)
entry.fetched_at = datetime.utcnow()
else:
entry = CacheEntry(
instance_id=instance_id,
key=key,
data=json.dumps(data)
)
session.add(entry)
await session.commit()
async def get_cache(instance_id: int, key: str) -> dict | None:
async with async_session() as session:
result = await session.execute(
select(CacheEntry).where(
CacheEntry.instance_id == instance_id,
CacheEntry.key == key
)
)
entry = result.scalar_one_or_none()
if entry:
return {"data": json.loads(entry.data), "fetched_at": entry.fetched_at.isoformat()}
return None

17
docker-compose.yml Normal file
View File

@@ -0,0 +1,17 @@
version: "3.8"
services:
veeam-dashboard:
build: .
container_name: veeam-dashboard
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- data:/data
environment:
- DB_URL=sqlite+aiosqlite:////data/veeam_dashboard.db
- CACHE_TTL=60
volumes:
data:

445
main.py Normal file
View File

@@ -0,0 +1,445 @@
import asyncio
import json
import time
from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from config import settings, BASE_DIR
from database import (
init_db, get_instances, get_instance, add_instance,
update_instance, delete_instance, set_cache, get_cache
)
from veeam_client import VeeamClient, port_for_version, detect_version_from_host
_cache: dict[str, tuple[float, dict]] = {}
def _cached(key: str, ttl: int = None) -> dict | None:
ttl = ttl or settings.cache_ttl
entry = _cache.get(key)
if entry and (time.time() - entry[0]) < ttl:
return entry[1]
return None
def _set_cache_inmem(key: str, data: dict):
_cache[key] = (time.time(), data)
# ─── Pydantic models ───────────────────────────────────────────────
class InstanceCreate(BaseModel):
name: str
host: str
port: int | None = None
username: str
password: str
version: str = "auto"
notes: str = ""
class InstanceUpdate(BaseModel):
name: str | None = None
host: str | None = None
port: int | None = None
username: str | None = None
password: str | None = None
version: str | None = None
is_active: bool | None = None
notes: str | None = None
class InstanceResponse(BaseModel):
id: int
name: str
host: str
port: int
username: str
version: str
notes: str
is_active: bool
created_at: str
updated_at: str
class DashboardResponse(BaseModel):
instances: list
summary: dict
updated_at: str
# ─── App lifecycle ─────────────────────────────────────────────────
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_db()
yield
app = FastAPI(title=settings.app_name, version="1.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ─── Helpers ───────────────────────────────────────────────────────
def _serialize_instance(inst) -> dict:
return {
"id": inst.id,
"name": inst.name,
"host": inst.host,
"port": inst.port,
"username": inst.username,
"version": inst.version,
"notes": inst.notes,
"is_active": inst.is_active,
"created_at": inst.created_at.isoformat() if inst.created_at else "",
"updated_at": inst.updated_at.isoformat() if inst.updated_at else "",
}
def _safe_str(v) -> str:
if v is None:
return ""
if isinstance(v, dict):
return str(v.get("result") or v.get("Result") or "unknown")
return str(v)
async def _collect_instance(inst) -> dict | None:
port = inst.port or port_for_version(inst.version)
ver = inst.version
if ver == "auto":
ver = detect_version_from_host(inst.host)
port = port_for_version(ver)
client = VeeamClient(inst.host, port, inst.username, inst.password, ver)
try:
role = await client.connect()
jobs_data = []
sessions_data = []
repos_data = []
repo_states = []
alarms_data = []
agents_data = []
errors = []
try:
jobs_data = await client.fetch_jobs()
except Exception as e:
errors.append(f"jobs: {e}")
try:
sessions_data = await client.fetch_sessions(100)
except Exception as e:
errors.append(f"sessions: {e}")
try:
repos_data = await client.fetch_repositories()
except Exception as e:
errors.append(f"repos: {e}")
try:
repo_states = await client.fetch_repository_states()
except Exception as e:
errors.append(f"repo states: {e}")
try:
alarms_data = await client.fetch_alarms()
except Exception as e:
pass
try:
agents_data = await client.fetch_agents()
except Exception as e:
errors.append(f"agents: {e}")
for s in sessions_data:
if isinstance(s.get("result"), dict):
s["result"] = s["result"].get("result") or s["result"].get("Result") or "unknown"
if "jobName" in s and "job_name" not in s:
s["job_name"] = s["jobName"]
if "creationTime" in s and "creation_time" not in s:
s["creation_time"] = s["creationTime"]
if "endTime" in s and "end_time" not in s:
s["end_time"] = s["endTime"]
job_last_backup = {}
for s in sessions_data:
s_name = s.get("jobName") or s.get("name") or s.get("Name") or ""
s_end = s.get("endTime") or s.get("end_time") or s.get("creationTime") or ""
if s_name and s_end:
if s_name not in job_last_backup or s_end > job_last_backup[s_name]:
job_last_backup[s_name] = s_end
for j in jobs_data:
if isinstance(j.get("result"), dict):
j["result"] = j["result"].get("result") or j["result"].get("Result") or "unknown"
if not j.get("result") and j.get("lastResult"):
j["result"] = j["lastResult"]
if not j.get("state") and j.get("status"):
j["state"] = j["status"]
if "scheduleEnabled" in j and "schedule_enabled" not in j:
j["schedule_enabled"] = j["scheduleEnabled"]
if "restorePointsToKeep" in j and "restore_points_to_keep" not in j:
j["restore_points_to_keep"] = j["restorePointsToKeep"]
if "nextRun" in j and "next_run" not in j:
j["next_run"] = j["nextRun"]
j_name = j.get("name") or j.get("Name") or ""
if j_name in job_last_backup:
j["last_backup"] = job_last_backup[j_name]
job_count = len(jobs_data)
success = sum(1 for j in jobs_data if _safe_str(j.get("result") or "").lower() in ("success", "true"))
failed = sum(1 for j in jobs_data if _safe_str(j.get("result") or "").lower() in ("failed", "error", "false"))
running = sum(1 for j in jobs_data if _safe_str(j.get("state") or "").lower() == "running")
warning = sum(1 for j in jobs_data if "warning" in _safe_str(j.get("result") or "").lower())
disabled = sum(1 for j in jobs_data if j.get("schedule_enabled") is False or _safe_str(j.get("state") or "").lower() == "disabled")
last_sessions = sessions_data[:20] if sessions_data else []
result = {
"id": inst.id,
"name": inst.name,
"host": inst.host,
"port": port,
"version": client.version,
"is_online": True,
"user_role": role,
"errors": errors,
"jobs": jobs_data,
"jobs_summary": {
"total": job_count,
"success": success,
"failed": failed,
"running": running,
"warning": warning,
"disabled": disabled,
},
"disabled_policies": disabled,
"recent_sessions": last_sessions,
"repositories": repos_data,
"repository_states": repo_states,
"alarms": alarms_data,
"agents": agents_data,
}
await set_cache(inst.id, "dashboard", result)
return result
except Exception as e:
error_result = {
"id": inst.id,
"name": inst.name,
"host": inst.host,
"port": port,
"version": ver,
"is_online": False,
"error": str(e),
"jobs": [],
"jobs_summary": {"total": 0, "success": 0, "failed": 0, "running": 0, "warning": 0, "disabled": 0},
"disabled_policies": 0,
"recent_sessions": [],
"repositories": [],
"repository_states": [],
"alarms": [],
"agents": [],
}
return error_result
finally:
await client.close()
async def _collect_all() -> list[dict]:
instances = await get_instances()
tasks = [_collect_instance(inst) for inst in instances if inst.is_active]
if not tasks:
return []
results = await asyncio.gather(*tasks, return_exceptions=True)
output = []
for r in results:
if isinstance(r, Exception):
continue
if r:
output.append(r)
return output
def _build_summary(instance_data: list[dict]) -> dict:
total_instances = len(instance_data)
online = sum(1 for i in instance_data if i.get("is_online"))
total_jobs = sum(i.get("jobs_summary", {}).get("total", 0) for i in instance_data)
total_success = sum(i.get("jobs_summary", {}).get("success", 0) for i in instance_data)
total_failed = sum(i.get("jobs_summary", {}).get("failed", 0) for i in instance_data)
total_running = sum(i.get("jobs_summary", {}).get("running", 0) for i in instance_data)
total_warning = sum(i.get("jobs_summary", {}).get("warning", 0) for i in instance_data)
total_alarms = sum(len(i.get("alarms", [])) for i in instance_data)
total_repos = sum(len(i.get("repositories", [])) for i in instance_data)
return {
"total_instances": total_instances,
"online_instances": online,
"offline_instances": total_instances - online,
"total_jobs": total_jobs,
"successful_jobs": total_success,
"failed_jobs": total_failed,
"running_jobs": total_running,
"warning_jobs": total_warning,
"total_alarms": total_alarms,
"total_repositories": total_repos,
}
# ─── API Endpoints ─────────────────────────────────────────────────
@app.get("/api/health")
async def health():
return {"status": "ok"}
# Instance CRUD
@app.get("/api/instances", response_model=list[InstanceResponse])
async def list_instances():
instances = await get_instances()
return [_serialize_instance(i) for i in instances]
@app.post("/api/instances", response_model=InstanceResponse, status_code=201)
async def create_instance(data: InstanceCreate):
port = data.port or port_for_version(data.version)
inst = await add_instance(
name=data.name,
host=data.host,
port=port,
username=data.username,
password=data.password,
version=data.version,
notes=data.notes,
)
return _serialize_instance(inst)
@app.get("/api/instances/{instance_id}", response_model=InstanceResponse)
async def read_instance(instance_id: int):
inst = await get_instance(instance_id)
if not inst:
raise HTTPException(404, "Instance not found")
return _serialize_instance(inst)
@app.put("/api/instances/{instance_id}", response_model=InstanceResponse)
async def edit_instance(instance_id: int, data: InstanceUpdate):
updates = data.model_dump(exclude_unset=True)
if not updates:
raise HTTPException(400, "No fields to update")
inst = await update_instance(instance_id, **updates)
if not inst:
raise HTTPException(404, "Instance not found")
return _serialize_instance(inst)
@app.delete("/api/instances/{instance_id}")
async def remove_instance(instance_id: int):
ok = await delete_instance(instance_id)
if not ok:
raise HTTPException(404, "Instance not found")
_cache.pop(f"instance_{instance_id}", None)
return {"ok": True}
# Test connection — MUST be before {instance_id} routes to avoid path conflicts
@app.post("/api/test-connection")
async def test_connection_raw(data: InstanceCreate):
port = data.port or port_for_version(data.version)
ver = data.version
if ver == "auto":
ver = detect_version_from_host(data.host)
port = port_for_version(ver)
client = VeeamClient(data.host, port, data.username, data.password, ver)
try:
role = await client.connect()
return {"ok": True, "role": role, "version": client.version}
except Exception as e:
return JSONResponse(status_code=502, content={"ok": False, "error": str(e)})
finally:
await client.close()
@app.post("/api/instances/{instance_id}/test")
async def test_instance(instance_id: int):
inst = await get_instance(instance_id)
if not inst:
raise HTTPException(404, "Instance not found")
result = await _collect_instance(inst)
if result and result.get("is_online"):
return {"ok": True, "role": result.get("user_role"), "version": result.get("version")}
return JSONResponse(
status_code=502,
content={"ok": False, "error": result.get("error", "Unknown error") if result else "No response"}
)
# Dashboard
@app.get("/api/dashboard")
async def dashboard(force_refresh: bool = False):
if not force_refresh:
cached = _cached("dashboard_aggregate")
if cached:
return cached
instance_data = await _collect_all()
summary = _build_summary(instance_data)
result = {
"instances": instance_data,
"summary": summary,
"updated_at": datetime.utcnow().isoformat(),
}
_set_cache_inmem("dashboard_aggregate", result)
return result
# Per-instance data
@app.get("/api/instances/{instance_id}/data")
async def instance_data(instance_id: int, force_refresh: bool = False):
inst = await get_instance(instance_id)
if not inst:
raise HTTPException(404, "Instance not found")
if not force_refresh:
cached = _cached(f"instance_{instance_id}")
if cached:
return cached
result = await _collect_instance(inst)
_set_cache_inmem(f"instance_{instance_id}", result)
if not result.get("is_online"):
return JSONResponse(status_code=502, content=result)
return result
# Serve frontend
@app.get("/")
async def index():
return FileResponse(str(BASE_DIR / "static" / "index.html"))
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host=settings.host, port=settings.port, reload=True)

9
requirements.txt Normal file
View File

@@ -0,0 +1,9 @@
fastapi>=0.110.0
uvicorn[standard]>=0.29.0
httpx>=0.27.0
sqlalchemy[asyncio]>=2.0.30
greenlet>=3.0.0
aiosqlite>=0.20.0
pydantic>=2.7.0
pydantic-settings>=2.2.0
pycryptodome>=3.20.0

769
static/index.html Normal file
View File

@@ -0,0 +1,769 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Veeam Dashboard</title>
<link rel="icon" href="data:,">
<style>
:root {
--bg: #0f1117;
--surface: #1a1d27;
--surface-2: #232736;
--border: #2a2e3d;
--text: #e1e4ed;
--text-muted: #8b90a0;
--green: #22c55e;
--red: #ef4444;
--yellow: #eab308;
--blue: #3b82f6;
--cyan: #06b6d4;
--accent: #6366f1;
--radius: 10px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: 24px;
}
.container { max-width: 1400px; margin: 0 auto; }
/* header */
.header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 28px; flex-wrap: wrap; gap: 12px;
}
.header h1 {
font-size: 24px; font-weight: 700;
background: linear-gradient(135deg, var(--accent), var(--cyan));
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
}
.header-actions { display: flex; gap: 10px; align-items: center; }
/* buttons */
.btn {
padding: 8px 18px; border-radius: 8px; border: 1px solid var(--border);
background: var(--surface); color: var(--text); cursor: pointer;
font-size: 14px; transition: all .15s; white-space: nowrap;
}
.btn:hover { background: var(--surface-2); border-color: var(--accent); }
.btn-primary {
background: var(--accent); border-color: var(--accent); color: #fff;
}
.btn-primary:hover { filter: brightness(1.15); }
.btn-danger { border-color: var(--red); color: var(--red); }
.btn-danger:hover { background: var(--red); color: #fff; }
.btn-sm { padding: 5px 12px; font-size: 12px; }
.btn-icon { padding: 6px 10px; line-height: 1; }
/* summary cards */
.summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px; margin-bottom: 28px;
}
.summary-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 16px; text-align: center;
}
.summary-card .value {
font-size: 32px; font-weight: 700; line-height: 1.2;
}
.summary-card .label {
font-size: 12px; color: var(--text-muted); margin-top: 4px;
text-transform: uppercase; letter-spacing: .5px;
}
.summary-card.green .value { color: var(--green); }
.summary-card.red .value { color: var(--red); }
.summary-card.yellow .value { color: var(--yellow); }
.summary-card.blue .value { color: var(--blue); }
.summary-card.cyan .value { color: var(--cyan); }
/* instance cards */
.instances { display: flex; flex-direction: column; gap: 10px; }
.instance-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); overflow: hidden;
transition: border-color .15s;
}
.instance-card:hover { border-color: var(--accent); }
.instance-card.offline { opacity: .7; }
.instance-card.offline .instance-header { border-left: 3px solid var(--red); }
.instance-header {
display: flex; align-items: center; gap: 14px;
padding: 14px 18px; cursor: pointer;
border-left: 3px solid var(--green);
transition: background .15s;
}
.instance-header:hover { background: var(--surface-2); }
.instance-header .status-dot {
width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;
}
.status-dot.online { background: var(--green); box-shadow: 0 0 6px var(--green); }
.status-dot.offline { background: var(--red); box-shadow: 0 0 6px var(--red); }
.instance-header .info { flex: 1; min-width: 0; }
.instance-header .name {
font-weight: 600; font-size: 15px; white-space: nowrap; overflow: hidden;
text-overflow: ellipsis;
}
.instance-header .meta {
font-size: 12px; color: var(--text-muted); margin-top: 2px;
}
.instance-header .badge {
padding: 2px 10px; border-radius: 20px; font-size: 11px; font-weight: 600;
white-space: nowrap;
}
.badge.success { background: rgba(34,197,94,.15); color: var(--green); }
.badge.warning { background: rgba(234,179,8,.15); color: var(--yellow); }
.badge.danger { background: rgba(239,68,68,.15); color: var(--red); }
.badge.info { background: rgba(59,130,246,.15); color: var(--blue); }
.badge.neutral { background: rgba(139,144,160,.15); color: var(--text-muted); }
.instance-header .stats {
display: flex; gap: 16px; align-items: center;
}
.instance-header .stat-item {
text-align: center; font-size: 11px; color: var(--text-muted);
}
.instance-header .stat-item .stat-value {
font-size: 16px; font-weight: 600; color: var(--text);
}
/* instance detail */
.instance-detail {
border-top: 1px solid var(--border); padding: 18px;
display: none;
}
.instance-detail.open { display: block; }
.detail-tabs {
display: flex; gap: 4px; margin-bottom: 14px;
border-bottom: 1px solid var(--border); padding-bottom: 0;
}
.detail-tab {
padding: 8px 16px; border: none; background: none; color: var(--text-muted);
cursor: pointer; font-size: 13px; border-bottom: 2px solid transparent;
transition: all .15s;
}
.detail-tab:hover { color: var(--text); }
.detail-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.tab-content { display: none; }
.tab-content.active { display: block; }
/* tables */
.table-wrap { overflow-x: auto; }
table {
width: 100%; border-collapse: collapse; font-size: 13px;
}
th {
text-align: left; padding: 8px 12px; font-weight: 600;
color: var(--text-muted); font-size: 11px; text-transform: uppercase;
letter-spacing: .5px; border-bottom: 1px solid var(--border);
}
td {
padding: 8px 12px; border-bottom: 1px solid var(--border);
}
tr:hover td { background: var(--surface-2); }
.job-result { font-weight: 600; }
.job-result.success { color: var(--green); }
.job-result.failed, .job-result.error { color: var(--red); }
.job-result.warning { color: var(--yellow); }
.job-result.running { color: var(--blue); }
.job-disabled td { opacity: .45; }
.job-disabled td:last-child { opacity: 1; }
.jobs-summary { margin-bottom: 10px; font-size: 13px; display: flex; gap: 16px; align-items: center; }
/* modal */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,.6);
display: flex; align-items: center; justify-content: center;
z-index: 100; padding: 20px;
opacity: 0; pointer-events: none; transition: opacity .2s;
}
.modal-overlay.open { opacity: 1; pointer-events: auto; }
.modal {
background: var(--surface); border: 1px solid var(--border);
border-radius: 12px; width: 100%; max-width: 520px;
max-height: 90vh; overflow-y: auto; padding: 24px;
}
.modal h2 { font-size: 18px; margin-bottom: 20px; }
.form-group { margin-bottom: 14px; }
.form-group label {
display: block; font-size: 12px; color: var(--text-muted);
margin-bottom: 4px; text-transform: uppercase; letter-spacing: .5px;
}
.form-group input, .form-group select, .form-group textarea {
width: 100%; padding: 9px 12px; border-radius: 8px;
border: 1px solid var(--border); background: var(--bg);
color: var(--text); font-size: 14px;
}
.form-group input:focus, .form-group select:focus {
outline: none; border-color: var(--accent);
}
.form-row { display: flex; gap: 12px; }
.form-row .form-group { flex: 1; }
.modal-actions {
display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px;
}
/* status bar */
.status-bar {
display: flex; justify-content: space-between; align-items: center;
padding: 10px 0; font-size: 12px; color: var(--text-muted);
margin-top: 20px; border-top: 1px solid var(--border);
}
.status-bar .spinner {
display: inline-block; width: 14px; height: 14px;
border: 2px solid var(--border); border-top-color: var(--accent);
border-radius: 50%; animation: spin .8s linear infinite;
margin-right: 8px; vertical-align: middle;
}
@keyframes spin { to { transform: rotate(360deg); } }
.error-banner {
background: rgba(239,68,68,.1); border: 1px solid var(--red);
border-radius: var(--radius); padding: 12px 16px; margin-bottom: 14px;
font-size: 13px; color: var(--red);
}
.empty-state {
text-align: center; padding: 40px 20px; color: var(--text-muted);
}
.empty-state p { margin-bottom: 12px; }
.version-tag {
display: inline-block; padding: 1px 8px; border-radius: 4px;
background: var(--surface-2); font-size: 11px; font-family: monospace;
color: var(--text-muted);
}
@media (max-width: 768px) {
body { padding: 12px; }
.header h1 { font-size: 18px; }
.summary { grid-template-columns: repeat(2, 1fr); }
.instance-header { flex-wrap: wrap; }
.instance-header .stats { width: 100%; justify-content: space-around; margin-top: 6px; }
.instance-header .badge { display: none; }
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Veeam Dashboard</h1>
<div class="header-actions">
<button class="btn btn-sm" onclick="refresh()">Обновить</button>
<button class="btn btn-primary btn-sm" onclick="openAddModal()">+ Инстанс</button>
</div>
</div>
<div id="errorContainer"></div>
<div class="summary" id="summaryCards">
<div class="summary-card"><div class="value"></div><div class="label">Инстансы</div></div>
<div class="summary-card"><div class="value"></div><div class="label">Задания</div></div>
<div class="summary-card green"><div class="value"></div><div class="label">Успешно</div></div>
<div class="summary-card red"><div class="value"></div><div class="label">Ошибки</div></div>
<div class="summary-card yellow"><div class="value"></div><div class="label">Warning</div></div>
<div class="summary-card blue"><div class="value"></div><div class="label">Running</div></div>
</div>
<div id="instanceList"><div class="empty-state"><p>Нет добавленных инстансов</p><button class="btn btn-primary btn-sm" onclick="openAddModal()">Добавить первый инстанс</button></div></div>
<div class="status-bar">
<span id="statusText">Загрузка...</span>
<button class="btn btn-sm" onclick="toggleAutoRefresh()" id="autoRefreshBtn">Автообновление: вкл</button>
</div>
</div>
<!-- Modal -->
<div class="modal-overlay" id="modal">
<div class="modal">
<h2 id="modalTitle">Добавить инстанс</h2>
<input type="hidden" id="editId" value="">
<div class="form-group">
<label>Название</label>
<input id="fName" placeholder="Например: DC-Office-Prod">
</div>
<div class="form-row">
<div class="form-group">
<label>Хост / IP</label>
<input id="fHost" placeholder="veeam.office.local">
</div>
<div class="form-group" style="max-width:100px">
<label>Порт</label>
<input id="fPort" placeholder="auto" type="number">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Пользователь</label>
<input id="fUser" placeholder="administrator">
</div>
<div class="form-group">
<label>Пароль</label>
<input id="fPass" type="password">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Версия Veeam</label>
<select id="fVersion">
<option value="auto">Автоопределение</option>
<option value="9.5">9.5</option>
<option value="10">10</option>
<option value="11">11</option>
<option value="12">12</option>
<option value="12.1">12.1</option>
<option value="12.2">12.2</option>
<option value="12.3">12.3</option>
</select>
</div>
<div class="form-group" style="max-width:140px">
<label>&nbsp;</label>
<button class="btn btn-sm" id="testBtn" onclick="testConnection()" style="width:100%">Test</button>
</div>
</div>
<div class="form-group">
<label>Заметки</label>
<input id="fNotes" placeholder="Опционально">
</div>
<div id="testResult" style="font-size:13px;margin-bottom:10px"></div>
<div class="modal-actions">
<button class="btn" onclick="closeModal()">Отмена</button>
<button class="btn btn-danger btn-sm" id="deleteBtn" onclick="deleteInstance()" style="display:none">Удалить</button>
<button class="btn btn-primary" id="saveBtn" onclick="saveInstance()">Сохранить</button>
</div>
</div>
</div>
<script>
// ─── State ────────────────────────────────────────────────────────
let instances = [];
let autoRefresh = true;
let refreshInterval = null;
let currentDetailInstance = null;
// ─── Init ─────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
loadDashboard();
startAutoRefresh();
});
function startAutoRefresh() {
if (refreshInterval) clearInterval(refreshInterval);
refreshInterval = setInterval(() => {
if (autoRefresh) loadDashboard(true);
}, 30000);
}
function toggleAutoRefresh() {
autoRefresh = !autoRefresh;
document.getElementById('autoRefreshBtn').textContent = autoRefresh
? 'Автообновление: вкл' : 'Автообновление: выкл';
}
function setStatus(text, loading = false) {
const el = document.getElementById('statusText');
el.innerHTML = loading
? '<span class="spinner"></span>' + text
: text;
}
// ─── Load ─────────────────────────────────────────────────────────
async function loadDashboard(silent = false) {
if (!silent) setStatus('Загрузка данных...', true);
try {
const res = await fetch('/api/dashboard?force_refresh=' + (silent ? 'false' : 'true'), {
signal: AbortSignal.timeout(60000)
});
if (!res.ok) throw new Error(res.statusText);
const data = await res.json();
instances = data.instances || [];
renderSummary(data.summary);
renderInstances();
setStatus('Обновлено: ' + new Date(data.updated_at).toLocaleString('ru-RU'));
} catch (e) {
if (!silent) {
showError('Ошибка загрузки: ' + e.message);
}
setStatus('Ошибка: ' + e.message);
}
}
async function refresh() {
setStatus('Обновление...', true);
await loadDashboard(false);
}
// ─── Render summary ──────────────────────────────────────────────
function renderSummary(s) {
if (!s) return;
const cards = document.getElementById('summaryCards');
cards.innerHTML = `
<div class="summary-card cyan"><div class="value">${s.total_instances}</div><div class="label">Инстансы (${s.online_instances} online)</div></div>
<div class="summary-card blue"><div class="value">${s.total_jobs}</div><div class="label">Всего заданий</div></div>
<div class="summary-card green"><div class="value">${s.successful_jobs}</div><div class="label">Успешно</div></div>
<div class="summary-card red"><div class="value">${s.failed_jobs}</div><div class="label">Ошибки</div></div>
<div class="summary-card yellow"><div class="value">${s.warning_jobs}</div><div class="label">Warning</div></div>
<div class="summary-card blue"><div class="value">${s.running_jobs}</div><div class="label">Running</div></div>
<div class="summary-card purple"><div class="value">${s.total_repositories}</div><div class="label">Репозитории</div></div>
`;
}
// ─── Render instances ────────────────────────────────────────────
function renderInstances() {
const container = document.getElementById('instanceList');
if (!instances.length) {
container.innerHTML = '<div class="empty-state"><p>Нет добавленных инстансов</p><button class="btn btn-primary btn-sm" onclick="openAddModal()">Добавить первый инстанс</button></div>';
return;
}
container.innerHTML = '<div class="instances">' + instances.map(i => renderInstanceCard(i)).join('') + '</div>';
}
function renderInstanceCard(inst) {
const online = inst.is_online;
const js = inst.jobs_summary || {};
let badge = js.total > 0
? (js.failed > 0 ? '<span class="badge danger">' + js.failed + ' errors</span>'
: js.warning > 0 ? '<span class="badge warning">' + js.warning + ' warnings</span>'
: js.running > 0 ? '<span class="badge info">' + js.running + ' running</span>'
: js.success > 0 ? '<span class="badge success">' + js.success + ' OK</span>'
: '<span class="badge neutral">0 jobs</span>')
: '<span class="badge neutral">offline</span>';
if (js.disabled > 0) {
badge += ' <span class="badge danger">' + js.disabled + ' disabled</span>';
}
const errors = inst.errors && inst.errors.length
? ' <span style="color:var(--red);font-size:11px">⚠ ' + inst.errors.join('; ') + '</span>'
: '';
return `
<div class="instance-card ${online ? '' : 'offline'}" id="card-${inst.id}">
<div class="instance-header" onclick="toggleDetail(${inst.id})">
<span class="status-dot ${online ? 'online' : 'offline'}"></span>
<div class="info">
<div class="name">${esc(inst.name)} <span class="version-tag">v${esc(inst.version)}</span></div>
<div class="meta">${esc(inst.host)}:${inst.port}${errors}</div>
</div>
<div class="stats">
<div class="stat-item"><div class="stat-value">${js.total || 0}</div>jobs</div>
<div class="stat-item"><div class="stat-value" style="color:var(--green)">${(js.total || 0) - (js.disabled || 0)}</div>on</div>
<div class="stat-item"><div class="stat-value" style="color:var(--red)">${js.disabled || 0}</div>off</div>
</div>
${badge}
<button class="btn btn-icon btn-sm" onclick="event.stopPropagation(); openEditModal(${inst.id})">⚙</button>
</div>
<div class="instance-detail" id="detail-${inst.id}">
<div class="detail-tabs">
<button class="detail-tab active" onclick="switchTab(${inst.id}, 'jobs', this)">Политики</button>
<button class="detail-tab" onclick="switchTab(${inst.id}, 'sessions', this)">Сессии</button>
<button class="detail-tab" onclick="switchTab(${inst.id}, 'storage', this)">Хранилище</button>
<button class="detail-tab" onclick="switchTab(${inst.id}, 'agents', this)">Агенты</button>
<button class="detail-tab" onclick="switchTab(${inst.id}, 'alarms', this)">Алерты</button>
</div>
<div class="tab-content active" id="tab-${inst.id}-jobs">${renderJobsTable(inst)}</div>
<div class="tab-content" id="tab-${inst.id}-sessions">${renderSessionsTable(inst)}</div>
<div class="tab-content" id="tab-${inst.id}-storage">${renderRepoStatesTable(inst)}</div>
<div class="tab-content" id="tab-${inst.id}-agents">${renderAgentsTable(inst)}</div>
<div class="tab-content" id="tab-${inst.id}-alarms">${renderAlarmsTable(inst)}</div>
</div>
</div>
`;
}
function renderJobsTable(inst) {
const jobs = inst.jobs || [];
const js = inst.jobs_summary || {};
if (!jobs.length) return '<div class="empty-state">Нет данных о политиках</div>';
const enabled = (js.total || 0) - (js.disabled || 0);
let header = '<div class="jobs-summary"><span>Всего: <strong>' + (js.total || 0) + '</strong></span> <span>Включено: <strong style="color:var(--green)">' + enabled + '</strong></span> <span>Выключено: <strong style="color:var(--red)">' + (js.disabled || 0) + '</strong></span>';
if (js.disabled > 0) header += ' <span class="badge danger">⚠ ВНИМАНИЕ! ЕСТЬ ВЫКЛЮЧЕННЫЕ ПОЛИТИКИ!</span>';
header += '</div>';
let rows = '';
for (const j of jobs) {
const isDisabled = j.schedule_enabled === false || (j.state || '').toLowerCase() === 'disabled';
const pts = j.restore_points_to_keep != null ? j.restore_points_to_keep : (j.restorePointsToKeep != null ? j.restorePointsToKeep : '—');
rows += '<tr class="' + (isDisabled ? 'job-disabled' : '') + '">';
rows += '<td>' + esc(j.name || j.Name || '—') + '</td>';
rows += '<td>' + esc(j.type || j.jobType || '—') + '</td>';
rows += '<td>' + pts + '</td>';
rows += '<td>' + formatDate(j.last_backup) + '</td>';
rows += '<td>' + (isDisabled ? '<span class="badge danger">выключена</span>' : '<span class="badge success">включена</span>') + '</td>';
rows += '</tr>';
}
return header + '<div class="table-wrap"><table><thead><tr><th>Имя</th><th>Тип</th><th>Точки восст.</th><th>Последний бэкап</th><th>Расписание</th></tr></thead><tbody>' + rows + '</tbody></table></div>';
}
function renderSessionsTable(inst) {
const sessions = inst.recent_sessions || [];
if (!sessions.length) return '<div class="empty-state">Нет сессий</div>';
return `<div class="table-wrap"><table>
<thead><tr><th>Задание</th><th>Результат</th><th>Состояние</th><th>Начало</th><th>Конец</th></tr></thead>
<tbody>${sessions.map(s => {
const rawResult = typeof s.result === 'object' ? s.result.result || s.result.Result || '' : (s.result || '');
const result = (rawResult || '').toLowerCase();
const cls = ['success','failed','error','warning'].find(c => result === c || (c === 'failed' && result === 'error')) || '';
return `<tr>
<td>${esc(s.job_name || '—')}</td>
<td class="job-result ${cls}">${esc(rawResult || '—')}</td>
<td>${esc(s.state || '—')}</td>
<td>${formatDate(s.creation_time)}</td>
<td>${formatDate(s.end_time)}</td>
</tr>`;
}).join('')}</tbody>
</table></div>`;
}
function renderReposTable(inst) {
const repos = inst.repositories || [];
if (!repos.length) return '<div class="empty-state">Нет репозиториев</div>';
return `<div class="table-wrap"><table>
<thead><tr><th>Имя</th><th>Тип</th><th>Host</th></tr></thead>
<tbody>${repos.map(r => `<tr>
<td>${esc(r.name || r.Name || '—')}</td>
<td>${esc(r.type || r.Type || '—')}</td>
<td>${esc(r.host || r.Host || r.hostName || '—')}</td>
</tr>`).join('')}</tbody>
</table></div>`;
}
function renderAlarmsTable(inst) {
const alarms = inst.alarms || [];
if (!alarms.length) return '<div class="empty-state">Нет активных алертов</div>';
return `<div class="table-wrap"><table>
<thead><tr><th>Тип</th><th>Статус</th><th>Время</th><th>Сообщение</th></tr></thead>
<tbody>${alarms.map(a => `<tr>
<td>${esc(a.alarmType || a.type || '—')}</td>
<td class="job-result ${(a.status||'').toLowerCase() === 'error' ? 'error' : 'warning'}">${esc(a.status || '—')}</td>
<td>${formatDate(a.creationTime || a.time)}</td>
<td>${esc(a.message || a.description || '—')}</td>
</tr>`).join('')}</tbody>
</table></div>`;
}
function renderRepoStatesTable(inst) {
const states = inst.repository_states || [];
if (!states.length) return '<div class="empty-state">Нет данных о хранилище</div>';
return `<div class="table-wrap"><table>
<thead><tr><th>Имя</th><th>Тип</th><th>Ёмкость</th><th>Занято</th><th>Свободно</th><th>Online</th></tr></thead>
<tbody>${states.map(r => {
const pct = r.capacityGB > 0 ? ((r.usedSpaceGB / r.capacityGB) * 100).toFixed(0) : 0;
return `<tr>
<td>${esc(r.name || r.Name || '—')}</td>
<td>${esc(r.type || r.Type || '—')}</td>
<td>${formatGB(r.capacityGB)}</td>
<td>${formatGB(r.usedSpaceGB)} (${pct}%)</td>
<td>${formatGB(r.freeGB)}</td>
<td>${r.isOnline ? '✓' : '✗'}</td>
</tr>`;
}).join('')}</tbody>
</table></div>`;
}
function renderAgentsTable(inst) {
const agents = inst.agents || [];
if (!agents.length) return '<div class="empty-state">Нет агентов</div>';
return `<div class="table-wrap"><table>
<thead><tr><th>Имя</th><th>Тип</th><th>Статус</th></tr></thead>
<tbody>${agents.map(a => `<tr>
<td>${esc(a.name || a.Name || a.hostName || '—')}</td>
<td>${esc(a.type || a.Type || '—')}</td>
<td>${esc(a.status || a.Status || a.protectionStatus || '—')}</td>
</tr>`).join('')}</tbody>
</table></div>`;
}
// ─── Detail toggle ───────────────────────────────────────────────
function toggleDetail(id) {
const detail = document.getElementById('detail-' + id);
if (!detail) return;
const isOpen = detail.classList.toggle('open');
if (isOpen) {
currentDetailInstance = id;
}
}
function switchTab(instId, tab, btn) {
const parent = btn.closest('.instance-detail');
parent.querySelectorAll('.detail-tab').forEach(t => t.classList.remove('active'));
parent.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
btn.classList.add('active');
document.getElementById('tab-' + instId + '-' + tab).classList.add('active');
}
// ─── Modal ────────────────────────────────────────────────────────
function openAddModal() {
document.getElementById('editId').value = '';
document.getElementById('modalTitle').textContent = 'Добавить инстанс';
document.getElementById('saveBtn').textContent = 'Сохранить';
document.getElementById('deleteBtn').style.display = 'none';
document.getElementById('testResult').textContent = '';
document.getElementById('fName').value = '';
document.getElementById('fHost').value = '';
document.getElementById('fPort').value = '';
document.getElementById('fUser').value = '';
document.getElementById('fPass').value = '';
document.getElementById('fVersion').value = 'auto';
document.getElementById('fNotes').value = '';
document.getElementById('modal').classList.add('open');
}
async function openEditModal(id) {
document.getElementById('editId').value = id;
document.getElementById('modalTitle').textContent = 'Редактировать инстанс';
document.getElementById('saveBtn').textContent = 'Сохранить';
document.getElementById('deleteBtn').style.display = 'inline-block';
document.getElementById('testResult').textContent = '';
try {
const res = await fetch('/api/instances/' + id);
if (!res.ok) throw new Error('Not found');
const inst = await res.json();
document.getElementById('fName').value = inst.name;
document.getElementById('fHost').value = inst.host;
document.getElementById('fPort').value = inst.port;
document.getElementById('fUser').value = inst.username;
document.getElementById('fPass').value = '';
document.getElementById('fVersion').value = inst.version;
document.getElementById('fNotes').value = inst.notes;
} catch (e) {
showError('Ошибка загрузки данных инстанса');
}
document.getElementById('modal').classList.add('open');
}
function closeModal() {
document.getElementById('modal').classList.remove('open');
}
async function testConnection() {
const btn = document.getElementById('testBtn');
const result = document.getElementById('testResult');
btn.disabled = true;
btn.textContent = 'Тестирую...';
result.textContent = '';
try {
const res = await fetch('/api/test-connection', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
name: document.getElementById('fName').value || 'test',
host: document.getElementById('fHost').value,
port: parseInt(document.getElementById('fPort').value) || null,
username: document.getElementById('fUser').value,
password: document.getElementById('fPass').value,
version: document.getElementById('fVersion').value,
})
});
const data = await res.json();
if (data.ok) {
result.style.color = 'var(--green)';
result.textContent = '✓ Успешно! Роль: ' + data.role + ', версия: ' + data.version;
} else {
result.style.color = 'var(--red)';
result.textContent = '✗ ' + (data.error || 'Ошибка подключения');
}
} catch (e) {
result.style.color = 'var(--red)';
result.textContent = '✗ ' + e.message;
}
btn.disabled = false;
btn.textContent = 'Test';
}
async function saveInstance() {
const id = document.getElementById('editId').value;
const payload = {
name: document.getElementById('fName').value,
host: document.getElementById('fHost').value,
port: parseInt(document.getElementById('fPort').value) || null,
username: document.getElementById('fUser').value,
password: document.getElementById('fPass').value,
version: document.getElementById('fVersion').value,
notes: document.getElementById('fNotes').value,
};
if (!payload.host || !payload.username || !payload.password) {
showError('Заполните хост, пользователя и пароль');
return;
}
try {
let res;
if (id) {
res = await fetch('/api/instances/' + id, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload),
});
} else {
res = await fetch('/api/instances', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload),
});
}
if (!res.ok) {
const err = await res.text();
throw new Error(err);
}
closeModal();
await loadDashboard(false);
} catch (e) {
showError('Ошибка сохранения: ' + e.message);
}
}
async function deleteInstance() {
const id = document.getElementById('editId').value;
if (!id || !confirm('Удалить этот инстанс?')) return;
try {
const res = await fetch('/api/instances/' + id, { method: 'DELETE' });
if (!res.ok) throw new Error('Delete failed');
closeModal();
await loadDashboard(false);
} catch (e) {
showError('Ошибка удаления: ' + e.message);
}
}
// ─── Utils ────────────────────────────────────────────────────────
function esc(s) {
if (s == null) return '—';
const div = document.createElement('div');
div.textContent = String(s);
return div.innerHTML;
}
function formatDate(d) {
if (!d || d === '—' || d === '') return '—';
try {
const dt = new Date(d);
if (isNaN(dt.getTime())) return d;
return dt.toLocaleString('ru-RU', {
day: '2-digit', month: '2-digit', year: '2-digit',
hour: '2-digit', minute: '2-digit'
});
} catch { return d; }
}
function formatGB(gb) {
if (gb == null || isNaN(gb)) return '—';
if (gb >= 1024) return (gb / 1024).toFixed(1) + ' TB';
return gb.toFixed(1) + ' GB';
}
function showError(msg) {
const container = document.getElementById('errorContainer');
container.innerHTML = '<div class="error-banner">' + esc(msg) + '</div>';
setTimeout(() => container.innerHTML = '', 8000);
}
</script>
</body>
</html>

359
veeam_client.py Normal file
View File

@@ -0,0 +1,359 @@
import base64
import httpx
import re
from datetime import datetime
# Legacy API (v9.5-v11) — used in x-api-version header for session auth
API_VERSIONS_LEGACY = {
"9.5": "1.0-rev1",
"10": "1.0-rev1",
"11": "1.0-rev1",
}
# v12+ OAuth2 API — used in x-api-version header for /api/oauth2/token and data requests
# 1.0-rev2 is for v11 (legacy). For v12+, use 1.1-rev2 as safe default.
API_VERSIONS_V12 = {
"auto": "1.1-rev2",
"12": "1.1-rev0",
"12.1": "1.1-rev1",
"12.2": "1.1-rev2",
"12.3": "1.2-rev1",
"13": "1.3-rev1",
}
def detect_version_from_host(host: str) -> str:
h = host.lower()
for v in ["12.3", "12.2", "12.1", "12", "11", "10", "9.5"]:
if v in h:
return v
return "auto"
def port_for_version(version: str) -> int:
try:
ver = float(version)
except ValueError:
return 9419
return 9419 if ver >= 12 else 9398
class VeeamClient:
def __init__(self, host: str, port: int, username: str, password: str, version: str = "auto"):
self.host = host
self.port = port
self.username = username
self.password = password
self.version = version
self.base_url = f"https://{host}:{port}"
self.token = None
self.session_id = None
self._v12_mode = False
self._legacy_api_version = "1.0-rev1"
self._v12_api_version = "1.1-rev2"
self._http = httpx.AsyncClient(verify=False, timeout=30.0)
async def close(self):
if self.session_id:
try:
await self._http.delete(
f"{self.base_url}/api/session/{self.session_id}",
headers=self._legacy_headers()
)
except Exception:
pass
await self._http.aclose()
def _legacy_headers(self) -> dict:
h = {
"Accept": "application/json",
"x-api-version": self._legacy_api_version,
}
if self.session_id:
h["X-RestSvcSessionId"] = self.session_id
return h
def _v12_headers(self) -> dict:
h = {
"Accept": "application/json",
"Content-Type": "application/json",
"x-api-version": self._v12_api_version,
}
if self.token:
h["Authorization"] = f"Bearer {self.token}"
return h
async def connect(self) -> str:
return await self._try_connect()
def _is_v12(self) -> bool:
return self._v12_mode
def _should_try_v12_first(self) -> bool:
if self.version == "auto":
return True
try:
return float(self.version) >= 12
except ValueError:
return True
def _resolve_v12_revision(self) -> str:
return API_VERSIONS_V12.get(self.version, "1.0-rev2")
def _resolve_legacy_revision(self) -> str:
return API_VERSIONS_LEGACY.get(self.version, "1.0-rev1")
async def _try_connect(self) -> str:
combos = []
default_port = self.port or 9419
if self.version == "auto":
combos = [
(9419, "v12"),
(9398, "legacy"),
]
elif self._should_try_v12_first():
combos = [
(default_port, "v12"),
((9398 if default_port == 9419 else 9419), "legacy"),
]
else:
combos = [
(default_port, "legacy"),
((9419 if default_port == 9398 else 9398), "v12"),
]
last_error = ""
for port, method in combos:
self.port = port
self.base_url = f"https://{self.host}:{self.port}"
try:
if method == "v12":
self._v12_api_version = self._resolve_v12_revision()
result = await self._connect_v12()
self._v12_mode = True
else:
self._legacy_api_version = self._resolve_legacy_revision()
result = await self._connect_legacy()
self._v12_mode = False
return result
except Exception as e:
last_error = f"{type(e).__name__}: {e}"
continue
raise ConnectionError(
f"Cannot connect to {self.host} (tried ports {[c[0] for c in combos]}). "
f"Last error: {last_error}"
)
async def _connect_legacy(self) -> str:
auth_str = base64.b64encode(f"{self.username}:{self.password}".encode()).decode()
resp = await self._http.post(
f"{self.base_url}/api/session",
headers={
"Accept": "application/json",
"x-api-version": self._legacy_api_version,
"Authorization": f"Basic {auth_str}",
},
)
data = self._parse(resp)
sid = data.get("sessionId") or data.get("SessionId")
if not sid:
msg = data.get("_raw", resp.text[:300]) if isinstance(data, dict) else str(data)[:300]
raise ConnectionError(f"Legacy auth failed ({resp.status_code}): {msg}")
self.session_id = sid
return data.get("userRole") or data.get("UserRole") or "unknown"
async def _connect_v12(self) -> str:
resp = await self._http.post(
f"{self.base_url}/api/oauth2/token",
data={
"grant_type": "password",
"username": self.username,
"password": self.password,
},
headers={
"Accept": "application/json",
"Content-Type": "application/x-www-form-urlencoded",
"x-api-version": self._v12_api_version,
},
)
if resp.status_code == 404:
raise ConnectionError(
f"v12 auth endpoint not found on {self.base_url} "
f"(404 — REST API may be disabled or wrong port)")
try:
data = resp.json()
except Exception:
raise ConnectionError(
f"v12 auth failed ({resp.status_code}): {resp.text[:200]}")
self.token = data.get("access_token")
if not self.token:
raise ConnectionError(
f"Token auth failed ({resp.status_code}): "
f"{data.get('error_description', resp.text[:200])}")
return data.get("user_role") or data.get("username", "unknown")
def _resolve_version(self) -> str:
if self.version != "auto":
return self.version
detected = detect_version_from_host(self.host)
if detected != "auto":
return detected
return "12"
async def fetch_jobs(self) -> list[dict]:
if self._is_v12():
last_err = None
for url in ("/api/v1/jobs/states", "/api/v1/jobs", "/api/v1/jobs/states?typeFilter=Backup"):
try:
return await self._fetch_json_list(url)
except Exception as e:
last_err = e
continue
raise ConnectionError(f"all job endpoints failed: {last_err}")
return await self._fetch_legacy_jobs()
async def fetch_sessions(self, limit: int = 50) -> list[dict]:
if self._is_v12():
return await self._fetch_json_list(f"/api/v1/sessions?limit={limit}")
return await self._fetch_legacy_sessions(limit)
async def fetch_repositories(self) -> list[dict]:
if self._is_v12():
return await self._fetch_json_list("/api/v1/backupInfrastructure/repositories")
return await self._fetch_legacy_repos()
async def fetch_repository_states(self) -> list[dict]:
if self._is_v12():
return await self._fetch_json_list("/api/v1/backupInfrastructure/repositories/states")
return []
async def fetch_alarms(self) -> list[dict]:
if self._is_v12():
return await self._fetch_json_list("/api/v1/alarms")
return []
async def fetch_backup_servers(self) -> list[dict]:
if self._is_v12():
return await self._fetch_json_list("/api/v1/backupInfrastructure/managedServers")
return await self._fetch_legacy_list("/api/backupServers", "BackupServer")
async def fetch_agents(self) -> list[dict]:
if self._is_v12():
return await self._fetch_json_list("/api/v1/agents/protectedComputers")
return []
async def _fetch_json_list(self, path: str) -> list[dict]:
resp = await self._http.get(
f"{self.base_url}{path}",
headers=self._v12_headers(),
)
if resp.status_code >= 400:
raise ConnectionError(f"GET {path}: {resp.status_code} {resp.text[:200]}")
data = resp.json()
return data.get("data") or data.get("items") or data.get("results") or data.get("jobs") or (data if isinstance(data, list) else [])
async def _fetch_legacy_jobs(self) -> list[dict]:
resp = await self._http.get(
f"{self.base_url}/api/jobs",
headers=self._legacy_headers(),
)
if resp.status_code >= 400:
raise ConnectionError(f"GET /api/jobs: {resp.status_code} {resp.text[:200]}")
data = self._parse(resp)
jobs = data.get("Jobs") or data.get("jobs") or data.get("Refs", {}).get("Job") or []
if isinstance(jobs, dict):
jobs = [jobs]
return [self._normalize_legacy_job(j) for j in jobs]
async def _fetch_legacy_sessions(self, limit: int) -> list[dict]:
resp = await self._http.get(
f"{self.base_url}/api/sessions?limit={limit}",
headers=self._legacy_headers(),
)
if resp.status_code >= 400:
return []
data = self._parse(resp)
sessions = data.get("Sessions") or data.get("sessions") or []
if isinstance(sessions, dict):
sessions = [sessions]
return [self._normalize_legacy_session(s) for s in sessions]
async def _fetch_legacy_repos(self) -> list[dict]:
return await self._fetch_legacy_list("/api/repositories", "BackupRepository")
async def _fetch_legacy_list(self, path: str, item_key: str) -> list[dict]:
resp = await self._http.get(
f"{self.base_url}{path}",
headers=self._legacy_headers(),
)
if resp.status_code >= 400:
return []
data = self._parse(resp)
items = data.get(f"{item_key}s") or data.get(item_key) or []
if isinstance(items, dict):
items = [items]
return items
def _parse(self, resp: httpx.Response) -> dict:
ct = resp.headers.get("content-type", "")
if "json" in ct:
try:
return resp.json()
except Exception:
pass
text = resp.text
if text.strip().startswith("<"):
return self._xml_to_dict_simple(text)
try:
return resp.json()
except Exception:
return {"_raw": text[:500]}
def _xml_to_dict_simple(self, xml: str) -> dict:
result = {}
tags = re.findall(r"<(\w+)([^>]*)>(.*?)</\1>", xml, re.DOTALL)
for tag_name, attrs_str, content in tags:
attrs = dict(re.findall(r'(\w+)="([^"]*)"', attrs_str))
sub = self._xml_to_dict_simple(content) if "<" in content else content.strip()
if attrs:
entry = {"_text": sub} if sub else {}
entry.update(attrs)
else:
entry = sub
if tag_name not in result:
result[tag_name] = entry
else:
existing = result[tag_name]
if not isinstance(existing, list):
result[tag_name] = [existing]
result[tag_name].append(entry)
return result
def _normalize_legacy_job(self, job: dict) -> dict:
return {
"id": job.get("@Id") or job.get("id") or job.get("Id", ""),
"name": job.get("@Name") or job.get("name") or job.get("Name", ""),
"type": job.get("@Type") or job.get("type") or job.get("JobType", ""),
"description": job.get("@Description") or job.get("description", ""),
"schedule_enabled": job.get("@ScheduleEnabled") or job.get("scheduleEnabled", ""),
"is_active": job.get("@IsBackupToD2D") or job.get("isActive", ""),
"next_run": job.get("@NextRun") or job.get("nextRun", ""),
}
def _normalize_legacy_session(self, s: dict) -> dict:
return {
"id": s.get("@Id") or s.get("id", ""),
"name": s.get("@Name") or s.get("name", ""),
"job_name": s.get("@JobName") or s.get("jobName", ""),
"job_id": s.get("@JobId") or s.get("jobId", ""),
"type": s.get("@Type") or s.get("type", ""),
"result": s.get("@Result") or s.get("result", ""),
"state": s.get("@State") or s.get("state", ""),
"creation_time": s.get("@CreationTime") or s.get("creationTime", ""),
"end_time": s.get("@EndTime") or s.get("endTime", ""),
}
def __repr__(self):
return f"VeeamClient({self.host}:{self.port} v{self.version})"