feat: CMDB full-stack app - FastAPI + PostgreSQL + React

- PostgreSQL schema: 14 tables, JSONB attributes, audit triggers, soft delete
- FastAPI backend: CRUD, search/filter, relationship graph, bulk import, JWT RBAC
- React frontend: CI table, detail card, force-graph, dashboard
- Seed data: homelab scenario (Proxmox, Mikrotik, VMs, services)
- Docker Compose + Kubernetes manifests
- 20 backend tests (pytest + httpx)
This commit is contained in:
2026-06-25 13:01:40 +07:00
commit 9b15f8b09c
47 changed files with 4268 additions and 0 deletions

292
README.md Normal file
View File

@@ -0,0 +1,292 @@
# CMDB — Configuration Management Database
Full-stack CMDB application: **FastAPI + PostgreSQL + React (MUI)**
## Architecture
```
┌──────────┐ ┌────────────┐ ┌────────────┐
│ React │────▶│ Nginx │────▶│ FastAPI │
│ (MUI) │ │ (reverse │ │ (async) │
│ :3000 │ │ proxy) │ │ :8000 │
└──────────┘ │ :80 │ └─────┬──────┘
└────────────┘ │
┌──────▼──────┐
│ PostgreSQL │
│ :5432 │
└─────────────┘
```
## Quick Start (Docker Compose)
```bash
# Clone and start
cd cmdb-app
docker-compose up -d
# Apply migrations (if not auto-applied)
docker exec -i cmdb-postgres psql -U cmdb -d cmdb < backend/migrations/001_initial_schema.sql
docker exec -i cmdb-postgres psql -U cmdb -d cmdb < backend/migrations/002_seed_data.sql
# Open
# API docs: http://localhost/api/docs
# Frontend: http://localhost
```
## Local Development
### Backend
```bash
cd backend
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
# Start PostgreSQL (Docker)
docker run -d --name cmdb-pg -p 5432:5432 \
-e POSTGRES_DB=cmdb -e POSTGRES_USER=cmdb -e POSTGRES_PASSWORD=cmdb_secret \
postgres:16-alpine
# Run migrations
psql -h localhost -U cmdb -d cmdb < migrations/001_initial_schema.sql
psql -h localhost -U cmdb -d cmdb < migrations/002_seed_data.sql
# Start backend
uvicorn app.main:app --reload --port 8000
```
### Frontend
```bash
cd frontend
npm install
npm run dev
# → http://localhost:5173
```
## API Reference
### Authentication
```bash
# Login
curl -X POST http://localhost:8000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin123"}'
# → {"access_token":"eyJ...","token_type":"bearer"}
# Get current user
curl -H "Authorization: Bearer <token>" http://localhost:8000/api/auth/me
```
### Configuration Items
```bash
# List CIs (paginated, filtered)
curl -H "Authorization: Bearer <token>" \
"http://localhost:8000/api/ci?page=1&page_size=10&status=active&search=proxmox"
# Get single CI with all details
curl -H "Authorization: Bearer <token>" \
http://localhost:8000/api/ci/<ci_id>
# Create CI
curl -X POST http://localhost:8000/api/ci \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"name": "new-server",
"ci_type_id": "<type_uuid>",
"status": "active",
"tags": ["new", "production"],
"attributes": {"cpu": "Xeon", "ram_gb": 32}
}'
# Update CI
curl -X PATCH http://localhost:8000/api/ci/<ci_id> \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"status": "maintenance"}'
# Delete (soft)
curl -X DELETE -H "Authorization: Bearer <token>" \
http://localhost:8000/api/ci/<ci_id>
# Export CSV
curl -H "Authorization: Bearer <token>" \
http://localhost:8000/api/ci/export?format=csv > cmdb_export.csv
```
### Relationships & Graph
```bash
# Add relationship
curl -X POST http://localhost:8000/api/ci/<ci_id>/relationships \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"source_ci_id": "<ci_id>",
"target_ci_id": "<other_ci_id>",
"relationship": "depends_on",
"description": "Service depends on server"
}'
# Get relationship graph
curl -H "Authorization: Bearer <token>" \
"http://localhost:8000/api/ci/graph/visualize?depth=2"
```
### Bulk Import
```bash
curl -X POST http://localhost:8000/api/ci/bulk/import \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"items": [
{"name": "server-1", "ci_type_name": "PhysicalServer", "status": "active"},
{"name": "vm-web", "ci_type_name": "VirtualMachine", "status": "active"}
]
}'
```
### Dashboard
```bash
curl -H "Authorization: Bearer <token>" \
http://localhost:8000/api/dashboard/stats
```
## Query Parameters (CI List)
| Parameter | Type | Description |
|-------------|--------|--------------------------------------|
| page | int | Page number (default: 1) |
| page_size | int | Items per page (1-100, default: 20) |
| search | string | Full-text search on name/desc/serial |
| status | string | Filter by status |
| ci_type_id | UUID | Filter by CI type |
| location_id | UUID | Filter by location |
| tag | string | Filter by tag |
| owner_id | UUID | Filter by owner |
| sort_by | string | Sort field (default: name) |
| sort_order | string | asc/desc (default: asc) |
## Testing
```bash
cd backend
pytest tests/ -v
```
## Deployment
### Docker Compose (production)
```bash
# Set secrets
export JWT_SECRET=$(openssl rand -hex 32)
export POSTGRES_PASSWORD=$(openssl rand -hex 32)
docker-compose -f docker-compose.yml up -d
```
### Kubernetes
```bash
# Create namespace
kubectl create namespace cmdb
# Create secrets
kubectl -n cmdb create secret generic cmdb-secrets \
--from-literal=db-user=cmdb \
--from-literal=db-password=$(openssl rand -hex 16) \
--from-literal=jwt-secret=$(openssl rand -hex 32)
# Deploy
kubectl apply -f k8s/postgres.yaml
kubectl apply -f k8s/backend.yaml
kubectl apply -f k8s/frontend.yaml
# Check
kubectl -n cmdb get pods
```
## Security Checklist
- [ ] Change `JWT_SECRET` in production
- [ ] Change PostgreSQL password
- [ ] Enable SSL/TLS for PostgreSQL (`sslmode=require`)
- [ ] Run backend as non-root user
- [ ] Configure CORS for production domain only
- [ ] Set up `pg_hba.conf` to restrict DB access
- [ ] Enable rate limiting (configured: 120 req/min)
- [ ] Run `pg_dump` backups daily
- [ ] Review audit trail in `changelog` table
## Database Schema
### ER Diagram (simplified)
```
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ ci_classes │────▶│ ci_types │────▶│ cis │
└──────────────┘ └──────────────┘ └──────┬───────┘
┌─────────────────────────────┼───────────────────────┐
│ │ │ │ │
┌─────▼─────┐ ┌────▼─────┐ ┌──────▼──────┐ ┌───▼────┐ ┌──▼──────────┐
│ip_addresses│ │ nics │ │ hw_details │ │sw_inst │ │relationships│
└───────────┘ └──────────┘ └─────────────┘ └────────┘ └─────────────┘
```
### Key Design Decisions
1. **Soft delete everywhere**`deleted_at` column, never hard delete
2. **JSONB attributes** — extensible key-value store for class-specific fields
3. **Audit trail**`changelog` table + PostgreSQL triggers
4. **Versioning** — CI `version` column incremented on every update
5. **UUID primary keys** — safe for distributed/multi-instance
6. **INET type** — native PostgreSQL IP address handling
## Ansible Integration
```yaml
# playbooks/cmdb-import.yml
- name: Import Ansible facts into CMDB
hosts: all
tasks:
- name: Get system facts
set_fact:
ci_data:
name: "{{ inventory_hostname }}"
status: active
attributes:
os: "{{ ansible_distribution }} {{ ansible_distribution_version }}"
cpu_cores: "{{ ansible_processor_vcpus }}"
ram_gb: "{{ (ansible_memtotal_mb / 1024) | round(1) }}"
ip: "{{ ansible_default_ipv4.address }}"
- name: Register in CMDB
uri:
url: "http://cmdb-host:8000/api/ci"
method: POST
headers:
Authorization: "Bearer {{ cmdb_token }}"
body_format: json
body: "{{ ci_data }}"
status_code: [201, 409]
```
## Roadmap
1. **Discovery integration** — nmap, arp-scan, SNMP polling
2. **CMDB reconciliation** — compare discovered vs. recorded state
3. **Change management** — RFC workflow, approval chain
4. **Dependency impact analysis** — cascade failure simulation
5. **SSO/LDAP** — corporate directory integration
6. **Webhook notifications** — Slack/Teams alerts on CI changes
7. **API versioning**`/api/v2/` with backward compatibility
8. **GraphQL** — alternative API layer for complex queries
9. **RBAC per CI type** — fine-grained access control
10. **Terraform/Pulumi integration** — import IaC resources as CIs

8
backend/.env.example Normal file
View File

@@ -0,0 +1,8 @@
# ============================================================
# Environment variables for backend
# ============================================================
DATABASE_URL=postgresql+asyncpg://cmdb:cmdb_secret@localhost:5432/cmdb
JWT_SECRET=CHANGE_ME_IN_PRODUCTION
CORS_ORIGINS=["http://localhost:3000","http://localhost:5173"]
RATE_LIMIT_PER_MINUTE=120
DEBUG=false

14
backend/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends gcc libpq-dev && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]

0
backend/app/__init__.py Normal file
View File

29
backend/app/config.py Normal file
View File

@@ -0,0 +1,29 @@
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
APP_NAME: str = "CMDB"
APP_VERSION: str = "1.0.0"
DEBUG: bool = False
DATABASE_URL: str = "postgresql+asyncpg://cmdb:cmdb_secret@localhost:5432/cmdb"
DATABASE_POOL_SIZE: int = 20
DATABASE_MAX_OVERFLOW: int = 10
JWT_SECRET: str = "CHANGE-ME-IN-PRODUCTION-use-openssl-rand-hex-32"
JWT_ALGORITHM: str = "HS256"
JWT_EXPIRATION_MINUTES: int = 60
CORS_ORIGINS: list[str] = ["http://localhost:3000", "http://localhost:5173"]
RATE_LIMIT_PER_MINUTE: int = 120
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
@lru_cache()
def get_settings() -> Settings:
return Settings()

30
backend/app/database.py Normal file
View File

@@ -0,0 +1,30 @@
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy.orm import DeclarativeBase
from app.config import get_settings
settings = get_settings()
engine = create_async_engine(
settings.DATABASE_URL,
pool_size=settings.DATABASE_POOL_SIZE,
max_overflow=settings.DATABASE_MAX_OVERFLOW,
echo=settings.DEBUG,
)
AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db():
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()

35
backend/app/main.py Normal file
View File

@@ -0,0 +1,35 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import get_settings
from app.routes import auth, ci, reference, dashboard
from app.middleware.rate_limit import rate_limit_middleware
settings = get_settings()
app = FastAPI(
title=settings.APP_NAME,
version=settings.APP_VERSION,
docs_url="/api/docs",
redoc_url="/api/redoc",
openapi_url="/api/openapi.json",
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.middleware("http")(rate_limit_middleware)
app.include_router(auth.router)
app.include_router(ci.router)
app.include_router(reference.router)
app.include_router(dashboard.router)
@app.get("/api/health")
async def health_check():
return {"status": "healthy", "version": settings.APP_VERSION}

View File

View File

@@ -0,0 +1,69 @@
from datetime import datetime, timedelta
from typing import Optional
from uuid import UUID
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.config import get_settings
from app.database import get_db
from app.models.models import User
settings = get_settings()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
security = HTTPBearer()
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def create_access_token(user_id: UUID, role: str, expires_delta: Optional[timedelta] = None) -> str:
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=settings.JWT_EXPIRATION_MINUTES))
payload = {
"sub": str(user_id),
"role": role,
"exp": expire,
}
return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
def decode_token(token: str) -> dict:
try:
return jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM])
except JWTError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db),
) -> User:
payload = decode_token(credentials.credentials)
user_id = payload.get("sub")
result = await db.execute(select(User).where(User.id == UUID(user_id)))
user = result.scalar_one_or_none()
if not user or user.deleted_at:
raise HTTPException(status_code=401, detail="User not found")
return user
class RequireRole:
def __init__(self, *allowed_roles: str):
self.allowed_roles = allowed_roles
async def __call__(self, user: User = Depends(get_current_user)) -> User:
if user.role not in self.allowed_roles:
raise HTTPException(status_code=403, detail="Insufficient permissions")
return user
require_admin = RequireRole("admin")
require_editor = RequireRole("admin", "editor")
require_viewer = RequireRole("admin", "editor", "viewer")

View File

@@ -0,0 +1,23 @@
import time
from collections import defaultdict
from fastapi import Request, HTTPException
from app.config import get_settings
settings = get_settings()
_requests: dict[str, list[float]] = defaultdict(list)
async def rate_limit_middleware(request: Request, call_next):
client_ip = request.client.host if request.client else "unknown"
now = time.time()
window = 60.0
_requests[client_ip] = [t for t in _requests[client_ip] if now - t < window]
if len(_requests[client_ip]) >= settings.RATE_LIMIT_PER_MINUTE:
raise HTTPException(status_code=429, detail="Rate limit exceeded")
_requests[client_ip].append(now)
response = await call_next(request)
return response

View File

View File

@@ -0,0 +1,222 @@
import uuid
from datetime import datetime, date
from sqlalchemy import (
Column, String, Text, Boolean, Integer, Numeric, Date,
DateTime, ForeignKey, Enum as SAEnum, UniqueConstraint, Index
)
from sqlalchemy.dialects.postgresql import UUID, INET, MACADDR, JSONB, ARRAY
from sqlalchemy.orm import relationship
from app.database import Base
class Location(Base):
__tablename__ = "locations"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(Text, nullable=False, unique=True)
description = Column(Text)
parent_id = Column(UUID(as_uuid=True), ForeignKey("locations.id"), nullable=True)
metadata_ = Column("metadata", JSONB, default={})
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
updated_at = Column(DateTime(timezone=True), default=datetime.utcnow)
deleted_at = Column(DateTime(timezone=True), nullable=True)
class User(Base):
__tablename__ = "users"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
username = Column(Text, nullable=False, unique=True)
email = Column(Text, nullable=False, unique=True)
full_name = Column(Text)
role = Column(SAEnum("admin", "editor", "viewer", name="user_role"), nullable=False, default="viewer")
team = Column(Text)
is_active = Column(Boolean, nullable=False, default=True)
password_hash = Column(Text, nullable=False)
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
updated_at = Column(DateTime(timezone=True), default=datetime.utcnow)
deleted_at = Column(DateTime(timezone=True), nullable=True)
class CIClass(Base):
__tablename__ = "ci_classes"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(Text, nullable=False, unique=True)
description = Column(Text)
parent_id = Column(UUID(as_uuid=True), ForeignKey("ci_classes.id"), nullable=True)
icon = Column(Text)
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
updated_at = Column(DateTime(timezone=True), default=datetime.utcnow)
deleted_at = Column(DateTime(timezone=True), nullable=True)
class CIType(Base):
__tablename__ = "ci_types"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
class_id = Column(UUID(as_uuid=True), ForeignKey("ci_classes.id"), nullable=False)
name = Column(Text, nullable=False)
description = Column(Text)
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
updated_at = Column(DateTime(timezone=True), default=datetime.utcnow)
deleted_at = Column(DateTime(timezone=True), nullable=True)
__table_args__ = (
UniqueConstraint("class_id", "name"),
)
class ConfigurationItem(Base):
__tablename__ = "configuration_items"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
ci_type_id = Column(UUID(as_uuid=True), ForeignKey("ci_types.id"), nullable=False)
name = Column(Text, nullable=False)
description = Column(Text)
status = Column(
SAEnum("active", "inactive", "maintenance", "deprecated", "planned", name="ci_status"),
nullable=False, default="active"
)
location_id = Column(UUID(as_uuid=True), ForeignKey("locations.id"), nullable=True)
serial_number = Column(Text)
asset_tag = Column(Text)
purchase_date = Column(Date)
warranty_expiry = Column(Date)
attributes = Column(JSONB, default={})
tags = Column(ARRAY(Text), default=[])
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"))
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
updated_at = Column(DateTime(timezone=True), default=datetime.utcnow)
deleted_at = Column(DateTime(timezone=True), nullable=True)
version = Column(Integer, nullable=False, default=1)
ci_type = relationship("CIType", lazy="joined")
location = relationship("Location", lazy="joined")
created_by_user = relationship("User", lazy="joined")
__table_args__ = (
Index("idx_ci_name_type_active", "name", "ci_type_id", unique=True,
postgresql_where="deleted_at IS NULL"),
)
class IPAddress(Base):
__tablename__ = "ip_addresses"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
ci_id = Column(UUID(as_uuid=True), ForeignKey("configuration_items.id"), nullable=False)
ip_address = Column(INET, nullable=False)
subnet_mask = Column(INET)
gateway = Column(INET)
dns_servers = Column(ARRAY(INET))
is_primary = Column(Boolean, nullable=False, default=False)
vlan_id = Column(Integer)
dhcp_enabled = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
updated_at = Column(DateTime(timezone=True), default=datetime.utcnow)
deleted_at = Column(DateTime(timezone=True), nullable=True)
class NetworkInterface(Base):
__tablename__ = "network_interfaces"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
ci_id = Column(UUID(as_uuid=True), ForeignKey("configuration_items.id"), nullable=False)
name = Column(Text, nullable=False)
mac_address = Column(MACADDR)
speed_mbps = Column(Integer)
interface_type = Column(Text, default="ethernet")
is_up = Column(Boolean, default=True)
ip_address_id = Column(UUID(as_uuid=True), ForeignKey("ip_addresses.id"), nullable=True)
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
updated_at = Column(DateTime(timezone=True), default=datetime.utcnow)
deleted_at = Column(DateTime(timezone=True), nullable=True)
class HardwareDetail(Base):
__tablename__ = "hardware_details"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
ci_id = Column(UUID(as_uuid=True), ForeignKey("configuration_items.id"), nullable=False)
manufacturer = Column(Text)
model = Column(Text)
cpu_model = Column(Text)
cpu_cores = Column(Integer)
ram_gb = Column(Numeric(10, 2))
storage_gb = Column(Numeric(10, 2))
storage_type = Column(Text)
form_factor = Column(Text)
power_supply = Column(Text)
bios_version = Column(Text)
serial_number = Column(Text)
specs = Column(JSONB, default={})
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
updated_at = Column(DateTime(timezone=True), default=datetime.utcnow)
deleted_at = Column(DateTime(timezone=True), nullable=True)
class SoftwareInstance(Base):
__tablename__ = "software_instances"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
ci_id = Column(UUID(as_uuid=True), ForeignKey("configuration_items.id"), nullable=False)
name = Column(Text, nullable=False)
vendor = Column(Text)
version = Column(Text)
license_key = Column(Text)
license_type = Column(Text)
install_path = Column(Text)
config_path = Column(Text)
port = Column(Integer)
protocol = Column(Text)
start_command = Column(Text)
service_user = Column(Text)
config = Column(JSONB, default={})
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
updated_at = Column(DateTime(timezone=True), default=datetime.utcnow)
deleted_at = Column(DateTime(timezone=True), nullable=True)
class CIRelationship(Base):
__tablename__ = "ci_relationships"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
source_ci_id = Column(UUID(as_uuid=True), ForeignKey("configuration_items.id"), nullable=False)
target_ci_id = Column(UUID(as_uuid=True), ForeignKey("configuration_items.id"), nullable=False)
relationship = Column(
SAEnum("depends_on", "connected_to", "hosted_on", "runs_on",
"manages", "contains", "part_of", "related_to", name="relationship_type"),
nullable=False
)
description = Column(Text)
metadata_ = Column("metadata", JSONB, default={})
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
deleted_at = Column(DateTime(timezone=True), nullable=True)
source_ci = relationship("ConfigurationItem", foreign_keys=[source_ci_id], lazy="joined")
target_ci = relationship("ConfigurationItem", foreign_keys=[target_ci_id], lazy="joined")
__table_args__ = (
UniqueConstraint("source_ci_id", "target_ci_id", "relationship"),
)
class ChangeLog(Base):
__tablename__ = "changelog"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
ci_id = Column(UUID(as_uuid=True), ForeignKey("configuration_items.id"), nullable=False)
action = Column(
SAEnum("create", "update", "delete", "restore",
"relationship_add", "relationship_remove", name="change_action"),
nullable=False
)
changed_by = Column(UUID(as_uuid=True), ForeignKey("users.id"))
field_name = Column(Text)
old_value = Column(Text)
new_value = Column(Text)
snapshot = Column(JSONB)
version = Column(Integer, nullable=False)
comment = Column(Text)
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)

