From 9b15f8b09cca1dab7d6e8109fb6f0b8e176eef7f Mon Sep 17 00:00:00 2001 From: smolkik-code Date: Thu, 25 Jun 2026 13:01:40 +0700 Subject: [PATCH] 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) --- README.md | 292 ++++++++++++++++ backend/.env.example | 8 + backend/Dockerfile | 14 + backend/app/__init__.py | 0 backend/app/config.py | 29 ++ backend/app/database.py | 30 ++ backend/app/main.py | 35 ++ backend/app/middleware/__init__.py | 0 backend/app/middleware/auth.py | 69 ++++ backend/app/middleware/rate_limit.py | 23 ++ backend/app/models/__init__.py | 0 backend/app/models/models.py | 222 ++++++++++++ backend/app/routes/__init__.py | 0 backend/app/routes/auth.py | 48 +++ backend/app/routes/ci.py | 391 ++++++++++++++++++++++ backend/app/routes/dashboard.py | 51 +++ backend/app/routes/reference.py | 60 ++++ backend/app/schemas/__init__.py | 0 backend/app/schemas/schemas.py | 314 +++++++++++++++++ backend/migrations/001_initial_schema.sql | 387 +++++++++++++++++++++ backend/migrations/002_seed_data.sql | 178 ++++++++++ backend/migrations/003_audit_triggers.sql | 74 ++++ backend/pyproject.toml | 3 + backend/requirements.txt | 14 + backend/tests/test_api.py | 199 +++++++++++ docker-compose.prod.yml | 69 ++++ docker-compose.yml | 69 ++++ docs/ansible-integration.md | 91 +++++ docs/security.md | 132 ++++++++ frontend/Dockerfile | 12 + frontend/index.html | 12 + frontend/package.json | 30 ++ frontend/src/App.tsx | 48 +++ frontend/src/components/Layout.tsx | 72 ++++ frontend/src/main.tsx | 9 + frontend/src/pages/CIDetailPage.tsx | 351 +++++++++++++++++++ frontend/src/pages/CIListPage.tsx | 241 +++++++++++++ frontend/src/pages/DashboardPage.tsx | 84 +++++ frontend/src/pages/GraphPage.tsx | 195 +++++++++++ frontend/src/pages/LoginPage.tsx | 56 ++++ frontend/src/services/api.ts | 89 +++++ frontend/tsconfig.json | 20 ++ frontend/vite.config.ts | 15 + k8s/backend.yaml | 79 +++++ k8s/frontend.yaml | 65 ++++ k8s/postgres.yaml | 70 ++++ nginx.conf | 18 + 47 files changed, 4268 insertions(+) create mode 100644 README.md create mode 100644 backend/.env.example create mode 100644 backend/Dockerfile create mode 100644 backend/app/__init__.py create mode 100644 backend/app/config.py create mode 100644 backend/app/database.py create mode 100644 backend/app/main.py create mode 100644 backend/app/middleware/__init__.py create mode 100644 backend/app/middleware/auth.py create mode 100644 backend/app/middleware/rate_limit.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/models.py create mode 100644 backend/app/routes/__init__.py create mode 100644 backend/app/routes/auth.py create mode 100644 backend/app/routes/ci.py create mode 100644 backend/app/routes/dashboard.py create mode 100644 backend/app/routes/reference.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/schemas.py create mode 100644 backend/migrations/001_initial_schema.sql create mode 100644 backend/migrations/002_seed_data.sql create mode 100644 backend/migrations/003_audit_triggers.sql create mode 100644 backend/pyproject.toml create mode 100644 backend/requirements.txt create mode 100644 backend/tests/test_api.py create mode 100644 docker-compose.prod.yml create mode 100644 docker-compose.yml create mode 100644 docs/ansible-integration.md create mode 100644 docs/security.md create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/components/Layout.tsx create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/CIDetailPage.tsx create mode 100644 frontend/src/pages/CIListPage.tsx create mode 100644 frontend/src/pages/DashboardPage.tsx create mode 100644 frontend/src/pages/GraphPage.tsx create mode 100644 frontend/src/pages/LoginPage.tsx create mode 100644 frontend/src/services/api.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts create mode 100644 k8s/backend.yaml create mode 100644 k8s/frontend.yaml create mode 100644 k8s/postgres.yaml create mode 100644 nginx.conf diff --git a/README.md b/README.md new file mode 100644 index 0000000..22012c8 --- /dev/null +++ b/README.md @@ -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 " http://localhost:8000/api/auth/me +``` + +### Configuration Items + +```bash +# List CIs (paginated, filtered) +curl -H "Authorization: Bearer " \ + "http://localhost:8000/api/ci?page=1&page_size=10&status=active&search=proxmox" + +# Get single CI with all details +curl -H "Authorization: Bearer " \ + http://localhost:8000/api/ci/ + +# Create CI +curl -X POST http://localhost:8000/api/ci \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "name": "new-server", + "ci_type_id": "", + "status": "active", + "tags": ["new", "production"], + "attributes": {"cpu": "Xeon", "ram_gb": 32} + }' + +# Update CI +curl -X PATCH http://localhost:8000/api/ci/ \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"status": "maintenance"}' + +# Delete (soft) +curl -X DELETE -H "Authorization: Bearer " \ + http://localhost:8000/api/ci/ + +# Export CSV +curl -H "Authorization: Bearer " \ + http://localhost:8000/api/ci/export?format=csv > cmdb_export.csv +``` + +### Relationships & Graph + +```bash +# Add relationship +curl -X POST http://localhost:8000/api/ci//relationships \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "source_ci_id": "", + "target_ci_id": "", + "relationship": "depends_on", + "description": "Service depends on server" + }' + +# Get relationship graph +curl -H "Authorization: Bearer " \ + "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 " \ + -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 " \ + 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 diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..e620541 --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..bc0b870 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..f512fcf --- /dev/null +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..f242da8 --- /dev/null +++ b/backend/app/database.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..5d80b7c --- /dev/null +++ b/backend/app/main.py @@ -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} diff --git a/backend/app/middleware/__init__.py b/backend/app/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/middleware/auth.py b/backend/app/middleware/auth.py new file mode 100644 index 0000000..ef6deec --- /dev/null +++ b/backend/app/middleware/auth.py @@ -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") diff --git a/backend/app/middleware/rate_limit.py b/backend/app/middleware/rate_limit.py new file mode 100644 index 0000000..63c7796 --- /dev/null +++ b/backend/app/middleware/rate_limit.py @@ -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 diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/models/models.py b/backend/app/models/models.py new file mode 100644 index 0000000..63cc623 --- /dev/null +++ b/backend/app/models/models.py @@ -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) diff --git a/backend/app/routes/__init__.py b/backend/app/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py new file mode 100644 index 0000000..e6bb249 --- /dev/null +++ b/backend/app/routes/auth.py @@ -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 diff --git a/backend/app/routes/ci.py b/backend/app/routes/ci.py new file mode 100644 index 0000000..268f6f0 --- /dev/null +++ b/backend/app/routes/ci.py @@ -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} diff --git a/backend/app/routes/dashboard.py b/backend/app/routes/dashboard.py new file mode 100644 index 0000000..e78cf75 --- /dev/null +++ b/backend/app/routes/dashboard.py @@ -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, + } diff --git a/backend/app/routes/reference.py b/backend/app/routes/reference.py new file mode 100644 index 0000000..e1c92fd --- /dev/null +++ b/backend/app/routes/reference.py @@ -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) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py new file mode 100644 index 0000000..2f13fd8 --- /dev/null +++ b/backend/app/schemas/schemas.py @@ -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] diff --git a/backend/migrations/001_initial_schema.sql b/backend/migrations/001_initial_schema.sql new file mode 100644 index 0000000..510a13d --- /dev/null +++ b/backend/migrations/001_initial_schema.sql @@ -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; diff --git a/backend/migrations/002_seed_data.sql b/backend/migrations/002_seed_data.sql new file mode 100644 index 0000000..2f2b3df --- /dev/null +++ b/backend/migrations/002_seed_data.sql @@ -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; diff --git a/backend/migrations/003_audit_triggers.sql b/backend/migrations/003_audit_triggers.sql new file mode 100644 index 0000000..51b286f --- /dev/null +++ b/backend/migrations/003_audit_triggers.sql @@ -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; diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..68f242a --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,3 @@ +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..e365ced --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py new file mode 100644 index 0000000..36079fb --- /dev/null +++ b/backend/tests/test_api.py @@ -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 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..db9fa4b --- /dev/null +++ b/docker-compose.prod.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..db9fa4b --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docs/ansible-integration.md b/docs/ansible-integration.md new file mode 100644 index 0000000..96336ce --- /dev/null +++ b/docs/ansible-integration.md @@ -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` diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..02cbdd1 --- /dev/null +++ b/docs/security.md @@ -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 diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..c1f2abe --- /dev/null +++ b/frontend/Dockerfile @@ -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;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..b993edd --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + CMDB + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..7126777 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..f054ae4 --- /dev/null +++ b/frontend/src/App.tsx @@ -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 ; + return <>{children}; +} + +export default function App() { + return ( + + + + + + } /> + }> + } /> + } /> + } /> + } /> + + + + + + ); +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..87f3098 --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -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: , path: '/' }, + { text: 'Configuration Items', icon: , path: '/ci' }, + { text: 'Relationship Graph', icon: , path: '/graph' }, +]; + +export default function Layout() { + const navigate = useNavigate(); + const [mobileOpen, setMobileOpen] = useState(false); + const [anchorEl, setAnchorEl] = useState(null); + + const handleLogout = () => { + localStorage.removeItem('token'); + navigate('/login'); + }; + + return ( + + t.zIndex.drawer + 1 }}> + + setMobileOpen(!mobileOpen)} sx={{ mr: 2 }}> + + + CMDB + setAnchorEl(e.currentTarget)}> + A + + setAnchorEl(null)}> + Logout + + + + + + + + {menuItems.map((item) => ( + navigate(item.path)}> + {item.icon} + + + ))} + + + + + + + + + ); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..c018515 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/frontend/src/pages/CIDetailPage.tsx b/frontend/src/pages/CIDetailPage.tsx new file mode 100644 index 0000000..d03d0e0 --- /dev/null +++ b/frontend/src/pages/CIDetailPage.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [editing, setEditing] = useState(false); + const [editData, setEditData] = useState>({}); + 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 ; + if (!ci) return null; + + return ( + + + navigate('/ci')}> + {ci.name} + + v{ci.version} + + {editing ? ( + <> + + + + ) : ( + + )} + + + + + + + setTab(v)}> + + + + + + + + + {tab === 0 && ( + + {editing ? ( + + + setEditData({ ...editData, name: e.target.value })} /> + + + setEditData({ ...editData, description: e.target.value })} /> + + + setEditData({ ...editData, serial_number: e.target.value })} /> + + + setEditData({ ...editData, asset_tag: e.target.value })} /> + + + ) : ( + <> + {ci.description || 'No description'} + + + Type: {ci.ci_type?.name} + Location: {ci.location?.name || '—'} + Serial: {ci.serial_number || '—'} + Asset Tag: {ci.asset_tag || '—'} + Created: {new Date(ci.created_at).toLocaleString()} + Updated: {new Date(ci.updated_at).toLocaleString()} + + {ci.attributes && Object.keys(ci.attributes).length > 0 && ( + <> + + Attributes (JSONB) + +
+                              {JSON.stringify(ci.attributes, null, 2)}
+                            
+
+ + )} + {ci.tags?.length > 0 && ( + + {ci.tags.map((t) => )} + + )} + + )} +
+ )} + + {tab === 1 && ( + + + + + IP Address + Subnet + Gateway + VLAN + Primary + + + + {ci.ip_addresses?.map((ip: any) => ( + + {ip.ip_address} + {ip.subnet_mask || '—'} + {ip.gateway || '—'} + {ip.vlan_id || '—'} + {ip.is_primary ? '✓' : ''} + + ))} + +
+
+ )} + + {tab === 2 && ( + + + + + Name + MAC + Speed + Type + Up + + + + {ci.network_interfaces?.map((n: any) => ( + + {n.name} + {n.mac_address || '—'} + {n.speed_mbps ? `${n.speed_mbps} Mbps` : '—'} + {n.interface_type} + {n.is_up ? '🟢' : '🔴'} + + ))} + +
+
+ )} + + {tab === 3 && ci.hardware_detail && ( + + + {Object.entries(ci.hardware_detail).filter(([k]) => !['id','ci_id','created_at','updated_at','deleted_at'].includes(k)).map(([k, v]) => ( + + {k.replace(/_/g, ' ')} + {String(v ?? '—')} + + ))} + + + )} + + {tab === 4 && ( + + + + + Name + Version + Port + Protocol + + + + {ci.software_instances?.map((s: any) => ( + + {s.name} + {s.version || '—'} + {s.port || '—'} + {s.protocol || '—'} + + ))} + +
+
+ )} + + {tab === 5 && ( + + + + + + + Direction + Type + Target + Description + + + + {ci.relationships_out?.map((r: any) => ( + + + {r.relationship} + {r.target_ci_id} + {r.description || '—'} + + ))} + {ci.relationships_in?.map((r: any) => ( + + + {r.relationship} + {r.source_ci_id} + {r.description || '—'} + + ))} + +
+
+
+ )} +
+
+
+ + + + + Quick Info + + Type: {ci.ci_type?.name} + Status: {ci.status} + Location: {ci.location?.name || 'N/A'} + IPs: {ci.ip_addresses?.length || 0} + NICs: {ci.network_interfaces?.length || 0} + Relationships: {(ci.relationships_out?.length || 0) + (ci.relationships_in?.length || 0)} + + + + + + + Tags + + {ci.tags?.map((t) => ) || 'No tags'} + + + + +
+ + setRelDialogOpen(false)}> + Add Relationship + + setNewRel({ ...newRel, target_ci_id: e.target.value })} /> + + Type + + + setNewRel({ ...newRel, description: e.target.value })} /> + + + + + + +
+ ); +} diff --git a/frontend/src/pages/CIListPage.tsx b/frontend/src/pages/CIListPage.tsx new file mode 100644 index 0000000..2b6b263 --- /dev/null +++ b/frontend/src/pages/CIListPage.tsx @@ -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 = { + active: 'success', inactive: 'default', maintenance: 'warning', + deprecated: 'error', planned: 'info', +}; + +export default function CIListPage() { + const navigate = useNavigate(); + const { enqueueSnackbar } = useSnackbar(); + const [data, setData] = useState(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([]); + const [deleteConfirm, setDeleteConfirm] = useState(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 ( + + + Configuration Items + + + + + + + + + { setSearch(e.target.value); setPage(0); }} + InputProps={{ startAdornment: }} + sx={{ minWidth: 250 }} + /> + + Status + + + + Type + + + + + + + + + + Name + Type + Status + Location + Tags + Version + Actions + + + + {loading ? ( + + ) : data?.items.length === 0 ? ( + No CIs found + ) : ( + data?.items.map((ci) => ( + navigate(`/ci/${ci.id}`)}> + + {ci.name} + {ci.serial_number && ( + SN: {ci.serial_number} + )} + + {ci.ci_type?.name || '—'} + + + + {ci.location?.name || '—'} + + {ci.tags?.slice(0, 3).map((tag) => ( + + ))} + + v{ci.version} + + + { e.stopPropagation(); navigate(`/ci/${ci.id}`); }}> + + + + + { e.stopPropagation(); setDeleteConfirm(ci.id); }}> + + + + + + )) + )} + +
+ setPage(p)} + onRowsPerPageChange={(e) => { setRowsPerPage(parseInt(e.target.value)); setPage(0); }} + rowsPerPageOptions={[10, 20, 50]} + /> +
+ + {/* Create Dialog */} + setCreateOpen(false)} maxWidth="sm" fullWidth> + Create Configuration Item + + setNewCI({ ...newCI, name: e.target.value })} /> + + Type + + + setNewCI({ ...newCI, description: e.target.value })} /> + + Status + + + + + + + + + + {/* Delete Confirm */} + setDeleteConfirm(null)}> + Delete CI? + + This will soft-delete the CI. It can be restored later. + + + + + + +
+ ); +} diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx new file mode 100644 index 0000000..b2479a8 --- /dev/null +++ b/frontend/src/pages/DashboardPage.tsx @@ -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; + by_class: Record; + total_relationships: number; + total_locations: number; +} + +const StatCard = ({ title, value, icon, color }: { title: string; value: number | string; icon: React.ReactNode; color: string }) => ( + + + + + {title} + {value} + + {icon} + + + +); + +export default function DashboardPage() { + const [stats, setStats] = useState(null); + + useEffect(() => { + dashboardApi.stats().then((r) => setStats(r.data)); + }, []); + + if (!stats) return Loading...; + + return ( + + Dashboard + + + } color="#4fc3f7" /> + + + } color="#ff8a65" /> + + + } color="#81c784" /> + + + } color="#ba68c8" /> + + + + + + By Status + {Object.entries(stats.by_status || {}).map(([status, count]) => ( + + {status} + {count} + + ))} + + + + + + + + By Class + {Object.entries(stats.by_class || {}).map(([cls, count]) => ( + + {cls} + {count} + + ))} + + + + + + ); +} diff --git a/frontend/src/pages/GraphPage.tsx b/frontend/src/pages/GraphPage.tsx new file mode 100644 index 0000000..18362a6 --- /dev/null +++ b/frontend/src/pages/GraphPage.tsx @@ -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(null); + const [graph, setGraph] = useState(null); + const [loading, setLoading] = useState(true); + const [depth, setDepth] = useState(2); + const [selectedNode, setSelectedNode] = useState(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(); + 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 = { + 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 ( + + Relationship Graph + + + + Depth: + setDepth(v as number)} + min={1} max={5} step={1} marks sx={{ width: 200 }} /> + + {graph && ( + + {graph.nodes.length} nodes, {graph.edges.length} edges + + )} + + + + + {loading ? ( + + ) : graph?.nodes.length === 0 ? ( + No data to display. Add CIs and relationships first. + ) : ( + { + 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); + }} + /> + )} + + ); +} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..867061c --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -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 ( + + + + + CMDB Login + +
+ setUsername(e.target.value)} + /> + setPassword(e.target.value)} + /> + {error && {error}} + + +
+
+
+ ); +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts new file mode 100644 index 0000000..37c85d7 --- /dev/null +++ b/frontend/src/services/api.ts @@ -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; + 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) => + api.get('/ci', { params }), + get: (id: string) => api.get(`/ci/${id}`), + create: (data: any) => api.post('/ci', data), + update: (id: string, data: any) => api.patch(`/ci/${id}`, data), + delete: (id: string) => api.delete(`/ci/${id}`), + graph: (params?: Record) => + api.get('/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; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..a4c834a --- /dev/null +++ b/frontend/tsconfig.json @@ -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"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..1406734 --- /dev/null +++ b/frontend/vite.config.ts @@ -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, + }, + }, + }, +}) diff --git a/k8s/backend.yaml b/k8s/backend.yaml new file mode 100644 index 0000000..0b8a6ba --- /dev/null +++ b/k8s/backend.yaml @@ -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 diff --git a/k8s/frontend.yaml b/k8s/frontend.yaml new file mode 100644 index 0000000..298f07f --- /dev/null +++ b/k8s/frontend.yaml @@ -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 diff --git a/k8s/postgres.yaml b/k8s/postgres.yaml new file mode 100644 index 0000000..f6a420e --- /dev/null +++ b/k8s/postgres.yaml @@ -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 diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..1f9b7c8 --- /dev/null +++ b/nginx.conf @@ -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; + } +}