View File

View File

@@ -0,0 +1,48 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.models.models import User
from app.schemas.schemas import LoginRequest, Token, UserCreate, UserResponse
from app.middleware.auth import hash_password, verify_password, create_access_token, get_current_user, require_admin
router = APIRouter(prefix="/api/auth", tags=["auth"])
@router.post("/login", response_model=Token)
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.username == body.username))
user = result.scalar_one_or_none()
if not user or not verify_password(body.password, user.password_hash):
raise HTTPException(status_code=401, detail="Invalid credentials")
token = create_access_token(user.id, user.role)
return Token(access_token=token)
@router.post("/users", response_model=UserResponse)
async def create_user(
body: UserCreate,
db: AsyncSession = Depends(get_db),
_admin: User = Depends(require_admin),
):
existing = await db.execute(select(User).where(User.username == body.username))
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail="Username already exists")
user = User(
username=body.username,
email=body.email,
full_name=body.full_name,
role=body.role,
team=body.team,
password_hash=hash_password(body.password),
)
db.add(user)
await db.flush()
await db.refresh(user)
return user
@router.get("/me", response_model=UserResponse)
async def get_me(user: User = Depends(get_current_user)):
return user

391
backend/app/routes/ci.py Normal file
View File

@@ -0,0 +1,391 @@
import math
import io
import csv
import json
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, or_
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.models.models import (
ConfigurationItem, CIType, CIClass, Location, User, IPAddress,
NetworkInterface, HardwareDetail, SoftwareInstance, CIRelationship, Owner
)
from app.schemas.schemas import (
CICreate, CIUpdate, CIResponse, CIDetailResponse, PaginatedResponse,
IPAddressCreate, IPAddressResponse, RelationshipCreate, RelationshipResponse,
GraphData, GraphNode, GraphEdge, BulkImportRequest, BulkImportItem, OwnerCreate, OwnerResponse
)
from app.middleware.auth import get_current_user, require_editor, require_viewer, require_admin
router = APIRouter(prefix="/api/ci", tags=["ci"])
def build_ci_query(filters: dict):
q = select(ConfigurationItem).where(ConfigurationItem.deleted_at.is_(None))
if filters.get("search"):
term = f"%{filters['search']}%"
q = q.where(or_(
ConfigurationItem.name.ilike(term),
ConfigurationItem.description.ilike(term),
ConfigurationItem.serial_number.ilike(term),
ConfigurationItem.asset_tag.ilike(term),
))
if filters.get("status"):
q = q.where(ConfigurationItem.status == filters["status"])
if filters.get("ci_type_id"):
q = q.where(ConfigurationItem.ci_type_id == UUID(filters["ci_type_id"]))
if filters.get("location_id"):
q = q.where(ConfigurationItem.location_id == UUID(filters["location_id"]))
if filters.get("tag"):
q = q.where(ConfigurationItem.tags.contains([filters["tag"]]))
if filters.get("owner_id"):
owner_sub = select(Owner.ci_id).where(Owner.user_id == UUID(filters["owner_id"]))
q = q.where(ConfigurationItem.id.in_(owner_sub))
return q
@router.get("", response_model=PaginatedResponse)
async def list_cis(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
search: str = Query(None),
status: str = Query(None),
ci_type_id: str = Query(None),
location_id: str = Query(None),
tag: str = Query(None),
owner_id: str = Query(None),
sort_by: str = Query("name"),
sort_order: str = Query("asc"),
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_viewer),
):
filters = {
"search": search, "status": status, "ci_type_id": ci_type_id,
"location_id": location_id, "tag": tag, "owner_id": owner_id,
}
q = build_ci_query(filters)
# Sorting
sort_col = getattr(ConfigurationItem, sort_by, ConfigurationItem.name)
if sort_order == "desc":
q = q.order_by(sort_col.desc())
else:
q = q.order_by(sort_col.asc())
# Count
count_q = select(func.count()).select_from(
build_ci_query(filters).subquery()
)
total = (await db.execute(count_q)).scalar()
# Paginate
q = q.offset((page - 1) * page_size).limit(page_size)
q = q.options(
selectinload(ConfigurationItem.ci_type),
selectinload(ConfigurationItem.location),
)
result = await db.execute(q)
items = result.scalars().all()
return PaginatedResponse(
items=[CIResponse.model_validate(i) for i in items],
total=total,
page=page,
page_size=page_size,
pages=math.ceil(total / page_size) if total else 0,
)
@router.get("/export")
async def export_cis(
format: str = Query("csv"),
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_viewer),
):
q = select(ConfigurationItem).where(ConfigurationItem.deleted_at.is_(None))
result = await db.execute(q)
items = result.scalars().all()
if format == "csv":
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["name", "type", "status", "location", "serial_number", "tags", "attributes"])
for ci in items:
writer.writerow([
ci.name, str(ci.ci_type_id), ci.status, str(ci.location_id),
ci.serial_number or "", ",".join(ci.tags or []), json.dumps(ci.attributes or {})
])
output.seek(0)
return StreamingResponse(io.BytesIO(output.getvalue().encode()),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=cmdb_export.csv"})
else:
data = [
{"name": ci.name, "status": ci.status, "attributes": ci.attributes, "tags": ci.tags}
for ci in items
]
return json.dumps(data, indent=2)
@router.get("/{ci_id}", response_model=CIDetailResponse)
async def get_ci(ci_id: UUID, db: AsyncSession = Depends(get_db), _user: User = Depends(require_viewer)):
q = (
select(ConfigurationItem)
.where(ConfigurationItem.id == ci_id, ConfigurationItem.deleted_at.is_(None))
.options(
selectinload(ConfigurationItem.ci_type),
selectinload(ConfigurationItem.location),
)
)
result = await db.execute(q)
ci = result.scalar_one_or_none()
if not ci:
raise HTTPException(status_code=404, detail="CI not found")
# Load related data
ips = (await db.execute(
select(IPAddress).where(IPAddress.ci_id == ci_id, IPAddress.deleted_at.is_(None))
)).scalars().all()
nics = (await db.execute(
select(NetworkInterface).where(NetworkInterface.ci_id == ci_id, NetworkInterface.deleted_at.is_(None))
)).scalars().all()
hw = (await db.execute(
select(HardwareDetail).where(HardwareDetail.ci_id == ci_id, HardwareDetail.deleted_at.is_(None))
)).scalar_one_or_none()
sw = (await db.execute(
select(SoftwareInstance).where(SoftwareInstance.ci_id == ci_id, SoftwareInstance.deleted_at.is_(None))
)).scalars().all()
rels_out = (await db.execute(
select(CIRelationship).where(CIRelationship.source_ci_id == ci_id, CIRelationship.deleted_at.is_(None))
)).scalars().all()
rels_in = (await db.execute(
select(CIRelationship).where(CIRelationship.target_ci_id == ci_id, CIRelationship.deleted_at.is_(None))
)).scalars().all()
owners = (await db.execute(
select(Owner).where(Owner.ci_id == ci_id)
)).scalars().all()
resp = CIDetailResponse.model_validate(ci)
resp.ip_addresses = [IPAddressResponse.model_validate(ip) for ip in ips]
resp.network_interfaces = [NetworkInterfaceResponse.model_validate(n) for n in nics]
resp.hardware_detail = HardwareDetailResponse.model_validate(hw) if hw else None
resp.software_instances = [SoftwareInstanceResponse.model_validate(s) for s in sw]
resp.relationships_out = [RelationshipResponse.model_validate(r) for r in rels_out]
resp.relationships_in = [RelationshipResponse.model_validate(r) for r in rels_in]
resp.owners = [OwnerResponse.model_validate(o) for o in owners]
return resp
@router.post("", response_model=CIResponse, status_code=201)
async def create_ci(body: CICreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_editor)):
ci = ConfigurationItem(
ci_type_id=body.ci_type_id,
name=body.name,
description=body.description,
status=body.status,
location_id=body.location_id,
serial_number=body.serial_number,
asset_tag=body.asset_tag,
purchase_date=body.purchase_date,
warranty_expiry=body.warranty_expiry,
attributes=body.attributes,
tags=body.tags,
created_by=user.id,
)
db.add(ci)
await db.flush()
await db.refresh(ci)
return CIResponse.model_validate(ci)
@router.patch("/{ci_id}", response_model=CIResponse)
async def update_ci(ci_id: UUID, body: CIUpdate, db: AsyncSession = Depends(get_db), user: User = Depends(require_editor)):
result = await db.execute(
select(ConfigurationItem).where(ConfigurationItem.id == ci_id, ConfigurationItem.deleted_at.is_(None))
)
ci = result.scalar_one_or_none()
if not ci:
raise HTTPException(status_code=404, detail="CI not found")
update_data = body.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(ci, field, value)
await db.flush()
await db.refresh(ci)
return CIResponse.model_validate(ci)
@router.delete("/{ci_id}", status_code=204)
async def delete_ci(ci_id: UUID, db: AsyncSession = Depends(get_db), user: User = Depends(require_editor)):
result = await db.execute(
select(ConfigurationItem).where(ConfigurationItem.id == ci_id, ConfigurationItem.deleted_at.is_(None))
)
ci = result.scalar_one_or_none()
if not ci:
raise HTTPException(status_code=404, detail="CI not found")
from datetime import datetime
ci.deleted_at = datetime.utcnow()
await db.flush()
@router.post("/{ci_id}/restore", response_model=CIResponse)
async def restore_ci(ci_id: UUID, db: AsyncSession = Depends(get_db), user: User = Depends(require_editor)):
result = await db.execute(
select(ConfigurationItem).where(ConfigurationItem.id == ci_id, ConfigurationItem.deleted_at.is_(None) == False)
)
ci = result.scalar_one_or_none()
if not ci:
raise HTTPException(status_code=404, detail="CI not found")
ci.deleted_at = None
await db.flush()
await db.refresh(ci)
return CIResponse.model_validate(ci)
# ── Relationships ───────────────────────────────────────
@router.post("/{ci_id}/relationships", response_model=RelationshipResponse, status_code=201)
async def add_relationship(ci_id: UUID, body: RelationshipCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_editor)):
rel = CIRelationship(
source_ci_id=ci_id,
target_ci_id=body.target_ci_id,
relationship=body.relationship,
description=body.description,
metadata_=body.metadata,
)
db.add(rel)
await db.flush()
await db.refresh(rel)
return RelationshipResponse.model_validate(rel)
@router.delete("/{ci_id}/relationships/{rel_id}", status_code=204)
async def remove_relationship(ci_id: UUID, rel_id: UUID, db: AsyncSession = Depends(get_db), user: User = Depends(require_editor)):
result = await db.execute(
select(CIRelationship).where(CIRelationship.id == rel_id, CIRelationship.deleted_at.is_(None))
)
rel = result.scalar_one_or_none()
if not rel:
raise HTTPException(status_code=404, detail="Relationship not found")
from datetime import datetime
rel.deleted_at = datetime.utcnow()
await db.flush()
# ── Graph ───────────────────────────────────────────────
@router.get("/graph/visualize", response_model=GraphData)
async def get_graph(
depth: int = Query(2, ge=1, le=5),
ci_id: UUID = Query(None),
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_viewer),
):
visited = set()
nodes = []
edges = []
async def traverse(current_id: UUID, current_depth: int):
if current_depth > depth or str(current_id) in visited:
return
visited.add(str(current_id))
ci_result = await db.execute(
select(ConfigurationItem).where(
ConfigurationItem.id == current_id,
ConfigurationItem.deleted_at.is_(None)
).options(selectinload(ConfigurationItem.ci_type))
)
ci = ci_result.scalar_one_or_none()
if not ci:
return
class_name = ci.ci_type.name if ci.ci_type else "Unknown"
nodes.append(GraphNode(
id=str(ci.id), label=ci.name, group=class_name, status=ci.status
))
rels = (await db.execute(
select(CIRelationship).where(
or_(CIRelationship.source_ci_id == current_id, CIRelationship.target_ci_id == current_id),
CIRelationship.deleted_at.is_(None)
)
)).scalars().all()
for rel in rels:
neighbor_id = rel.target_ci_id if rel.source_ci_id == current_id else rel.source_ci_id
edges.append(GraphEdge(
source=str(rel.source_ci_id), target=str(rel.target_ci_id),
label=rel.relationship
))
await traverse(neighbor_id, current_depth + 1)
if ci_id:
await traverse(ci_id, 0)
else:
# Start from first 50 CIs
all_cis = (await db.execute(
select(ConfigurationItem).where(ConfigurationItem.deleted_at.is_(None)).limit(50)
)).scalars().all()
for ci in all_cis:
await traverse(ci.id, 0)
return GraphData(nodes=nodes, edges=edges)
# ── Bulk Import ─────────────────────────────────────────
@router.post("/bulk/import", status_code=201)
async def bulk_import(body: BulkImportRequest, db: AsyncSession = Depends(get_db), user: User = Depends(require_editor)):
created = 0
errors = []
for idx, item in enumerate(body.items):
try:
type_result = await db.execute(
select(CIType).join(CIClass).where(CIType.name == item.ci_type_name)
)
ci_type = type_result.scalar_one_or_none()
if not ci_type:
errors.append({"index": idx, "error": f"Type '{item.ci_type_name}' not found"})
continue
location_id = None
if item.location_name:
loc_result = await db.execute(select(Location).where(Location.name == item.location_name))
loc = loc_result.scalar_one_or_none()
if loc:
location_id = loc.id
ci = ConfigurationItem(
ci_type_id=ci_type.id,
name=item.name,
description=item.description,
status=item.status,
location_id=location_id,
attributes=item.attributes,
tags=item.tags,
created_by=user.id,
)
db.add(ci)
created += 1
except Exception as e:
errors.append({"index": idx, "error": str(e)})
return {"created": created, "errors": errors}

View File

@@ -0,0 +1,51 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.database import get_db
from app.models.models import ConfigurationItem, CIRelationship, Location, User, CIClass, CIType
from app.middleware.auth import get_current_user
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
@router.get("/stats")
async def get_stats(db: AsyncSession = Depends(get_db), _user: User = Depends(get_current_user)):
total = (await db.execute(
select(func.count()).select_from(ConfigurationItem.__table__).where(
ConfigurationItem.deleted_at.is_(None)
)
)).scalar()
by_status = (await db.execute(
select(ConfigurationItem.status, func.count())
.where(ConfigurationItem.deleted_at.is_(None))
.group_by(ConfigurationItem.status)
)).all()
by_class = (await db.execute(
select(CIClass.name, func.count(ConfigurationItem.id))
.join(CIType, CIType.class_id == CIClass.id)
.join(ConfigurationItem, ConfigurationItem.ci_type_id == CIType.id)
.where(ConfigurationItem.deleted_at.is_(None))
.group_by(CIClass.name)
)).all()
total_relationships = (await db.execute(
select(func.count()).select_from(CIRelationship.__table__).where(
CIRelationship.deleted_at.is_(None)
)
)).scalar()
total_locations = (await db.execute(
select(func.count()).select_from(Location.__table__).where(
Location.deleted_at.is_(None)
)
)).scalar()
return {
"total_cis": total,
"by_status": {s: c for s, c in by_status},
"by_class": {n: c for n, c in by_class},
"total_relationships": total_relationships,
"total_locations": total_locations,
}

View File

@@ -0,0 +1,60 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.models.models import CIClass, CIType, Location, User
from app.schemas.schemas import (
CIClassCreate, CIClassResponse, CITypeCreate, CITypeResponse,
LocationCreate, LocationResponse
)
from app.middleware.auth import require_viewer, require_editor
router = APIRouter(prefix="/api", tags=["reference"])
@router.get("/classes", response_model=list[CIClassResponse])
async def list_classes(db: AsyncSession = Depends(get_db), _user: User = Depends(require_viewer)):
result = await db.execute(select(CIClass).where(CIClass.deleted_at.is_(None)).order_by(CIClass.name))
return [CIClassResponse.model_validate(c) for c in result.scalars().all()]
@router.post("/classes", response_model=CIClassResponse, status_code=201)
async def create_class(body: CIClassCreate, db: AsyncSession = Depends(get_db), _user: User = Depends(require_editor)):
cls = CIClass(**body.model_dump())
db.add(cls)
await db.flush()
await db.refresh(cls)
return CIClassResponse.model_validate(cls)
@router.get("/types", response_model=list[CITypeResponse])
async def list_types(class_id: str = None, db: AsyncSession = Depends(get_db), _user: User = Depends(require_viewer)):
q = select(CIType).where(CIType.deleted_at.is_(None))
if class_id:
q = q.where(CIType.class_id == class_id)
result = await db.execute(q.order_by(CIType.name))
return [CITypeResponse.model_validate(t) for t in result.scalars().all()]
@router.post("/types", response_model=CITypeResponse, status_code=201)
async def create_type(body: CITypeCreate, db: AsyncSession = Depends(get_db), _user: User = Depends(require_editor)):
ct = CIType(**body.model_dump())
db.add(ct)
await db.flush()
await db.refresh(ct)
return CITypeResponse.model_validate(ct)
@router.get("/locations", response_model=list[LocationResponse])
async def list_locations(db: AsyncSession = Depends(get_db), _user: User = Depends(require_viewer)):
result = await db.execute(select(Location).where(Location.deleted_at.is_(None)).order_by(Location.name))
return [LocationResponse.model_validate(l) for l in result.scalars().all()]
@router.post("/locations", response_model=LocationResponse, status_code=201)
async def create_location(body: LocationCreate, db: AsyncSession = Depends(get_db), _user: User = Depends(require_editor)):
loc = Location(**body.model_dump())
db.add(loc)
await db.flush()
await db.refresh(loc)
return LocationResponse.model_validate(loc)

View File

View File

@@ -0,0 +1,314 @@
from __future__ import annotations
from datetime import date, datetime
from uuid import UUID
from pydantic import BaseModel, Field, ConfigDict
from typing import Optional
# ── Auth ────────────────────────────────────────────────
class UserCreate(BaseModel):
username: str
email: str
full_name: Optional[str] = None
role: str = "viewer"
team: Optional[str] = None
password: str
class UserResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
username: str
email: str
full_name: Optional[str]
role: str
team: Optional[str]
is_active: bool
created_at: datetime
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
class LoginRequest(BaseModel):
username: str
password: str
# ── Locations ───────────────────────────────────────────
class LocationCreate(BaseModel):
name: str
description: Optional[str] = None
parent_id: Optional[UUID] = None
metadata: dict = {}
class LocationResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
name: str
description: Optional[str]
parent_id: Optional[UUID]
metadata: dict
created_at: datetime
# ── CI Classes / Types ──────────────────────────────────
class CIClassCreate(BaseModel):
name: str
description: Optional[str] = None
parent_id: Optional[UUID] = None
icon: Optional[str] = None
class CIClassResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
name: str
description: Optional[str]
parent_id: Optional[UUID]
icon: Optional[str]
class CITypeCreate(BaseModel):
class_id: UUID
name: str
description: Optional[str] = None
class CITypeResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
class_id: UUID
name: str
description: Optional[str]
# ── Configuration Item ──────────────────────────────────
class CICreate(BaseModel):
ci_type_id: UUID
name: str
description: Optional[str] = None
status: str = "active"
location_id: Optional[UUID] = None
serial_number: Optional[str] = None
asset_tag: Optional[str] = None
purchase_date: Optional[date] = None
warranty_expiry: Optional[date] = None
attributes: dict = {}
tags: list[str] = []
class CIUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
status: Optional[str] = None
location_id: Optional[UUID] = None
serial_number: Optional[str] = None
asset_tag: Optional[str] = None
purchase_date: Optional[date] = None
warranty_expiry: Optional[date] = None
attributes: Optional[dict] = None
tags: Optional[list[str]] = None
class CIResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
ci_type_id: UUID
ci_type: Optional[CITypeResponse] = None
name: str
description: Optional[str]
status: str
location_id: Optional[UUID]
location: Optional[LocationResponse] = None
serial_number: Optional[str]
asset_tag: Optional[str]
purchase_date: Optional[date]
warranty_expiry: Optional[date]
attributes: dict
tags: list[str]
version: int
created_at: datetime
updated_at: datetime
class CIDetailResponse(CIResponse):
ip_addresses: list[IPAddressResponse] = []
network_interfaces: list[NetworkInterfaceResponse] = []
hardware_detail: Optional[HardwareDetailResponse] = None
software_instances: list[SoftwareInstanceResponse] = []
relationships_out: list[RelationshipResponse] = []
relationships_in: list[RelationshipResponse] = []
owners: list[OwnerResponse] = []
# ── IP Address ──────────────────────────────────────────
class IPAddressCreate(BaseModel):
ip_address: str
subnet_mask: Optional[str] = None
gateway: Optional[str] = None
is_primary: bool = False
vlan_id: Optional[int] = None
class IPAddressResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
ci_id: UUID
ip_address: str
subnet_mask: Optional[str]
gateway: Optional[str]
is_primary: bool
vlan_id: Optional[int]
# ── Network Interface ───────────────────────────────────
class NetworkInterfaceCreate(BaseModel):
name: str
mac_address: Optional[str] = None
speed_mbps: Optional[int] = None
interface_type: str = "ethernet"
is_up: bool = True
ip_address_id: Optional[UUID] = None
class NetworkInterfaceResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
ci_id: UUID
name: str
mac_address: Optional[str]
speed_mbps: Optional[int]
interface_type: str
is_up: bool
ip_address_id: Optional[UUID]
# ── Hardware Detail ─────────────────────────────────────
class HardwareDetailCreate(BaseModel):
manufacturer: Optional[str] = None
model: Optional[str] = None
cpu_model: Optional[str] = None
cpu_cores: Optional[int] = None
ram_gb: Optional[float] = None
storage_gb: Optional[float] = None
storage_type: Optional[str] = None
form_factor: Optional[str] = None
specs: dict = {}
class HardwareDetailResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
ci_id: UUID
manufacturer: Optional[str]
model: Optional[str]
cpu_model: Optional[str]
cpu_cores: Optional[int]
ram_gb: Optional[float]
storage_gb: Optional[float]
storage_type: Optional[str]
form_factor: Optional[str]
specs: dict
# ── Software Instance ───────────────────────────────────
class SoftwareInstanceCreate(BaseModel):
name: str
vendor: Optional[str] = None
version: Optional[str] = None
port: Optional[int] = None
protocol: Optional[str] = None
config: dict = {}
class SoftwareInstanceResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
ci_id: UUID
name: str
vendor: Optional[str]
version: Optional[str]
port: Optional[int]
protocol: Optional[str]
config: dict
# ── Relationships ───────────────────────────────────────
class RelationshipCreate(BaseModel):
source_ci_id: UUID
target_ci_id: UUID
relationship: str
description: Optional[str] = None
metadata: dict = {}
class RelationshipResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
source_ci_id: UUID
target_ci_id: UUID
relationship: str
description: Optional[str]
metadata: dict
created_at: datetime
# ── Owner ───────────────────────────────────────────────
class OwnerCreate(BaseModel):
user_id: UUID
role: str = "owner"
class OwnerResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
ci_id: UUID
user_id: UUID
role: str
# ── Pagination ──────────────────────────────────────────
class PaginatedResponse(BaseModel):
items: list
total: int
page: int
page_size: int
pages: int
# ── Graph ───────────────────────────────────────────────
class GraphNode(BaseModel):
id: str
label: str
group: str
status: str
class GraphEdge(BaseModel):
source: str
target: str
label: str
class GraphData(BaseModel):
nodes: list[GraphNode]
edges: list[GraphEdge]
# ── Bulk Import ─────────────────────────────────────────
class BulkImportItem(BaseModel):
name: str
ci_type_name: str
description: Optional[str] = None
status: str = "active"
location_name: Optional[str] = None
attributes: dict = {}
tags: list[str] = []
class BulkImportRequest(BaseModel):
items: list[BulkImportItem]

View File

@@ -0,0 +1,387 @@
-- ============================================================
-- CMDB Schema v1.0 — PostgreSQL
-- ============================================================
-- Strategy: soft delete everywhere, audit trail via triggers,
-- JSONB for extensible attributes, full indexing.
-- ============================================================
BEGIN;
-- -----------------------------------------------------------
-- 0. Extensions
-- -----------------------------------------------------------
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- -----------------------------------------------------------
-- 1. Custom ENUM types
-- -----------------------------------------------------------
CREATE TYPE ci_status AS ENUM (
'active', 'inactive', 'maintenance', 'deprecated', 'planned'
);
CREATE TYPE relationship_type AS ENUM (
'depends_on', 'connected_to', 'hosted_on', 'runs_on',
'manages', 'contains', 'part_of', 'related_to'
);
CREATE TYPE change_action AS ENUM (
'create', 'update', 'delete', 'restore', 'relationship_add', 'relationship_remove'
);
CREATE TYPE user_role AS ENUM ('admin', 'editor', 'viewer');
-- -----------------------------------------------------------
-- 2. Locations
-- -----------------------------------------------------------
CREATE TABLE locations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL UNIQUE,
description TEXT,
parent_id UUID REFERENCES locations(id) ON DELETE SET NULL,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX idx_locations_parent ON locations(parent_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_locations_metadata ON locations USING gin(metadata);
-- -----------------------------------------------------------
-- 3. Users / Teams (owners)
-- -----------------------------------------------------------
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
username TEXT NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE,
full_name TEXT,
role user_role NOT NULL DEFAULT 'viewer',
team TEXT,
is_active BOOLEAN NOT NULL DEFAULT true,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX idx_users_role ON users(role) WHERE deleted_at IS NULL;
CREATE INDEX idx_users_team ON users(team) WHERE deleted_at IS NULL AND team IS NOT NULL;
-- -----------------------------------------------------------
-- 4. CI Classes (taxonomy)
-- -----------------------------------------------------------
CREATE TABLE ci_classes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL UNIQUE, -- e.g. 'Server', 'NetworkDevice', 'Service'
description TEXT,
parent_id UUID REFERENCES ci_classes(id) ON DELETE SET NULL,
icon TEXT, -- optional icon name for UI
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX idx_ci_classes_parent ON ci_classes(parent_id) WHERE deleted_at IS NULL;
-- -----------------------------------------------------------
-- 5. CI Types (subtypes within a class)
-- -----------------------------------------------------------
CREATE TABLE ci_types (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
class_id UUID NOT NULL REFERENCES ci_classes(id) ON DELETE CASCADE,
name TEXT NOT NULL, -- e.g. 'PhysicalServer', 'VirtualMachine', 'Switch'
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ,
UNIQUE(class_id, name)
);
CREATE INDEX idx_ci_types_class ON ci_types(class_id) WHERE deleted_at IS NULL;
-- -----------------------------------------------------------
-- 6. Attribute definitions (template per class)
-- -----------------------------------------------------------
CREATE TABLE attributes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
ci_class_id UUID NOT NULL REFERENCES ci_classes(id) ON DELETE CASCADE,
name TEXT NOT NULL, -- e.g. 'cpu_cores', 'ram_gb'
label TEXT NOT NULL, -- display label
data_type TEXT NOT NULL DEFAULT 'text', -- text, integer, float, boolean, date, ip, json
is_required BOOLEAN NOT NULL DEFAULT false,
is_unique BOOLEAN NOT NULL DEFAULT false,
default_value TEXT,
validation_regex TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ,
UNIQUE(ci_class_id, name)
);
CREATE INDEX idx_attributes_class ON attributes(ci_class_id) WHERE deleted_at IS NULL;
-- -----------------------------------------------------------
-- 7. Owners (many-to-many: CI ↔ User)
-- -----------------------------------------------------------
CREATE TABLE owners (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
ci_id UUID NOT NULL,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'owner', -- owner, admin, responsible
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(ci_id, user_id, role)
);
-- -----------------------------------------------------------
-- 8. Configuration Items (the core entity)
-- -----------------------------------------------------------
CREATE TABLE configuration_items (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
ci_type_id UUID NOT NULL REFERENCES ci_types(id) ON DELETE RESTRICT,
name TEXT NOT NULL,
description TEXT,
status ci_status NOT NULL DEFAULT 'active',
location_id UUID REFERENCES locations(id) ON DELETE SET NULL,
serial_number TEXT,
asset_tag TEXT,
purchase_date DATE,
warranty_expiry DATE,
attributes JSONB DEFAULT '{}', -- extensible key-value store
tags TEXT[] DEFAULT '{}',
created_by UUID REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ,
version INTEGER NOT NULL DEFAULT 1
);
CREATE INDEX idx_ci_type ON configuration_items(ci_type_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_ci_status ON configuration_items(status) WHERE deleted_at IS NULL;
CREATE INDEX idx_ci_location ON configuration_items(location_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_ci_name ON configuration_items(name) WHERE deleted_at IS NULL;
CREATE INDEX idx_ci_serial ON configuration_items(serial_number) WHERE deleted_at IS NULL AND serial_number IS NOT NULL;
CREATE INDEX idx_ci_asset_tag ON configuration_items(asset_tag) WHERE deleted_at IS NULL AND asset_tag IS NOT NULL;
CREATE INDEX idx_ci_tags ON configuration_items USING gin(tags) WHERE deleted_at IS NULL;
CREATE INDEX idx_ci_attributes ON configuration_items USING gin(attributes) WHERE deleted_at IS NULL;
-- Unique constraint on name + type for active items
CREATE UNIQUE INDEX idx_ci_name_type_active
ON configuration_items(name, ci_type_id)
WHERE deleted_at IS NULL;
-- -----------------------------------------------------------
-- 9. IP Addresses
-- -----------------------------------------------------------
CREATE TABLE ip_addresses (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
ci_id UUID NOT NULL REFERENCES configuration_items(id) ON DELETE CASCADE,
ip_address INET NOT NULL,
subnet_mask INET,
gateway INET,
dns_servers INET[],
is_primary BOOLEAN NOT NULL DEFAULT false,
vlan_id INTEGER,
dhcp_enabled BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX idx_ip_address ON ip_addresses(ip_address) WHERE deleted_at IS NULL;
CREATE INDEX idx_ip_ci ON ip_addresses(ci_id) WHERE deleted_at IS NULL;
-- -----------------------------------------------------------
-- 10. Network Interfaces
-- -----------------------------------------------------------
CREATE TABLE network_interfaces (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
ci_id UUID NOT NULL REFERENCES configuration_items(id) ON DELETE CASCADE,
name TEXT NOT NULL, -- eth0, ens192, bond0
mac_address MACADDR,
speed_mbps INTEGER,
interface_type TEXT DEFAULT 'ethernet', -- ethernet, bond, bridge, vlan
is_up BOOLEAN DEFAULT true,
ip_address_id UUID REFERENCES ip_addresses(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX idx_nic_ci ON network_interfaces(ci_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_nic_mac ON network_interfaces(mac_address) WHERE deleted_at IS NULL AND mac_address IS NOT NULL;
-- -----------------------------------------------------------
-- 11. Hardware Details
-- -----------------------------------------------------------
CREATE TABLE hardware_details (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
ci_id UUID NOT NULL REFERENCES configuration_items(id) ON DELETE CASCADE,
manufacturer TEXT,
model TEXT,
cpu_model TEXT,
cpu_cores INTEGER,
ram_gb NUMERIC(10,2),
storage_gb NUMERIC(10,2),
storage_type TEXT, -- SSD, HDD, NVMe
form_factor TEXT, -- 1U, 2U,塔式, 刀片
power_supply TEXT,
bios_version TEXT,
serial_number TEXT,
specs JSONB DEFAULT '{}', -- extra hardware specs
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX idx_hw_ci ON hardware_details(ci_id) WHERE deleted_at IS NULL;
-- -----------------------------------------------------------
-- 12. Software Instances
-- -----------------------------------------------------------
CREATE TABLE software_instances (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
ci_id UUID NOT NULL REFERENCES configuration_items(id) ON DELETE CASCADE,
name TEXT NOT NULL,
vendor TEXT,
version TEXT,
license_key TEXT,
license_type TEXT, -- open_source, commercial, trial
install_path TEXT,
config_path TEXT,
port INTEGER,
protocol TEXT, -- tcp, udp
start_command TEXT,
service_user TEXT,
config JSONB DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX idx_sw_ci ON software_instances(ci_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_sw_name ON software_instances(name) WHERE deleted_at IS NULL;
-- -----------------------------------------------------------
-- 13. CI Relationships (graph edges)
-- -----------------------------------------------------------
CREATE TABLE ci_relationships (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
source_ci_id UUID NOT NULL REFERENCES configuration_items(id) ON DELETE CASCADE,
target_ci_id UUID NOT NULL REFERENCES configuration_items(id) ON DELETE CASCADE,
relationship relationship_type NOT NULL,
description TEXT,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ,
UNIQUE(source_ci_id, target_ci_id, relationship)
);
CREATE INDEX idx_rel_source ON ci_relationships(source_ci_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_rel_target ON ci_relationships(target_ci_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_rel_type ON ci_relationships(relationship) WHERE deleted_at IS NULL;
-- -----------------------------------------------------------
-- 14. Change Log (audit trail / versioning)
-- -----------------------------------------------------------
CREATE TABLE changelog (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
ci_id UUID NOT NULL REFERENCES configuration_items(id) ON DELETE CASCADE,
action change_action NOT NULL,
changed_by UUID REFERENCES users(id),
field_name TEXT,
old_value TEXT,
new_value TEXT,
snapshot JSONB, -- full CI snapshot at time of change
version INTEGER NOT NULL,
comment TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_changelog_ci ON changelog(ci_id);
CREATE INDEX idx_changelog_action ON changelog(action);
CREATE INDEX idx_changelog_created ON changelog(created_at);
CREATE INDEX idx_changelog_version ON changelog(ci_id, version);
-- -----------------------------------------------------------
-- 15. Audit Trigger Function
-- -----------------------------------------------------------
CREATE OR REPLACE FUNCTION audit_trigger_func()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'UPDATE' THEN
INSERT INTO changelog (ci_id, action, field_name, old_value, new_value, version, snapshot)
VALUES (
NEW.id,
'update',
'general',
row_to_json(OLD)::text,
row_to_json(NEW)::text,
NEW.version,
to_jsonb(NEW)
);
NEW.updated_at = now();
NEW.version = OLD.version + 1;
RETURN NEW;
ELSIF TG_OP = 'DELETE' THEN
INSERT INTO changelog (ci_id, action, snapshot, version)
VALUES (
OLD.id,
'delete',
to_jsonb(OLD),
OLD.version
);
RETURN OLD;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_ci_audit
AFTER UPDATE OR DELETE ON configuration_items
FOR EACH ROW
EXECUTE FUNCTION audit_trigger_func();
-- -----------------------------------------------------------
-- 16. Updated_at auto-trigger
-- -----------------------------------------------------------
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_locations_updated
BEFORE UPDATE ON locations
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER trg_users_updated
BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER trg_ci_classes_updated
BEFORE UPDATE ON ci_classes
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER trg_ci_types_updated
BEFORE UPDATE ON ci_types
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER trg_ip_updated
BEFORE UPDATE ON ip_addresses
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER trg_nic_updated
BEFORE UPDATE ON network_interfaces
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER trg_hw_updated
BEFORE UPDATE ON hardware_details
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER trg_sw_updated
BEFORE UPDATE ON software_instances
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
COMMIT;

View File

@@ -0,0 +1,178 @@
-- ============================================================
-- CMDB Seed Data — Homelab Scenario
-- 5 hosts, 10 VMs, 3 switches, 1 NAS, 5 services
-- ============================================================
BEGIN;
-- -----------------------------------------------------------
-- Users
-- -----------------------------------------------------------
INSERT INTO users (id, username, email, full_name, role, team, password_hash) VALUES
('a0000000-0000-0000-0000-000000000001', 'admin', 'admin@homelab.local', 'Admin User', 'admin', 'IT', crypt('admin123', gen_salt('bf'))),
('a0000000-0000-0000-0000-000000000002', 'operator','ops@homelab.local', 'Ops Engineer', 'editor', 'Operations', crypt('ops123', gen_salt('bf'))),
('a0000000-0000-0000-0000-000000000003', 'viewer', 'viewer@homelab.local', 'Read Only', 'viewer', 'External', crypt('view123', gen_salt('bf')));
-- -----------------------------------------------------------
-- Locations
-- -----------------------------------------------------------
INSERT INTO locations (id, name, description) VALUES
('b0000000-0000-0000-0000-000000000001', 'Homelab Rack 1', 'Main 42U rack in basement'),
('b0000000-0000-0000-0000-000000000002', 'Office Desk', 'Home office desk area'),
('b0000000-0000-0000-0000-000000000003', 'Cloud — Hetzner', 'Hetzner CX31 VPS');
-- -----------------------------------------------------------
-- CI Classes & Types
-- -----------------------------------------------------------
INSERT INTO ci_classes (id, name, description, icon) VALUES
('c0000000-0000-0000-0000-000000000001', 'Hardware', 'Physical hardware devices', 'server'),
('c0000000-0000-0000-0000-000000000002', 'NetworkDevice', 'Network infrastructure', 'router'),
('c0000000-0000-0000-0000-000000000003', 'Software', 'Software and services', 'software'),
('c0000000-0000-0000-0000-000000000004', 'Storage', 'Storage systems', 'storage');
INSERT INTO ci_types (id, class_id, name, description) VALUES
('d0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000001', 'PhysicalServer', 'Bare-metal server'),
('d0000000-0000-0000-0000-000000000002', 'c0000000-0000-0000-0000-000000000001', 'VirtualMachine', 'Virtual machine'),
('d0000000-0000-0000-0000-000000000003', 'c0000000-0000-0000-0000-000000000002', 'Switch', 'Network switch'),
('d0000000-0000-0000-0000-000000000004', 'c0000000-0000-0000-0000-000000000002', 'Router', 'Router / firewall'),
('d0000000-0000-0000-0000-000000000005', 'c0000000-0000-0000-0000-000000000003', 'Application', 'Application / service'),
('d0000000-0000-0000-0000-000000000006', 'c0000000-0000-0000-0000-000000000004', 'NAS', 'Network attached storage');
-- -----------------------------------------------------------
-- Physical Servers (5)
-- -----------------------------------------------------------
INSERT INTO configuration_items (id, ci_type_id, name, description, status, location_id, attributes, tags) VALUES
('e0000000-0000-0000-0000-000000000001', 'd0000000-0000-0000-0000-000000000001', 'proxmox-01', 'Primary Proxmox VE host', 'active', 'b0000000-0000-0000-0000-000000000001', '{"os": "Proxmox VE 8.1", "cpu": "Intel Xeon E-2278G", "ram_gb": 64, "storage_tb": 2.0}', ARRAY['proxmox','production']),
('e0000000-0000-0000-0000-000000000002', 'd0000000-0000-0000-0000-000000000001', 'proxmox-02', 'Secondary Proxmox VE host', 'active', 'b0000000-0000-0000-0000-000000000001', '{"os": "Proxmox VE 8.1", "cpu": "Intel Xeon E-2278G", "ram_gb": 64, "storage_tb": 4.0}', ARRAY['proxmox','production']),
('e0000000-0000-0000-0000-000000000003', 'd0000000-0000-0000-0000-000000000001', 'esxi-01', 'VMware ESXi host (legacy)', 'maintenance', 'b0000000-0000-0000-0000-000000000001', '{"os": "ESXi 7.0U3", "cpu": "AMD EPYC 7302P", "ram_gb": 128, "storage_tb": 8.0}', ARRAY['vmware','legacy']),
('e0000000-0000-0000-0000-000000000004', 'd0000000-0000-0000-0000-000000000001', 'backup-nas', 'Synology NAS for backups', 'active', 'b0000000-0000-0000-0000-000000000001', '{"os": "DSM 7.2", "cpu": "Intel Celeron J4125", "ram_gb": 8, "storage_tb": 16.0}', ARRAY['synology','backup']),
('e0000000-0000-0000-0000-000000000005', 'd0000000-0000-0000-0000-000000000001', 'hetzner-01', 'Hetzner Cloud CX31', 'active', 'b0000000-0000-0000-0000-000000000003', '{"os": "Ubuntu 22.04", "cpu": "AMD EPYC (shared 4 vCPU)", "ram_gb": 8, "storage_gb": 160}', ARRAY['cloud','hetzner']);
-- -----------------------------------------------------------
-- Virtual Machines (10)
-- -----------------------------------------------------------
INSERT INTO configuration_items (id, ci_type_id, name, description, status, location_id, attributes, tags) VALUES
('e0000000-0000-0000-0000-000000000010', 'd0000000-0000-0000-0000-000000000002', 'vm-plex', 'Plex Media Server', 'active', 'b0000000-0000-0000-0000-000000000001', '{"os": "Ubuntu 22.04", "cpu_cores": 4, "ram_gb": 16, "disk_gb": 500}', ARRAY['plex','media']),
('e0000000-0000-0000-0000-000000000011', 'd0000000-0000-0000-0000-000000000002', 'vm-nextcloud', 'Nextcloud file sync', 'active', 'b0000000-0000-0000-0000-000000000001', '{"os": "Ubuntu 22.04", "cpu_cores": 2, "ram_gb": 4, "disk_gb": 100}', ARRAY['nextcloud','files']),
('e0000000-0000-0000-0000-000000000012', 'd0000000-0000-0000-0000-000000000002', 'vm-gitlab', 'GitLab CE instance', 'active', 'b0000000-0000-0000-0000-000000000001', '{"os": "Ubuntu 22.04", "cpu_cores": 4, "ram_gb": 8, "disk_gb": 200}', ARRAY['gitlab','dev']),
('e0000000-0000-0000-0000-000000000013', 'd0000000-0000-0000-0000-000000000002', 'vm-monitoring', 'Prometheus + Grafana', 'active', 'b0000000-0000-0000-0000-000000000001', '{"os": "Ubuntu 22.04", "cpu_cores": 2, "ram_gb": 8, "disk_gb": 100}', ARRAY['monitoring','observability']),
('e0000000-0000-0000-0000-000000000014', 'd0000000-0000-0000-0000-000000000002', 'vm-dns', 'Pi-hole / AdGuard DNS', 'active', 'b0000000-0000-0000-0000-000000000001', '{"os": "Debian 12", "cpu_cores": 1, "ram_gb": 2, "disk_gb": 20}', ARRAY['dns','network']),
('e0000000-0000-0000-0000-000000000015', 'd0000000-0000-0000-0000-000000000002', 'vm-docker', 'Docker workload host', 'active', 'b0000000-0000-0000-0000-000000000001', '{"os": "Ubuntu 22.04", "cpu_cores": 4, "ram_gb": 16, "disk_gb": 250}', ARRAY['docker','containers']),
('e0000000-0000-0000-0000-000000000016', 'd0000000-0000-0000-0000-000000000002', 'vm-homeassistant','Home Assistant', 'active', 'b0000000-0000-0000-0000-000000000001', '{"os": "HAOS", "cpu_cores": 2, "ram_gb": 4, "disk_gb": 60}', ARRAY['homeassistant','iot']),
('e0000000-0000-0000-0000-000000000017', 'd0000000-0000-0000-0000-000000000002', 'vm-gaming', 'Windows gaming VM', 'inactive', 'b0000000-0000-0000-0000-000000000001', '{"os": "Windows 11", "cpu_cores": 8, "ram_gb": 32, "disk_gb": 500}', ARRAY['windows','gaming']),
('e0000000-0000-0000-0000-000000000018', 'd0000000-0000-0000-0000-000000000002', 'vm-jenkins', 'CI/CD Jenkins', 'active', 'b0000000-0000-0000-0000-000000000001', '{"os": "Ubuntu 22.04", "cpu_cores": 2, "ram_gb": 4, "disk_gb": 50}', ARRAY['jenkins','cicd']),
('e0000000-0000-0000-0000-000000000019', 'd0000000-0000-0000-0000-000000000002', 'vm-vaultwarden', 'Vaultwarden password mgr', 'active', 'b0000000-0000-0000-0000-000000000001', '{"os": "Debian 12", "cpu_cores": 1, "ram_gb": 2, "disk_gb": 10}', ARRAY['vaultwarden','security']);
-- -----------------------------------------------------------
-- Network Devices (3 — Mikrotik + 1 switch)
-- -----------------------------------------------------------
INSERT INTO configuration_items (id, ci_type_id, name, description, status, location_id, attributes, tags) VALUES
('e0000000-0000-0000-0000-000000000020', 'd0000000-0000-0000-0000-000000000004', 'mikrotik-core', 'MikroTik CCR1036 core router', 'active', 'b0000000-0000-0000-0000-000000000001', '{"firmware": "RouterOS 7.12", "ports": 36, "throughput_gbps": 36}', ARRAY['mikrotik','router']),
('e0000000-0000-0000-0000-000000000021', 'd0000000-0000-0000-0000-000000000003', 'mikrotik-access', 'MikroTik CRS328 access switch', 'active', 'b0000000-0000-0000-0000-000000000001', '{"firmware": "RouterOS 7.12", "ports": 28, "type": "managed"}', ARRAY['mikrotik','switch']),
('e0000000-0000-0000-0000-000000000022', 'd0000000-0000-0000-0000-000000000004', 'mikrotik-ap', 'MikroTik hAP ac² WiFi AP', 'active', 'b0000000-0000-0000-0000-000000000002', '{"firmware": "RouterOS 7.12", "wifi_standard": "802.11ac"}', ARRAY['mikrotik','wifi']);
-- -----------------------------------------------------------
-- NAS
-- -----------------------------------------------------------
INSERT INTO configuration_items (id, ci_type_id, name, description, status, location_id, attributes, tags) VALUES
('e0000000-0000-0000-0000-000000000030', 'd0000000-0000-0000-0000-000000000006', 'synology-ds920', 'Synology DS920+ NAS', 'active', 'b0000000-0000-0000-0000-000000000001', '{"model": "DS920+", "disks": 4, "total_tb": 16, "raid": "SHR-2"}', ARRAY['synology','nas']);
-- -----------------------------------------------------------
-- Software Instances (5 services)
-- -----------------------------------------------------------
INSERT INTO configuration_items (id, ci_type_id, name, description, status, attributes, tags) VALUES
('e0000000-0000-0000-0000-000000000040', 'd0000000-0000-0000-0000-000000000005', 'svc-plex', 'Plex Media Server', 'active', '{"port": 32400, "protocol": "tcp", "version": "1.40.0"}', ARRAY['plex','media']),
('e0000000-0000-0000-0000-000000000041', 'd0000000-0000-0000-0000-000000000005', 'svc-nextcloud', 'Nextcloud WebDAV', 'active', '{"port": 443, "protocol": "tcp", "version": "28.0"}', ARRAY['nextcloud','files']),
('e0000000-0000-0000-0000-000000000042', 'd0000000-0000-0000-0000-000000000005', 'svc-gitlab', 'GitLab CE', 'active', '{"port": 443, "protocol": "tcp", "version": "16.8"}', ARRAY['gitlab','dev']),
('e0000000-0000-0000-0000-000000000043', 'd0000000-0000-0000-0000-000000000005', 'svc-prometheus', 'Prometheus monitoring', 'active', '{"port": 9090, "protocol": "tcp", "version": "2.49"}', ARRAY['prometheus','monitoring']),
('e0000000-0000-0000-0000-000000000044', 'd0000000-0000-0000-0000-000000000005', 'svc-grafana', 'Grafana dashboards', 'active', '{"port": 3000, "protocol": "tcp", "version": "10.3"}', ARRAY['grafana','monitoring']);
-- -----------------------------------------------------------
-- IP Addresses
-- -----------------------------------------------------------
INSERT INTO ip_addresses (ci_id, ip_address, subnet_mask, gateway, is_primary, vlan_id) VALUES
('e0000000-0000-0000-0000-000000000001', '10.0.0.11/24', '255.255.255.0', '10.0.0.1', true, 10),
('e0000000-0000-0000-0000-000000000002', '10.0.0.12/24', '255.255.255.0', '10.0.0.1', true, 10),
('e0000000-0000-0000-0000-000000000003', '10.0.0.13/24', '255.255.255.0', '10.0.0.1', true, 10),
('e0000000-0000-0000-0000-000000000004', '10.0.0.14/24', '255.255.255.0', '10.0.0.1', true, 10),
('e0000000-0000-0000-0000-000000000005', '203.0.113.50/24','255.255.255.0', '203.0.113.1', true, NULL),
('e0000000-0000-0000-0000-000000000020', '10.0.0.1/24', '255.255.255.0', NULL, true, 10),
('e0000000-0000-0000-0000-000000000021', '10.0.0.2/24', '255.255.255.0', '10.0.0.1', true, 10),
('e0000000-0000-0000-0000-000000000022', '10.0.0.3/24', '255.255.255.0', '10.0.0.1', true, 20),
('e0000000-0000-0000-0000-000000000030', '10.0.0.15/24', '255.255.255.0', '10.0.0.1', true, 10);
-- VMs get DHCP from bridge
INSERT INTO ip_addresses (ci_id, ip_address, is_primary, vlan_id) VALUES
('e0000000-0000-0000-0000-000000000010', '10.0.0.101/24', true, 10),
('e0000000-0000-0000-0000-000000000011', '10.0.0.102/24', true, 10),
('e0000000-0000-0000-0000-000000000012', '10.0.0.103/24', true, 10),
('e0000000-0000-0000-0000-000000000013', '10.0.0.104/24', true, 10),
('e0000000-0000-0000-0000-000000000014', '10.0.0.105/24', true, 10),
('e0000000-0000-0000-0000-000000000015', '10.0.0.106/24', true, 10),
('e0000000-0000-0000-0000-000000000016', '10.0.0.107/24', true, 10),
('e0000000-0000-0000-0000-000000000017', '10.0.0.108/24', true, 10),
('e0000000-0000-0000-0000-000000000018', '10.0.0.109/24', true, 10),
('e0000000-0000-0000-0000-000000000019', '10.0.0.110/24', true, 10);
-- -----------------------------------------------------------
-- Hardware Details (physical servers + NAS)
-- -----------------------------------------------------------
INSERT INTO hardware_details (ci_id, manufacturer, model, cpu_model, cpu_cores, ram_gb, storage_gb, storage_type, form_factor, specs) VALUES
('e0000000-0000-0000-0000-000000000001', 'SuperMicro', 'X11SCL-IF', 'Intel Xeon E-2278G', 8, 64, 2000, 'NVMe', '1U', '{"ipmi": true, "gbe_ports": 2}'),
('e0000000-0000-0000-0000-000000000002', 'SuperMicro', 'X11SCL-IF', 'Intel Xeon E-2278G', 8, 64, 4000, 'NVMe', '1U', '{"ipmi": true, "gbe_ports": 2}'),
('e0000000-0000-0000-0000-000000000003', 'Dell', 'R740xd', 'AMD EPYC 7302P', 16, 128, 8000, 'SSD', '2U', '{"idrac": true, "gbe_ports": 4}'),
('e0000000-0000-0000-0000-000000000030', 'Synology', 'DS920+', 'Intel Celeron J4125', 4, 8, 16000, 'HDD', 'Desktop', '{"expandable": true, "nvme_cache": true}');
-- -----------------------------------------------------------
-- Network Interfaces (sample for hosts)
-- -----------------------------------------------------------
INSERT INTO network_interfaces (ci_id, name, mac_address, speed_mbps, interface_type, is_up) VALUES
('e0000000-0000-0000-0000-000000000001', 'enp1s0', 'AA:BB:CC:01:00:01', 1000, 'ethernet', true),
('e0000000-0000-0000-0000-000000000001', 'enp2s0', 'AA:BB:CC:01:00:02', 1000, 'ethernet', true),
('e0000000-0000-0000-0000-000000000002', 'enp1s0', 'AA:BB:CC:02:00:01', 1000, 'ethernet', true),
('e0000000-0000-0000-0000-000000000020', 'ether1', 'CC:DD:EE:01:00:01', 10000, 'ethernet', true),
('e0000000-0000-0000-0000-000000000020', 'bond1', 'CC:DD:EE:01:00:02', 20000, 'bond', true);
-- -----------------------------------------------------------
-- CI Relationships
-- -----------------------------------------------------------
-- VMs hosted on physical hosts
INSERT INTO ci_relationships (source_ci_id, target_ci_id, relationship, description) VALUES
('e0000000-0000-0000-0000-000000000010', 'e0000000-0000-0000-0000-000000000001', 'hosted_on', 'Plex runs on proxmox-01'),
('e0000000-0000-0000-0000-000000000011', 'e0000000-0000-0000-0000-000000000001', 'hosted_on', 'Nextcloud on proxmox-01'),
('e0000000-0000-0000-0000-000000000012', 'e0000000-0000-0000-0000-000000000001', 'hosted_on', 'GitLab on proxmox-01'),
('e0000000-0000-0000-0000-000000000013', 'e0000000-0000-0000-0000-000000000002', 'hosted_on', 'Monitoring on proxmox-02'),
('e0000000-0000-0000-0000-000000000014', 'e0000000-0000-0000-0000-000000000001', 'hosted_on', 'DNS on proxmox-01'),
('e0000000-0000-0000-0000-000000000015', 'e0000000-0000-0000-0000-000000000002', 'hosted_on', 'Docker host on proxmox-02'),
('e0000000-0000-0000-0000-000000000016', 'e0000000-0000-0000-0000-000000000001', 'hosted_on', 'HA on proxmox-01'),
('e0000000-0000-0000-0000-000000000017', 'e0000000-0000-0000-0000-000000000003', 'hosted_on', 'Gaming VM on esxi-01'),
('e0000000-0000-0000-0000-000000000018', 'e0000000-0000-0000-0000-000000000002', 'hosted_on', 'Jenkins on proxmox-02'),
('e0000000-0000-0000-0000-000000000019', 'e0000000-0000-0000-0000-000000000002', 'hosted_on', 'Vaultwarden on proxmox-02');
-- Services depend on VMs
INSERT INTO ci_relationships (source_ci_id, target_ci_id, relationship, description) VALUES
('e0000000-0000-0000-0000-000000000040', 'e0000000-0000-0000-0000-000000000010', 'runs_on', 'Plex service on vm-plex'),
('e0000000-0000-0000-0000-000000000041', 'e0000000-0000-0000-0000-000000000011', 'runs_on', 'Nextcloud on vm-nextcloud'),
('e0000000-0000-0000-0000-000000000042', 'e0000000-0000-0000-0000-000000000012', 'runs_on', 'GitLab on vm-gitlab'),
('e0000000-0000-0000-0000-000000000043', 'e0000000-0000-0000-0000-000000000013', 'runs_on', 'Prometheus on vm-monitoring'),
('e0000000-0000-0000-0000-000000000044', 'e0000000-0000-0000-0000-000000000013', 'runs_on', 'Grafana on vm-monitoring');
-- Network relationships
INSERT INTO ci_relationships (source_ci_id, target_ci_id, relationship, description) VALUES
('e0000000-0000-0000-0000-000000000001', 'e0000000-0000-0000-0000-000000000020', 'connected_to', 'proxmox-01 to core router'),
('e0000000-0000-0000-0000-000000000002', 'e0000000-0000-0000-0000-000000000020', 'connected_to', 'proxmox-02 to core router'),
('e0000000-0000-0000-0000-000000000020', 'e0000000-0000-0000-0000-000000000021', 'connected_to', 'Core router to access switch'),
('e0000000-0000-0000-0000-000000000021', 'e0000000-0000-0000-0000-000000000022', 'connected_to', 'Access switch to WiFi AP');
-- Backup dependencies
INSERT INTO ci_relationships (source_ci_id, target_ci_id, relationship, description) VALUES
('e0000000-0000-0000-0000-000000000004', 'e0000000-0000-0000-0000-000000000001', 'depends_on', 'NAS backs up proxmox-01'),
('e0000000-0000-0000-0000-000000000004', 'e0000000-0000-0000-0000-000000000002', 'depends_on', 'NAS backs up proxmox-02');
-- Owners
INSERT INTO owners (ci_id, user_id, role) VALUES
('e0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', 'owner'),
('e0000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000001', 'owner'),
('e0000000-0000-0000-0000-000000000043', 'a0000000-0000-0000-0000-000000000002', 'responsible'),
('e0000000-0000-0000-0000-000000000044', 'a0000000-0000-0000-0000-000000000002', 'responsible');
COMMIT;

View File

@@ -0,0 +1,74 @@
-- ============================================================
-- Audit Trigger Migration — Run on production after 001
-- Adds per-field audit triggers to configuration_items
-- ============================================================
BEGIN;
-- Fine-grained audit: capture individual field changes
CREATE OR REPLACE FUNCTION audit_ci_fields_func()
RETURNS TRIGGER AS $$
DECLARE
col TEXT;
old_val TEXT;
new_val TEXT;
BEGIN
IF TG_OP = 'UPDATE' THEN
FOR col IN SELECT unnest(ARRAY[
'name', 'description', 'status', 'location_id',
'serial_number', 'asset_tag', 'purchase_date', 'warranty_expiry',
'tags'
])
LOOP
old_val := row_to_json(OLD) ->> col;
new_val := row_to_json(NEW) ->> col;
IF old_val IS DISTINCT FROM new_val THEN
INSERT INTO changelog (ci_id, action, field_name, old_value, new_value, version, snapshot)
VALUES (NEW.id, 'update', col, old_val, NEW.version, NEW.version, to_jsonb(NEW));
END IF;
END LOOP;
-- JSONB attributes changes
IF OLD.attributes IS DISTINCT FROM NEW.attributes THEN
INSERT INTO changelog (ci_id, action, field_name, old_value, new_value, version, snapshot)
VALUES (NEW.id, 'update', 'attributes', OLD.attributes::text, NEW.attributes::text, NEW.version, to_jsonb(NEW));
END IF;
NEW.updated_at = now();
NEW.version = OLD.version + 1;
RETURN NEW;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_ci_audit ON configuration_items;
CREATE TRIGGER trg_ci_field_audit
AFTER UPDATE ON configuration_items
FOR EACH ROW
EXECUTE FUNCTION audit_ci_fields_func();
-- Relationship audit
CREATE OR REPLACE FUNCTION audit_relationship_func()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO changelog (ci_id, action, field_name, new_value, version, snapshot)
VALUES (NEW.source_ci_id, 'relationship_add', NEW.relationship::text,
NEW.target_ci_id::text, 0, to_jsonb(NEW));
RETURN NEW;
ELSIF TG_OP = 'DELETE' THEN
INSERT INTO changelog (ci_id, action, field_name, old_value, version, snapshot)
VALUES (OLD.source_ci_id, 'relationship_remove', OLD.relationship::text,
OLD.target_ci_id::text, 0, to_jsonb(OLD));
RETURN OLD;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_relationship_audit
AFTER INSERT OR DELETE ON ci_relationships
FOR EACH ROW
EXECUTE FUNCTION audit_relationship_func();
COMMIT;

3
backend/pyproject.toml Normal file
View File

@@ -0,0 +1,3 @@
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]

14
backend/requirements.txt Normal file
View File

@@ -0,0 +1,14 @@
fastapi==0.109.0
uvicorn[standard]==0.27.0
sqlalchemy[asyncio]==2.0.25
asyncpg==0.29.0
alembic==1.13.1
pydantic==2.5.3
pydantic-settings==2.1.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.6
httpx==0.26.0
pytest==7.4.4
pytest-asyncio==0.23.3
pytest-httpx==0.27.0

199
backend/tests/test_api.py Normal file
View File

@@ -0,0 +1,199 @@
import pytest
from httpx import AsyncClient, ASGITransport
from app.main import app
@pytest.fixture
async def client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
@pytest.fixture
async def admin_token(client: AsyncClient):
resp = await client.post("/api/auth/login", json={"username": "admin", "password": "admin123"})
return resp.json()["access_token"]
@pytest.fixture
async def auth_headers(admin_token: str):
return {"Authorization": f"Bearer {admin_token}"}
async def test_health(client: AsyncClient):
resp = await client.get("/api/health")
assert resp.status_code == 200
assert resp.json()["status"] == "healthy"
async def test_login_success(client: AsyncClient):
resp = await client.post("/api/auth/login", json={"username": "admin", "password": "admin123"})
assert resp.status_code == 200
assert "access_token" in resp.json()
async def test_login_invalid(client: AsyncClient):
resp = await client.post("/api/auth/login", json={"username": "admin", "password": "wrong"})
assert resp.status_code == 401
async def test_get_me(client: AsyncClient, auth_headers: dict):
resp = await client.get("/api/auth/me", headers=auth_headers)
assert resp.status_code == 200
assert resp.json()["username"] == "admin"
async def test_list_classes(client: AsyncClient, auth_headers: dict):
resp = await client.get("/api/classes", headers=auth_headers)
assert resp.status_code == 200
data = resp.json()
assert len(data) >= 4 # Hardware, NetworkDevice, Software, Storage
async def test_list_types(client: AsyncClient, auth_headers: dict):
resp = await client.get("/api/types", headers=auth_headers)
assert resp.status_code == 200
assert len(resp.json()) >= 6
async def test_list_locations(client: AsyncClient, auth_headers: dict):
resp = await client.get("/api/locations", headers=auth_headers)
assert resp.status_code == 200
assert len(resp.json()) >= 3
async def test_list_cis(client: AsyncClient, auth_headers: dict):
resp = await client.get("/api/ci", headers=auth_headers)
assert resp.status_code == 200
data = resp.json()
assert data["total"] >= 25
assert data["page"] == 1
async def test_list_cis_pagination(client: AsyncClient, auth_headers: dict):
resp = await client.get("/api/ci", headers=auth_headers, params={"page": 1, "page_size": 5})
data = resp.json()
assert len(data["items"]) == 5
assert data["page_size"] == 5
async def test_list_cis_search(client: AsyncClient, auth_headers: dict):
resp = await client.get("/api/ci", headers=auth_headers, params={"search": "proxmox"})
data = resp.json()
assert data["total"] >= 2
assert all("proxmox" in i["name"].lower() for i in data["items"])
async def test_list_cis_filter_status(client: AsyncClient, auth_headers: dict):
resp = await client.get("/api/ci", headers=auth_headers, params={"status": "active"})
data = resp.json()
assert all(i["status"] == "active" for i in data["items"])
async def test_get_ci_detail(client: AsyncClient, auth_headers: dict):
list_resp = await client.get("/api/ci", headers=auth_headers)
ci_id = list_resp.json()["items"][0]["id"]
resp = await client.get(f"/api/ci/{ci_id}", headers=auth_headers)
assert resp.status_code == 200
ci = resp.json()
assert "ip_addresses" in ci
assert "relationships_out" in ci
assert "relationships_in" in ci
async def test_create_ci(client: AsyncClient, auth_headers: dict):
types_resp = await client.get("/api/types", headers=auth_headers)
type_id = types_resp.json()[0]["id"]
resp = await client.post("/api/ci", headers=auth_headers, json={
"name": "test-ci-auto",
"ci_type_id": type_id,
"description": "Auto-created for testing",
"status": "active",
"tags": ["test", "automated"],
"attributes": {"cpu_cores": 2, "ram_gb": 4},
})
assert resp.status_code == 201
ci = resp.json()
assert ci["name"] == "test-ci-auto"
assert ci["version"] == 1
async def test_update_ci(client: AsyncClient, auth_headers: dict):
list_resp = await client.get("/api/ci", headers=auth_headers)
ci_id = list_resp.json()["items"][0]["id"]
resp = await client.patch(f"/api/ci/{ci_id}", headers=auth_headers, json={
"description": "Updated description"
})
assert resp.status_code == 200
assert resp.json()["description"] == "Updated description"
async def test_delete_ci_soft(client: AsyncClient, auth_headers: dict):
# Create then delete
types_resp = await client.get("/api/types", headers=auth_headers)
type_id = types_resp.json()[0]["id"]
create_resp = await client.post("/api/ci", headers=auth_headers, json={
"name": "to-be-deleted",
"ci_type_id": type_id,
"status": "active",
})
ci_id = create_resp.json()["id"]
del_resp = await client.delete(f"/api/ci/{ci_id}", headers=auth_headers)
assert del_resp.status_code == 204
# Should not appear in list
list_resp = await client.get("/api/ci", headers=auth_headers, params={"search": "to-be-deleted"})
assert list_resp.json()["total"] == 0
async def test_get_nonexistent_ci(client: AsyncClient, auth_headers: dict):
resp = await client.get("/api/ci/00000000-0000-0000-0000-000000000000", headers=auth_headers)
assert resp.status_code == 404
async def test_dashboard_stats(client: AsyncClient, auth_headers: dict):
resp = await client.get("/api/dashboard/stats", headers=auth_headers)
assert resp.status_code == 200
stats = resp.json()
assert stats["total_cis"] > 0
assert stats["total_relationships"] > 0
async def test_graph(client: AsyncClient, auth_headers: dict):
resp = await client.get("/api/ci/graph/visualize", headers=auth_headers, params={"depth": 1})
assert resp.status_code == 200
graph = resp.json()
assert "nodes" in graph
assert "edges" in graph
assert len(graph["nodes"]) > 0
async def test_bulk_import(client: AsyncClient, auth_headers: dict):
types_resp = await client.get("/api/types", headers=auth_headers)
type_name = types_resp.json()[0]["name"]
resp = await client.post("/api/ci/bulk/import", headers=auth_headers, json={
"items": [
{"name": "bulk-1", "ci_type_name": type_name, "status": "active"},
{"name": "bulk-2", "ci_type_name": type_name, "status": "active"},
]
})
assert resp.status_code == 201
assert resp.json()["created"] == 2
async def test_viewer_cannot_create(client: AsyncClient):
resp = await client.post("/api/auth/login", json={"username": "viewer", "password": "view123"})
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
types_resp = await client.get("/api/types", headers=headers)
type_id = types_resp.json()[0]["id"]
resp = await client.post("/api/ci", headers=headers, json={
"name": "should-fail",
"ci_type_id": type_id,
})
assert resp.status_code == 403
async def test_unauthorized_access(client: AsyncClient):
resp = await client.get("/api/ci")
assert resp.status_code == 403 # No auth header → HTTPBearer returns 403

69
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,69 @@
version: "3.9"
services:
postgres:
image: postgres:16-alpine
container_name: cmdb-postgres
environment:
POSTGRES_DB: cmdb
POSTGRES_USER: cmdb
POSTGRES_PASSWORD: cmdb_secret
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
- ./backend/migrations/001_initial_schema.sql:/docker-entrypoint-initdb.d/001.sql
- ./backend/migrations/002_seed_data.sql:/docker-entrypoint-initdb.d/002.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U cmdb"]
interval: 5s
timeout: 3s
retries: 5
redis:
image: redis:7-alpine
container_name: cmdb-redis
ports:
- "6379:6379"
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: cmdb-backend
environment:
DATABASE_URL: postgresql+asyncpg://cmdb:cmdb_secret@postgres:5432/cmdb
JWT_SECRET: ${JWT_SECRET:-super-secret-change-me}
REDIS_URL: redis://redis:6379/0
CORS_ORIGINS: '["http://localhost:3000","http://localhost:5173"]'
ports:
- "8000:8000"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: cmdb-frontend
ports:
- "3000:80"
depends_on:
- backend
nginx:
image: nginx:alpine
container_name: cmdb-nginx
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- backend
- frontend
volumes:
pgdata:

69
docker-compose.yml Normal file
View File

@@ -0,0 +1,69 @@
version: "3.9"
services:
postgres:
image: postgres:16-alpine
container_name: cmdb-postgres
environment:
POSTGRES_DB: cmdb
POSTGRES_USER: cmdb
POSTGRES_PASSWORD: cmdb_secret
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
- ./backend/migrations/001_initial_schema.sql:/docker-entrypoint-initdb.d/001.sql
- ./backend/migrations/002_seed_data.sql:/docker-entrypoint-initdb.d/002.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U cmdb"]
interval: 5s
timeout: 3s
retries: 5
redis:
image: redis:7-alpine
container_name: cmdb-redis
ports:
- "6379:6379"
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: cmdb-backend
environment:
DATABASE_URL: postgresql+asyncpg://cmdb:cmdb_secret@postgres:5432/cmdb
JWT_SECRET: ${JWT_SECRET:-super-secret-change-me}
REDIS_URL: redis://redis:6379/0
CORS_ORIGINS: '["http://localhost:3000","http://localhost:5173"]'
ports:
- "8000:8000"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: cmdb-frontend
ports:
- "3000:80"
depends_on:
- backend
nginx:
image: nginx:alpine
container_name: cmdb-nginx
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- backend
- frontend
volumes:
pgdata:

View File

@@ -0,0 +1,91 @@
# Ansible + CMDB Integration Guide
## Overview
Ansible can auto-discover infrastructure and register it as CIs in the CMDB.
## Setup
1. Install `ansible` and `requests` on your control node
2. Get a CMDB API token: `curl -X POST .../api/auth/login -d '{"username":"admin","password":"..."}'`
3. Set `CMDB_TOKEN` and `CMDB_URL` in your inventory
## Playbook: Import Hosts
```yaml
# playbooks/cmdb-sync.yml
---
- name: Sync Ansible inventory to CMDB
hosts: all
gather_facts: true
vars:
cmdb_url: "http://cmdb-host:8000/api"
cmdb_token: "{{ lookup('env', 'CMDB_TOKEN') }}"
tasks:
- name: Register host as CI
uri:
url: "{{ cmdb_url }}/ci"
method: POST
headers:
Authorization: "Bearer {{ cmdb_token }}"
Content-Type: "application/json"
body_format: json
body:
name: "{{ inventory_hostname }}"
ci_type_name: "{% if 'proxmox' in inventory_hostname %}PhysicalServer{% elif 'vm-' in inventory_hostname %}VirtualMachine{% else %}Application{% endif %}"
status: active
tags: "{{ group_names }}"
attributes:
os: "{{ ansible_distribution }} {{ ansible_distribution_version }}"
cpu_cores: "{{ ansible_processor_vcpus }}"
ram_mb: "{{ ansible_memtotal_mb }}"
ip: "{{ ansible_default_ipv4.address | default('N/A') }}"
kernel: "{{ ansible_kernel }}"
hostname: "{{ ansible_hostname }}"
status_code: [201, 409]
register: cmdb_result
- name: Show result
debug:
msg: "{{ inventory_hostname }} → {{ cmdb_result.status }}"
```
## Playbook: Update Software Versions
```yaml
- name: Update software instances in CMDB
hosts: all
tasks:
- name: Get installed packages
package_facts:
become: true
- name: Report key packages to CMDB
uri:
url: "{{ cmdb_url }}/ci/bulk/import"
method: POST
headers:
Authorization: "Bearer {{ cmdb_token }}"
body_format: json
body:
items: >-
{{
package_facts.packages.keys() | select('search', 'nginx|docker|postgresql|redis|prometheus') |
map(attribute='-', {
'name': item,
'ci_type_name': 'Application',
'attributes': {'version': package_facts.packages[item].version}
}) | list
}}
status_code: [201]
when: "'nginx' in package_facts.packages or 'docker-ce' in package_facts.packages"
```
## Best Practices
1. **Idempotent registration** — use 409 handling to update existing CIs
2. **Tag with inventory groups** — auto-tag CIs with Ansible group names
3. **Schedule via cron**`0 */6 * * * ansible-playbook cmdb-sync.yml`
4. **Use lookup plugins**`uri` plugin for API calls
5. **Store token securely** — use Ansible Vault for `CMDB_TOKEN`

132
docs/security.md Normal file
View File

@@ -0,0 +1,132 @@
# Security & Operations Guide
## PostgreSQL Security
### User Privileges
```sql
-- Create limited user for the application
CREATE USER cmdb_app WITH PASSWORD 'strong_password';
GRANT CONNECT ON DATABASE cmdb TO cmdb_app;
GRANT USAGE ON SCHEMA public TO cmdb_app;
GRANT SELECT, INSERT, UPDATE ON ALL TABLES IN SCHEMA public TO cmdb_app;
GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO cmdb_app;
-- Read-only user for reporting
CREATE USER cmdb_reader WITH PASSWORD 'reader_password';
GRANT CONNECT ON DATABASE cmdb TO cmdb_reader;
GRANT USAGE ON SCHEMA public TO cmdb_reader;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO cmdb_reader;
```
### SSL/TLS
```ini
# postgresql.conf
ssl = on
ssl_cert_file = '/etc/ssl/certs/server.crt'
ssl_key_file = '/etc/ssl/private/server.key'
ssl_min_protocol_version = 'TLSv1.2'
```
```bash
# pg_hba.conf — force SSL for remote connections
hostssl cmdb cmdb_app 10.0.0.0/24 scram-sha-256
```
## Backup Strategy
### pg_dump (logical backup)
```bash
# Daily backup script
#!/bin/bash
BACKUP_DIR="/var/backups/cmdb"
DATE=$(date +%Y%m%d_%H%M%S)
pg_dump -h localhost -U cmdb cmdb | gzip > "$BACKUP_DIR/cmdb_$DATE.sql.gz"
# Keep 30 days
find $BACKUP_DIR -name "*.sql.gz" -mtime +30 -delete
```
### pg_basebackup (physical backup)
```bash
# For PITR (Point-in-Time Recovery)
pg_basebackup -h localhost -U replicator -D /var/lib/cmdb-backup \
-Fp -Xs -P -R
```
### pgBackRest (enterprise)
```ini
# pgbackrest.conf
[cmdb]
pg1-path=/var/lib/postgresql/data
repo1-path=/var/lib/pgbackrest
repo1-retention-full=2
repo1-retention-diff=7
```
### Recovery
```bash
# From pg_dump
dropdb cmdb && createdb cmdb
zcat /var/backups/cmdb/cmdb_20240101_030000.sql.gz | psql -U cmdb cmdb
# From WAL replay
restore_command = 'cp /var/lib/pgbackrest/archive/cmdb/%f %p'
recovery_target_time = '2024-01-01 12:00:00'
```
## API Security
### Rate Limiting
Configured in `backend/.env`: `RATE_LIMIT_PER_MINUTE=120`
### CORS
```python
# Restrict in production
CORS_ORIGINS=["https://cmdb.yourdomain.com"]
```
### JWT Best Practices
- Rotate secrets quarterly
- Short expiration (60 min default)
- Store in HTTP-only cookies for web clients
- Validate `exp`, `iss`, `aud` claims
## Monitoring
### Prometheus Metrics (add to FastAPI)
```python
from prometheus_client import Counter, Histogram
REQUEST_COUNT = Counter('cmdb_requests_total', 'Total requests', ['method', 'endpoint'])
REQUEST_LATENCY = Histogram('cmdb_request_latency_seconds', 'Request latency', ['endpoint'])
```
### Health Checks
```bash
# Backend
curl http://localhost:8000/api/health
# → {"status": "healthy", "version": "1.0.0"}
# PostgreSQL
pg_isready -h localhost -p 5432 -U cmdb
```
### Log Aggregation
```yaml
# docker-compose logging
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
```
## Hardening Checklist
- [ ] Run containers as non-root
- [ ] Use read-only filesystem mounts where possible
- [ ] Enable seccomp/AppArmor profiles
- [ ] Scan images with Trivy/Snyk
- [ ] Rotate database passwords regularly
- [ ] Audit changelog table weekly
- [ ] Monitor for failed login attempts
- [ ] Set up alerting for 5xx errors

12
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CMDB</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

30
frontend/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "cmdb-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.15.4",
"@mui/material": "^5.15.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.3",
"axios": "^1.6.5",
"react-force-graph": "^2.1.0",
"notistack": "^3.0.1"
},
"devDependencies": {
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@vitejs/plugin-react": "^4.2.1",
"typescript": "^5.3.3",
"vite": "^5.0.12"
}
}

48
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,48 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { ThemeProvider, createTheme, CssBaseline } from '@mui/material';
import { SnackbarProvider } from 'notistack';
import Layout from './components/Layout';
import LoginPage from './pages/LoginPage';
import DashboardPage from './pages/DashboardPage';
import CIListPage from './pages/CIListPage';
import CIDetailPage from './pages/CIDetailPage';
import GraphPage from './pages/GraphPage';
const darkTheme = createTheme({
palette: {
mode: 'dark',
primary: { main: '#4fc3f7' },
secondary: { main: '#ff8a65' },
background: { default: '#0d1117', paper: '#161b22' },
},
typography: {
fontFamily: '"JetBrains Mono", "Fira Code", monospace',
},
});
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const token = localStorage.getItem('token');
if (!token) return <Navigate to="/login" />;
return <>{children}</>;
}
export default function App() {
return (
<ThemeProvider theme={darkTheme}>
<CssBaseline />
<SnackbarProvider maxSnack={3}>
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<ProtectedRoute><Layout /></ProtectedRoute>}>
<Route index element={<DashboardPage />} />
<Route path="ci" element={<CIListPage />} />
<Route path="ci/:id" element={<CIDetailPage />} />
<Route path="graph" element={<GraphPage />} />
</Route>
</Routes>
</BrowserRouter>
</SnackbarProvider>
</ThemeProvider>
);
}

View File

@@ -0,0 +1,72 @@
import { useState } from 'react';
import { Outlet, useNavigate } from 'react-router-dom';
import {
AppBar, Toolbar, Typography, Drawer, List, ListItemButton,
ListItemIcon, ListItemText, Box, IconButton, Avatar, Menu, MenuItem,
} from '@mui/material';
import {
Dashboard as DashboardIcon, Server as ServerIcon,
AccountTree as GraphIcon, Menu as MenuIcon, Logout as LogoutIcon,
} from '@mui/icons-material';
const DRAWER_WIDTH = 240;
const menuItems = [
{ text: 'Dashboard', icon: <DashboardIcon />, path: '/' },
{ text: 'Configuration Items', icon: <ServerIcon />, path: '/ci' },
{ text: 'Relationship Graph', icon: <GraphIcon />, path: '/graph' },
];
export default function Layout() {
const navigate = useNavigate();
const [mobileOpen, setMobileOpen] = useState(false);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const handleLogout = () => {
localStorage.removeItem('token');
navigate('/login');
};
return (
<Box sx={{ display: 'flex' }}>
<AppBar position="fixed" sx={{ zIndex: (t) => t.zIndex.drawer + 1 }}>
<Toolbar>
<IconButton color="inherit" edge="start" onClick={() => setMobileOpen(!mobileOpen)} sx={{ mr: 2 }}>
<MenuIcon />
</IconButton>
<Typography variant="h6" noWrap sx={{ flexGrow: 1 }}>CMDB</Typography>
<IconButton color="inherit" onClick={(e) => setAnchorEl(e.currentTarget)}>
<Avatar sx={{ width: 32, height: 32 }}>A</Avatar>
</IconButton>
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={() => setAnchorEl(null)}>
<MenuItem onClick={handleLogout}><LogoutIcon sx={{ mr: 1 }} /> Logout</MenuItem>
</Menu>
</Toolbar>
</AppBar>
<Drawer
variant="permanent"
sx={{
width: DRAWER_WIDTH,
flexShrink: 0,
'& .MuiDrawer-paper': { width: DRAWER_WIDTH, boxSizing: 'border-box', bgcolor: '#161b22' },
}}
>
<Toolbar />
<List>
{menuItems.map((item) => (
<ListItemButton key={item.text} onClick={() => navigate(item.path)}>
<ListItemIcon sx={{ color: '#4fc3f7' }}>{item.icon}</ListItemIcon>
<ListItemText primary={item.text} />
</ListItemButton>
))}
</List>
</Drawer>
<Box component="main" sx={{ flexGrow: 1, p: 3, ml: `${DRAWER_WIDTH}px` }}>
<Toolbar />
<Outlet />
</Box>
</Box>
);
}

9
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,9 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View File

@@ -0,0 +1,351 @@
import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Box, Typography, Card, CardContent, Grid, Chip, Button, TextField,
Dialog, DialogTitle, DialogContent, DialogActions, Tabs, Tab, Table,
TableBody, TableCell, TableContainer, TableHead, TableRow, Paper,
IconButton, Tooltip, Alert, CircularProgress, Divider, Select,
MenuItem, FormControl, InputLabel,
} from '@mui/material';
import { ArrowBack, Edit, Save, Link as LinkIcon, Hub } from '@mui/icons-material';
import { ciApi, CI } from '../services/api';
import { useSnackbar } from 'notistack';
interface CIDetail extends CI {
ip_addresses: any[];
network_interfaces: any[];
hardware_detail: any;
software_instances: any[];
relationships_out: any[];
relationships_in: any[];
owners: any[];
}
export default function CIDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { enqueueSnackbar } = useSnackbar();
const [ci, setCI] = useState<CIDetail | null>(null);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(false);
const [editData, setEditData] = useState<Partial<CI>>({});
const [tab, setTab] = useState(0);
const [relDialogOpen, setRelDialogOpen] = useState(false);
const [newRel, setNewRel] = useState({ target_ci_id: '', relationship: 'depends_on', description: '' });
const fetchCI = async () => {
if (!id) return;
try {
const res = await ciApi.get(id);
setCI(res.data as CIDetail);
} catch {
enqueueSnackbar('CI not found', { variant: 'error' });
navigate('/ci');
} finally {
setLoading(false);
}
};
useEffect(() => { fetchCI(); }, [id]);
const handleSave = async () => {
if (!id) return;
try {
await ciApi.update(id, editData);
enqueueSnackbar('CI updated', { variant: 'success' });
setEditing(false);
fetchCI();
} catch (err: any) {
enqueueSnackbar(err.response?.data?.detail || 'Update failed', { variant: 'error' });
}
};
const handleAddRelationship = async () => {
if (!id) return;
try {
await ciApi.addRelationship(id, { ...newRel, source_ci_id: id });
enqueueSnackbar('Relationship added', { variant: 'success' });
setRelDialogOpen(false);
setNewRel({ target_ci_id: '', relationship: 'depends_on', description: '' });
fetchCI();
} catch (err: any) {
enqueueSnackbar(err.response?.data?.detail || 'Failed', { variant: 'error' });
}
};
if (loading) return <CircularProgress />;
if (!ci) return null;
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3, gap: 2 }}>
<IconButton onClick={() => navigate('/ci')}><ArrowBack /></IconButton>
<Typography variant="h4" sx={{ color: '#4fc3f7' }}>{ci.name}</Typography>
<Chip label={ci.status} color={ci.status === 'active' ? 'success' : 'default'} />
<Typography variant="body2" color="text.secondary">v{ci.version}</Typography>
<Box sx={{ flexGrow: 1 }} />
{editing ? (
<>
<Button startIcon={<Save />} variant="contained" onClick={handleSave}>Save</Button>
<Button onClick={() => setEditing(false)}>Cancel</Button>
</>
) : (
<Button startIcon={<Edit />} variant="outlined" onClick={() => { setEditData(ci); setEditing(true); }}>
Edit
</Button>
)}
</Box>
<Grid container spacing={3}>
<Grid item xs={12} md={8}>
<Card sx={{ bgcolor: '#161b22' }}>
<CardContent>
<Tabs value={tab} onChange={(_, v) => setTab(v)}>
<Tab label="Details" />
<Tab label="IP Addresses" />
<Tab label="Network" />
<Tab label="Hardware" />
<Tab label="Software" />
<Tab label={`Relationships (${ci.relationships_out?.length + ci.relationships_in?.length || 0})`} />
</Tabs>
{tab === 0 && (
<Box sx={{ mt: 2 }}>
{editing ? (
<Grid container spacing={2}>
<Grid item xs={12}>
<TextField fullWidth label="Name" value={editData.name || ''}
onChange={(e) => setEditData({ ...editData, name: e.target.value })} />
</Grid>
<Grid item xs={12}>
<TextField fullWidth label="Description" multiline rows={2}
value={editData.description || ''}
onChange={(e) => setEditData({ ...editData, description: e.target.value })} />
</Grid>
<Grid item xs={6}>
<TextField fullWidth label="Serial Number" value={editData.serial_number || ''}
onChange={(e) => setEditData({ ...editData, serial_number: e.target.value })} />
</Grid>
<Grid item xs={6}>
<TextField fullWidth label="Asset Tag" value={editData.asset_tag || ''}
onChange={(e) => setEditData({ ...editData, asset_tag: e.target.value })} />
</Grid>
</Grid>
) : (
<>
<Typography variant="body2" sx={{ mb: 1 }}>{ci.description || 'No description'}</Typography>
<Divider sx={{ my: 2 }} />
<Grid container spacing={2}>
<Grid item xs={6}><strong>Type:</strong> {ci.ci_type?.name}</Grid>
<Grid item xs={6}><strong>Location:</strong> {ci.location?.name || '—'}</Grid>
<Grid item xs={6}><strong>Serial:</strong> {ci.serial_number || '—'}</Grid>
<Grid item xs={6}><strong>Asset Tag:</strong> {ci.asset_tag || '—'}</Grid>
<Grid item xs={6}><strong>Created:</strong> {new Date(ci.created_at).toLocaleString()}</Grid>
<Grid item xs={6}><strong>Updated:</strong> {new Date(ci.updated_at).toLocaleString()}</Grid>
</Grid>
{ci.attributes && Object.keys(ci.attributes).length > 0 && (
<>
<Divider sx={{ my: 2 }} />
<Typography variant="subtitle2" gutterBottom>Attributes (JSONB)</Typography>
<Paper sx={{ p: 2, bgcolor: '#0d1117' }}>
<pre style={{ margin: 0, fontSize: '0.85rem', color: '#81c784' }}>
{JSON.stringify(ci.attributes, null, 2)}
</pre>
</Paper>
</>
)}
{ci.tags?.length > 0 && (
<Box sx={{ mt: 2 }}>
{ci.tags.map((t) => <Chip key={t} label={t} size="small" sx={{ mr: 0.5 }} />)}
</Box>
)}
</>
)}
</Box>
)}
{tab === 1 && (
<TableContainer sx={{ mt: 2 }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>IP Address</TableCell>
<TableCell>Subnet</TableCell>
<TableCell>Gateway</TableCell>
<TableCell>VLAN</TableCell>
<TableCell>Primary</TableCell>
</TableRow>
</TableHead>
<TableBody>
{ci.ip_addresses?.map((ip: any) => (
<TableRow key={ip.id}>
<TableCell sx={{ fontFamily: 'monospace' }}>{ip.ip_address}</TableCell>
<TableCell>{ip.subnet_mask || '—'}</TableCell>
<TableCell>{ip.gateway || '—'}</TableCell>
<TableCell>{ip.vlan_id || '—'}</TableCell>
<TableCell>{ip.is_primary ? '✓' : ''}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
{tab === 2 && (
<TableContainer sx={{ mt: 2 }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>MAC</TableCell>
<TableCell>Speed</TableCell>
<TableCell>Type</TableCell>
<TableCell>Up</TableCell>
</TableRow>
</TableHead>
<TableBody>
{ci.network_interfaces?.map((n: any) => (
<TableRow key={n.id}>
<TableCell>{n.name}</TableCell>
<TableCell sx={{ fontFamily: 'monospace' }}>{n.mac_address || '—'}</TableCell>
<TableCell>{n.speed_mbps ? `${n.speed_mbps} Mbps` : '—'}</TableCell>
<TableCell>{n.interface_type}</TableCell>
<TableCell>{n.is_up ? '🟢' : '🔴'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
{tab === 3 && ci.hardware_detail && (
<Box sx={{ mt: 2 }}>
<Grid container spacing={2}>
{Object.entries(ci.hardware_detail).filter(([k]) => !['id','ci_id','created_at','updated_at','deleted_at'].includes(k)).map(([k, v]) => (
<Grid item xs={6} sm={4} key={k}>
<Typography variant="caption" color="text.secondary">{k.replace(/_/g, ' ')}</Typography>
<Typography>{String(v ?? '—')}</Typography>
</Grid>
))}
</Grid>
</Box>
)}
{tab === 4 && (
<TableContainer sx={{ mt: 2 }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Version</TableCell>
<TableCell>Port</TableCell>
<TableCell>Protocol</TableCell>
</TableRow>
</TableHead>
<TableBody>
{ci.software_instances?.map((s: any) => (
<TableRow key={s.id}>
<TableCell>{s.name}</TableCell>
<TableCell>{s.version || '—'}</TableCell>
<TableCell>{s.port || '—'}</TableCell>
<TableCell>{s.protocol || '—'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
{tab === 5 && (
<Box sx={{ mt: 2 }}>
<Button startIcon={<LinkIcon />} onClick={() => setRelDialogOpen(true)} sx={{ mb: 2 }}>
Add Relationship
</Button>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Direction</TableCell>
<TableCell>Type</TableCell>
<TableCell>Target</TableCell>
<TableCell>Description</TableCell>
</TableRow>
</TableHead>
<TableBody>
{ci.relationships_out?.map((r: any) => (
<TableRow key={r.id}>
<TableCell><Chip label="→ outgoing" size="small" color="primary" /></TableCell>
<TableCell>{r.relationship}</TableCell>
<TableCell>{r.target_ci_id}</TableCell>
<TableCell>{r.description || '—'}</TableCell>
</TableRow>
))}
{ci.relationships_in?.map((r: any) => (
<TableRow key={r.id}>
<TableCell><Chip label="← incoming" size="small" color="secondary" /></TableCell>
<TableCell>{r.relationship}</TableCell>
<TableCell>{r.source_ci_id}</TableCell>
<TableCell>{r.description || '—'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Box>
)}
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={4}>
<Card sx={{ bgcolor: '#161b22', mb: 2 }}>
<CardContent>
<Typography variant="h6" gutterBottom>Quick Info</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box><strong>Type:</strong> {ci.ci_type?.name}</Box>
<Box><strong>Status:</strong> {ci.status}</Box>
<Box><strong>Location:</strong> {ci.location?.name || 'N/A'}</Box>
<Box><strong>IPs:</strong> {ci.ip_addresses?.length || 0}</Box>
<Box><strong>NICs:</strong> {ci.network_interfaces?.length || 0}</Box>
<Box><strong>Relationships:</strong> {(ci.relationships_out?.length || 0) + (ci.relationships_in?.length || 0)}</Box>
</Box>
</CardContent>
</Card>
<Card sx={{ bgcolor: '#161b22' }}>
<CardContent>
<Typography variant="h6" gutterBottom>Tags</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{ci.tags?.map((t) => <Chip key={t} label={t} size="small" />) || 'No tags'}
</Box>
</CardContent>
</Card>
</Grid>
</Grid>
<Dialog open={relDialogOpen} onClose={() => setRelDialogOpen(false)}>
<DialogTitle>Add Relationship</DialogTitle>
<DialogContent>
<TextField fullWidth label="Target CI ID" margin="normal" value={newRel.target_ci_id}
onChange={(e) => setNewRel({ ...newRel, target_ci_id: e.target.value })} />
<FormControl fullWidth margin="normal">
<InputLabel>Type</InputLabel>
<Select value={newRel.relationship} label="Type"
onChange={(e) => setNewRel({ ...newRel, relationship: e.target.value })}>
{['depends_on', 'connected_to', 'hosted_on', 'runs_on', 'manages', 'contains', 'part_of', 'related_to'].map(r => (
<MenuItem key={r} value={r}>{r}</MenuItem>
))}
</Select>
</FormControl>
<TextField fullWidth label="Description" margin="normal" value={newRel.description}
onChange={(e) => setNewRel({ ...newRel, description: e.target.value })} />
</DialogContent>
<DialogActions>
<Button onClick={() => setRelDialogOpen(false)}>Cancel</Button>
<Button variant="contained" onClick={handleAddRelationship}
disabled={!newRel.target_ci_id}>Add</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View File

@@ -0,0 +1,241 @@
import { useEffect, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Box, Typography, Table, TableBody, TableCell, TableContainer,
TableHead, TableRow, Paper, Chip, IconButton, TextField, Select,
MenuItem, FormControl, InputLabel, TablePagination, Button,
Dialog, DialogTitle, DialogContent, DialogActions, Alert,
Tooltip, CircularProgress,
} from '@mui/material';
import {
Refresh as RefreshIcon, Add as AddIcon, Delete as DeleteIcon,
Edit as EditIcon, Search as SearchIcon,
} from '@mui/icons-material';
import { ciApi, refApi, CI, PaginatedResponse } from '../services/api';
import { useSnackbar } from 'notistack';
const STATUS_COLORS: Record<string, 'success' | 'warning' | 'error' | 'default' | 'info'> = {
active: 'success', inactive: 'default', maintenance: 'warning',
deprecated: 'error', planned: 'info',
};
export default function CIListPage() {
const navigate = useNavigate();
const { enqueueSnackbar } = useSnackbar();
const [data, setData] = useState<PaginatedResponse | null>(null);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(20);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [typeFilter, setTypeFilter] = useState('');
const [sortBy, setSortBy] = useState('name');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
const [createOpen, setCreateOpen] = useState(false);
const [newCI, setNewCI] = useState({ name: '', ci_type_id: '', description: '', status: 'active' });
const [types, setTypes] = useState<any[]>([]);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await ciApi.list({
page: page + 1, page_size: rowsPerPage,
search: search || undefined, status: statusFilter || undefined,
ci_type_id: typeFilter || undefined, sort_by: sortBy, sort_order: sortOrder,
});
setData(res.data);
} catch (err: any) {
enqueueSnackbar(err.response?.data?.detail || 'Failed to load CIs', { variant: 'error' });
} finally {
setLoading(false);
}
}, [page, rowsPerPage, search, statusFilter, typeFilter, sortBy, sortOrder]);
useEffect(() => { fetchData(); }, [fetchData]);
useEffect(() => { refApi.types().then(r => setTypes(r.data)); }, []);
const handleCreate = async () => {
try {
await ciApi.create(newCI);
enqueueSnackbar('CI created', { variant: 'success' });
setCreateOpen(false);
setNewCI({ name: '', ci_type_id: '', description: '', status: 'active' });
fetchData();
} catch (err: any) {
enqueueSnackbar(err.response?.data?.detail || 'Create failed', { variant: 'error' });
}
};
const handleDelete = async (id: string) => {
try {
await ciApi.delete(id);
enqueueSnackbar('CI deleted', { variant: 'success' });
setDeleteConfirm(null);
fetchData();
} catch (err: any) {
enqueueSnackbar(err.response?.data?.detail || 'Delete failed', { variant: 'error' });
}
};
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="h4">Configuration Items</Typography>
<Box>
<Button startIcon={<RefreshIcon />} onClick={fetchData} sx={{ mr: 1 }}>Refresh</Button>
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}
sx={{ bgcolor: '#4fc3f7', '&:hover': { bgcolor: '#29b6f6' } }}>
New CI
</Button>
</Box>
</Box>
<Paper sx={{ p: 2, mb: 2, bgcolor: '#161b22' }}>
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
<TextField
size="small" placeholder="Search CIs..." value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
InputProps={{ startAdornment: <SearchIcon sx={{ mr: 1, opacity: 0.5 }} /> }}
sx={{ minWidth: 250 }}
/>
<FormControl size="small" sx={{ minWidth: 120 }}>
<InputLabel>Status</InputLabel>
<Select value={statusFilter} label="Status"
onChange={(e) => { setStatusFilter(e.target.value); setPage(0); }}>
<MenuItem value="">All</MenuItem>
{['active', 'inactive', 'maintenance', 'deprecated', 'planned'].map(s => (
<MenuItem key={s} value={s}>{s}</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 150 }}>
<InputLabel>Type</InputLabel>
<Select value={typeFilter} label="Type"
onChange={(e) => { setTypeFilter(e.target.value); setPage(0); }}>
<MenuItem value="">All</MenuItem>
{types.map((t: any) => (
<MenuItem key={t.id} value={t.id}>{t.name}</MenuItem>
))}
</Select>
</FormControl>
</Box>
</Paper>
<TableContainer component={Paper} sx={{ bgcolor: '#161b22' }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Type</TableCell>
<TableCell>Status</TableCell>
<TableCell>Location</TableCell>
<TableCell>Tags</TableCell>
<TableCell>Version</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{loading ? (
<TableRow><TableCell colSpan={7} align="center"><CircularProgress /></TableCell></TableRow>
) : data?.items.length === 0 ? (
<TableRow><TableCell colSpan={7} align="center">No CIs found</TableCell></TableRow>
) : (
data?.items.map((ci) => (
<TableRow key={ci.id} hover sx={{ cursor: 'pointer' }}
onClick={() => navigate(`/ci/${ci.id}`)}>
<TableCell>
<Typography sx={{ fontWeight: 600, color: '#4fc3f7' }}>{ci.name}</Typography>
{ci.serial_number && (
<Typography variant="caption" color="text.secondary">SN: {ci.serial_number}</Typography>
)}
</TableCell>
<TableCell>{ci.ci_type?.name || '—'}</TableCell>
<TableCell>
<Chip label={ci.status} size="small" color={STATUS_COLORS[ci.status] || 'default'} />
</TableCell>
<TableCell>{ci.location?.name || '—'}</TableCell>
<TableCell>
{ci.tags?.slice(0, 3).map((tag) => (
<Chip key={tag} label={tag} size="small" variant="outlined" sx={{ mr: 0.5 }} />
))}
</TableCell>
<TableCell>v{ci.version}</TableCell>
<TableCell align="right">
<Tooltip title="Edit">
<IconButton size="small" onClick={(e) => { e.stopPropagation(); navigate(`/ci/${ci.id}`); }}>
<EditIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete">
<IconButton size="small" color="error"
onClick={(e) => { e.stopPropagation(); setDeleteConfirm(ci.id); }}>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
<TablePagination
component="div" count={data?.total || 0} page={page}
rowsPerPage={rowsPerPage}
onPageChange={(_, p) => setPage(p)}
onRowsPerPageChange={(e) => { setRowsPerPage(parseInt(e.target.value)); setPage(0); }}
rowsPerPageOptions={[10, 20, 50]}
/>
</TableContainer>
{/* Create Dialog */}
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Create Configuration Item</DialogTitle>
<DialogContent>
<TextField fullWidth label="Name" margin="normal" value={newCI.name}
onChange={(e) => setNewCI({ ...newCI, name: e.target.value })} />
<FormControl fullWidth margin="normal">
<InputLabel>Type</InputLabel>
<Select value={newCI.ci_type_id} label="Type"
onChange={(e) => setNewCI({ ...newCI, ci_type_id: e.target.value })}>
{types.map((t: any) => (
<MenuItem key={t.id} value={t.id}>{t.name}</MenuItem>
))}
</Select>
</FormControl>
<TextField fullWidth label="Description" margin="normal" multiline rows={2}
value={newCI.description}
onChange={(e) => setNewCI({ ...newCI, description: e.target.value })} />
<FormControl fullWidth margin="normal">
<InputLabel>Status</InputLabel>
<Select value={newCI.status} label="Status"
onChange={(e) => setNewCI({ ...newCI, status: e.target.value })}>
{['active', 'inactive', 'maintenance', 'deprecated', 'planned'].map(s => (
<MenuItem key={s} value={s}>{s}</MenuItem>
))}
</Select>
</FormControl>
</DialogContent>
<DialogActions>
<Button onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button variant="contained" onClick={handleCreate}
disabled={!newCI.name || !newCI.ci_type_id}>Create</Button>
</DialogActions>
</Dialog>
{/* Delete Confirm */}
<Dialog open={!!deleteConfirm} onClose={() => setDeleteConfirm(null)}>
<DialogTitle>Delete CI?</DialogTitle>
<DialogContent>
<Alert severity="warning">This will soft-delete the CI. It can be restored later.</Alert>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteConfirm(null)}>Cancel</Button>
<Button color="error" variant="contained" onClick={() => deleteConfirm && handleDelete(deleteConfirm)}>
Delete
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View File

@@ -0,0 +1,84 @@
import { useEffect, useState } from 'react';
import { Box, Card, CardContent, Typography, Grid } from '@mui/material';
import { Server, Hub, LocationOn, TrendingUp } from '@mui/icons-material';
import { dashboardApi } from '../services/api';
interface Stats {
total_cis: number;
by_status: Record<string, number>;
by_class: Record<string, number>;
total_relationships: number;
total_locations: number;
}
const StatCard = ({ title, value, icon, color }: { title: string; value: number | string; icon: React.ReactNode; color: string }) => (
<Card sx={{ bgcolor: '#161b22', borderLeft: `4px solid ${color}` }}>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Box>
<Typography variant="body2" color="text.secondary">{title}</Typography>
<Typography variant="h4" sx={{ color, mt: 1 }}>{value}</Typography>
</Box>
<Box sx={{ color, opacity: 0.3 }}>{icon}</Box>
</Box>
</CardContent>
</Card>
);
export default function DashboardPage() {
const [stats, setStats] = useState<Stats | null>(null);
useEffect(() => {
dashboardApi.stats().then((r) => setStats(r.data));
}, []);
if (!stats) return <Typography>Loading...</Typography>;
return (
<Box>
<Typography variant="h4" gutterBottom>Dashboard</Typography>
<Grid container spacing={3}>
<Grid item xs={12} sm={6} md={3}>
<StatCard title="Total CIs" value={stats.total_cis} icon={<Server fontSize="large" />} color="#4fc3f7" />
</Grid>
<Grid item xs={12} sm={6} md={3}>
<StatCard title="Relationships" value={stats.total_relationships} icon={<Hub fontSize="large" />} color="#ff8a65" />
</Grid>
<Grid item xs={12} sm={6} md={3}>
<StatCard title="Locations" value={stats.total_locations} icon={<LocationOn fontSize="large" />} color="#81c784" />
</Grid>
<Grid item xs={12} sm={6} md={3}>
<StatCard title="Active" value={stats.by_status?.active || 0} icon={<TrendingUp fontSize="large" />} color="#ba68c8" />
</Grid>
<Grid item xs={12} md={6}>
<Card sx={{ bgcolor: '#161b22' }}>
<CardContent>
<Typography variant="h6" gutterBottom>By Status</Typography>
{Object.entries(stats.by_status || {}).map(([status, count]) => (
<Box key={status} sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography>{status}</Typography>
<Typography sx={{ color: '#4fc3f7' }}>{count}</Typography>
</Box>
))}
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={6}>
<Card sx={{ bgcolor: '#161b22' }}>
<CardContent>
<Typography variant="h6" gutterBottom>By Class</Typography>
{Object.entries(stats.by_class || {}).map(([cls, count]) => (
<Box key={cls} sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography>{cls}</Typography>
<Typography sx={{ color: '#ff8a65' }}>{count}</Typography>
</Box>
))}
</CardContent>
</Card>
</Grid>
</Grid>
</Box>
);
}

View File

@@ -0,0 +1,195 @@
import { useEffect, useState, useRef, useCallback } from 'react';
import { Box, Typography, Card, CardContent, Slider, Button, CircularProgress, Alert } from '@mui/material';
import { ciApi, GraphData } from '../services/api';
import { useSnackbar } from 'notistack';
export default function GraphPage() {
const { enqueueSnackbar } = useSnackbar();
const canvasRef = useRef<HTMLCanvasElement>(null);
const [graph, setGraph] = useState<GraphData | null>(null);
const [loading, setLoading] = useState(true);
const [depth, setDepth] = useState(2);
const [selectedNode, setSelectedNode] = useState<string | null>(null);
const fetchGraph = async () => {
setLoading(true);
try {
const res = await ciApi.graph({ depth });
setGraph(res.data);
} catch {
enqueueSnackbar('Failed to load graph', { variant: 'error' });
} finally {
setLoading(false);
}
};
useEffect(() => { fetchGraph(); }, [depth]);
// Simple force-directed layout on canvas
useEffect(() => {
if (!graph || !canvasRef.current) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const W = canvas.width = canvas.parentElement?.clientWidth || 800;
const H = canvas.height = 600;
const nodeMap = new Map<string, { x: number; y: number; vx: number; vy: number }>();
graph.nodes.forEach((n, i) => {
const angle = (2 * Math.PI * i) / graph.nodes.length;
nodeMap.set(n.id, {
x: W / 2 + Math.cos(angle) * 200,
y: H / 2 + Math.sin(angle) * 200,
vx: 0, vy: 0,
});
});
const GROUP_COLORS: Record<string, string> = {
PhysicalServer: '#4fc3f7', VirtualMachine: '#81c784', Switch: '#ff8a65',
Router: '#ba68c8', Application: '#ffd54f', NAS: '#f48fb1',
};
let animFrame: number;
let iterations = 0;
const simulate = () => {
iterations++;
const alpha = Math.max(0.01, 1 - iterations / 300);
// Repulsion
for (let i = 0; i < graph.nodes.length; i++) {
for (let j = i + 1; j < graph.nodes.length; j++) {
const a = nodeMap.get(graph.nodes[i].id)!;
const b = nodeMap.get(graph.nodes[j].id)!;
let dx = b.x - a.x, dy = b.y - a.y;
let dist = Math.sqrt(dx * dx + dy * dy) || 1;
let force = 800 / (dist * dist);
force *= alpha;
a.vx -= (dx / dist) * force;
a.vy -= (dy / dist) * force;
b.vx += (dx / dist) * force;
b.vy += (dy / dist) * force;
}
}
// Attraction along edges
graph.edges.forEach((e) => {
const a = nodeMap.get(e.source);
const b = nodeMap.get(e.target);
if (!a || !b) return;
let dx = b.x - a.x, dy = b.y - a.y;
let dist = Math.sqrt(dx * dx + dy * dy) || 1;
let force = (dist - 100) * 0.01 * alpha;
a.vx += (dx / dist) * force;
a.vy += (dy / dist) * force;
b.vx -= (dx / dist) * force;
b.vy -= (dy / dist) * force;
});
// Center gravity
nodeMap.forEach((n) => {
n.vx += (W / 2 - n.x) * 0.001 * alpha;
n.vy += (H / 2 - n.y) * 0.001 * alpha;
n.vx *= 0.9;
n.vy *= 0.9;
n.x += n.vx;
n.y += n.vy;
n.x = Math.max(30, Math.min(W - 30, n.x));
n.y = Math.max(30, Math.min(H - 30, n.y));
});
// Draw
ctx.fillStyle = '#0d1117';
ctx.fillRect(0, 0, W, H);
// Edges
ctx.strokeStyle = 'rgba(79, 195, 247, 0.2)';
ctx.lineWidth = 1;
graph.edges.forEach((e) => {
const a = nodeMap.get(e.source);
const b = nodeMap.get(e.target);
if (!a || !b) return;
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
});
// Nodes
graph.nodes.forEach((n) => {
const pos = nodeMap.get(n.id)!;
const color = GROUP_COLORS[n.group] || '#ffffff';
const radius = n.id === selectedNode ? 14 : 10;
ctx.beginPath();
ctx.arc(pos.x, pos.y, radius, 0, 2 * Math.PI);
ctx.fillStyle = color;
ctx.fill();
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = n.id === selectedNode ? 3 : 1;
ctx.stroke();
ctx.fillStyle = '#ffffff';
ctx.font = '11px monospace';
ctx.textAlign = 'center';
ctx.fillText(n.label, pos.x, pos.y - radius - 5);
});
if (iterations < 300) {
animFrame = requestAnimationFrame(simulate);
}
};
simulate();
return () => cancelAnimationFrame(animFrame);
}, [graph, selectedNode]);
return (
<Box>
<Typography variant="h4" gutterBottom>Relationship Graph</Typography>
<Card sx={{ bgcolor: '#161b22', mb: 2 }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3 }}>
<Typography>Depth:</Typography>
<Slider value={depth} onChange={(_, v) => setDepth(v as number)}
min={1} max={5} step={1} marks sx={{ width: 200 }} />
<Button variant="contained" onClick={fetchGraph} disabled={loading}>
Reload
</Button>
{graph && (
<Typography variant="body2" color="text.secondary">
{graph.nodes.length} nodes, {graph.edges.length} edges
</Typography>
)}
</Box>
</CardContent>
</Card>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}><CircularProgress /></Box>
) : graph?.nodes.length === 0 ? (
<Alert severity="info">No data to display. Add CIs and relationships first.</Alert>
) : (
<canvas
ref={canvasRef}
style={{ width: '100%', borderRadius: 8, cursor: 'pointer' }}
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Find closest node (simplified)
let closest = null, minDist = 20;
graph?.nodes.forEach((n) => {
const pos = (canvasRef.current as any)?._nodePositions?.get(n.id);
if (!pos) return;
const d = Math.sqrt((pos.x - x) ** 2 + (pos.y - y) ** 2);
if (d < minDist) { closest = n.id; minDist = d; }
});
if (closest) setSelectedNode(closest);
}}
/>
)}
</Box>
);
}

View File

@@ -0,0 +1,56 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Box, Card, CardContent, TextField, Button, Typography, Alert } from '@mui/material';
import { authApi } from '../services/api';
export default function LoginPage() {
const navigate = useNavigate();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
const res = await authApi.login(username, password);
localStorage.setItem('token', res.data.access_token);
navigate('/');
} catch {
setError('Invalid credentials');
} finally {
setLoading(false);
}
};
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh', bgcolor: '#0d1117' }}>
<Card sx={{ width: 400, bgcolor: '#161b22' }}>
<CardContent sx={{ p: 4 }}>
<Typography variant="h4" align="center" gutterBottom sx={{ color: '#4fc3f7' }}>
CMDB Login
</Typography>
<form onSubmit={handleLogin}>
<TextField
fullWidth label="Username" margin="normal"
value={username} onChange={(e) => setUsername(e.target.value)}
/>
<TextField
fullWidth label="Password" type="password" margin="normal"
value={password} onChange={(e) => setPassword(e.target.value)}
/>
{error && <Alert severity="error" sx={{ mt: 2 }}>{error}</Alert>}
<Button
fullWidth variant="contained" type="submit" disabled={loading}
sx={{ mt: 3, bgcolor: '#4fc3f7', '&:hover': { bgcolor: '#29b6f6' } }}
>
{loading ? 'Signing in...' : 'Sign In'}
</Button>
</form>
</CardContent>
</Card>
</Box>
);
}

View File

@@ -0,0 +1,89 @@
import axios from 'axios';
const api = axios.create({
baseURL: '/api',
headers: { 'Content-Type': 'application/json' },
});
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
export interface CI {
id: string;
ci_type_id: string;
ci_type?: { id: string; name: string };
name: string;
description?: string;
status: string;
location_id?: string;
location?: { id: string; name: string };
serial_number?: string;
asset_tag?: string;
attributes: Record<string, any>;
tags: string[];
version: number;
created_at: string;
updated_at: string;
}
export interface PaginatedResponse {
items: CI[];
total: number;
page: number;
page_size: number;
pages: number;
}
export interface GraphData {
nodes: { id: string; label: string; group: string; status: string }[];
edges: { source: string; target: string; label: string }[];
}
export const ciApi = {
list: (params: Record<string, any>) =>
api.get<PaginatedResponse>('/ci', { params }),
get: (id: string) => api.get<CI>(`/ci/${id}`),
create: (data: any) => api.post<CI>('/ci', data),
update: (id: string, data: any) => api.patch<CI>(`/ci/${id}`, data),
delete: (id: string) => api.delete(`/ci/${id}`),
graph: (params?: Record<string, any>) =>
api.get<GraphData>('/ci/graph/visualize', { params }),
exportData: (format: string = 'csv') =>
api.get('/ci/export', { params: { format }, responseType: 'blob' }),
bulkImport: (items: any[]) => api.post('/ci/bulk/import', { items }),
};
export const refApi = {
classes: () => api.get('/classes'),
types: (classId?: string) =>
api.get('/types', { params: classId ? { class_id: classId } : {} }),
locations: () => api.get('/locations'),
};
export const authApi = {
login: (username: string, password: string) =>
api.post('/auth/login', { username, password }),
me: () => api.get('/auth/me'),
};
export const dashboardApi = {
stats: () => api.get('/dashboard/stats'),
};
export default api;

20
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

15
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
})

79
k8s/backend.yaml Normal file
View File

@@ -0,0 +1,79 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: cmdb-backend
labels:
app: cmdb-backend
spec:
replicas: 2
selector:
matchLabels:
app: cmdb-backend
template:
metadata:
labels:
app: cmdb-backend
spec:
containers:
- name: backend
image: cmdb-backend:latest
ports:
- containerPort: 8000
env:
- name: DATABASE_URL
value: postgresql+asyncpg://$(CMDB_USER):$(CMDB_PASS)@cmdb-postgres:5432/cmdb
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: cmdb-secrets
key: jwt-secret
- name: CMDB_USER
valueFrom:
secretKeyRef:
name: cmdb-secrets
key: db-user
- name: CMDB_PASS
valueFrom:
secretKeyRef:
name: cmdb-secrets
key: db-password
resources:
requests:
memory: 128Mi
cpu: 100m
limits:
memory: 512Mi
cpu: 500m
livenessProbe:
httpGet:
path: /api/health
port: 8000
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /api/health
port: 8000
initialDelaySeconds: 5
periodSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
name: cmdb-backend
spec:
selector:
app: cmdb-backend
ports:
- port: 8000
targetPort: 8000
---
apiVersion: v1
kind: Secret
metadata:
name: cmdb-secrets
type: Opaque
stringData:
db-user: cmdb
db-password: CHANGE_ME_IN_PRODUCTION
jwt-secret: CHANGE_ME_USE_OPENSSL_RAND_HEX_32

65
k8s/frontend.yaml Normal file
View File

@@ -0,0 +1,65 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: cmdb-frontend
labels:
app: cmdb-frontend
spec:
replicas: 2
selector:
matchLabels:
app: cmdb-frontend
template:
metadata:
labels:
app: cmdb-frontend
spec:
containers:
- name: frontend
image: cmdb-frontend:latest
ports:
- containerPort: 80
resources:
requests:
memory: 64Mi
cpu: 50m
limits:
memory: 128Mi
cpu: 200m
---
apiVersion: v1
kind: Service
metadata:
name: cmdb-frontend
spec:
selector:
app: cmdb-frontend
ports:
- port: 80
targetPort: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: cmdb-ingress
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
spec:
rules:
- host: cmdb.homelab.local
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: cmdb-backend
port:
number: 8000
- path: /
pathType: Prefix
backend:
service:
name: cmdb-frontend
port:
number: 80

70
k8s/postgres.yaml Normal file
View File

@@ -0,0 +1,70 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: cmdb-postgres
labels:
app: cmdb-postgres
spec:
replicas: 1
selector:
matchLabels:
app: cmdb-postgres
template:
metadata:
labels:
app: cmdb-postgres
spec:
containers:
- name: postgres
image: postgres:16-alpine
ports:
- containerPort: 5432
env:
- name: POSTGRES_DB
value: cmdb
- name: POSTGRES_USER
valueFrom:
secretKeyRef:
name: cmdb-secrets
key: db-user
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: cmdb-secrets
key: db-password
volumeMounts:
- name: pgdata
mountPath: /var/lib/postgresql/data
resources:
requests:
memory: 256Mi
cpu: 250m
limits:
memory: 1Gi
cpu: 1000m
volumes:
- name: pgdata
persistentVolumeClaim:
claimName: cmdb-pgdata
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: cmdb-pgdata
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
---
apiVersion: v1
kind: Service
metadata:
name: cmdb-postgres
spec:
selector:
app: cmdb-postgres
ports:
- port: 5432
targetPort: 5432

18
nginx.conf Normal file
View File

@@ -0,0 +1,18 @@
server {
listen 80;
server_name _;
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
proxy_pass http://frontend:80;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}