feat: CMDB full-stack app - FastAPI + PostgreSQL + React
- PostgreSQL schema: 14 tables, JSONB attributes, audit triggers, soft delete - FastAPI backend: CRUD, search/filter, relationship graph, bulk import, JWT RBAC - React frontend: CI table, detail card, force-graph, dashboard - Seed data: homelab scenario (Proxmox, Mikrotik, VMs, services) - Docker Compose + Kubernetes manifests - 20 backend tests (pytest + httpx)
This commit is contained in:
292
README.md
Normal file
292
README.md
Normal file
@@ -0,0 +1,292 @@
|
||||
# CMDB — Configuration Management Database
|
||||
|
||||
Full-stack CMDB application: **FastAPI + PostgreSQL + React (MUI)**
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────┐ ┌────────────┐ ┌────────────┐
|
||||
│ React │────▶│ Nginx │────▶│ FastAPI │
|
||||
│ (MUI) │ │ (reverse │ │ (async) │
|
||||
│ :3000 │ │ proxy) │ │ :8000 │
|
||||
└──────────┘ │ :80 │ └─────┬──────┘
|
||||
└────────────┘ │
|
||||
┌──────▼──────┐
|
||||
│ PostgreSQL │
|
||||
│ :5432 │
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
## Quick Start (Docker Compose)
|
||||
|
||||
```bash
|
||||
# Clone and start
|
||||
cd cmdb-app
|
||||
docker-compose up -d
|
||||
|
||||
# Apply migrations (if not auto-applied)
|
||||
docker exec -i cmdb-postgres psql -U cmdb -d cmdb < backend/migrations/001_initial_schema.sql
|
||||
docker exec -i cmdb-postgres psql -U cmdb -d cmdb < backend/migrations/002_seed_data.sql
|
||||
|
||||
# Open
|
||||
# API docs: http://localhost/api/docs
|
||||
# Frontend: http://localhost
|
||||
```
|
||||
|
||||
## Local Development
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python -m venv .venv && source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Start PostgreSQL (Docker)
|
||||
docker run -d --name cmdb-pg -p 5432:5432 \
|
||||
-e POSTGRES_DB=cmdb -e POSTGRES_USER=cmdb -e POSTGRES_PASSWORD=cmdb_secret \
|
||||
postgres:16-alpine
|
||||
|
||||
# Run migrations
|
||||
psql -h localhost -U cmdb -d cmdb < migrations/001_initial_schema.sql
|
||||
psql -h localhost -U cmdb -d cmdb < migrations/002_seed_data.sql
|
||||
|
||||
# Start backend
|
||||
uvicorn app.main:app --reload --port 8000
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
# → http://localhost:5173
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Authentication
|
||||
|
||||
```bash
|
||||
# Login
|
||||
curl -X POST http://localhost:8000/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"admin","password":"admin123"}'
|
||||
# → {"access_token":"eyJ...","token_type":"bearer"}
|
||||
|
||||
# Get current user
|
||||
curl -H "Authorization: Bearer <token>" http://localhost:8000/api/auth/me
|
||||
```
|
||||
|
||||
### Configuration Items
|
||||
|
||||
```bash
|
||||
# List CIs (paginated, filtered)
|
||||
curl -H "Authorization: Bearer <token>" \
|
||||
"http://localhost:8000/api/ci?page=1&page_size=10&status=active&search=proxmox"
|
||||
|
||||
# Get single CI with all details
|
||||
curl -H "Authorization: Bearer <token>" \
|
||||
http://localhost:8000/api/ci/<ci_id>
|
||||
|
||||
# Create CI
|
||||
curl -X POST http://localhost:8000/api/ci \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "new-server",
|
||||
"ci_type_id": "<type_uuid>",
|
||||
"status": "active",
|
||||
"tags": ["new", "production"],
|
||||
"attributes": {"cpu": "Xeon", "ram_gb": 32}
|
||||
}'
|
||||
|
||||
# Update CI
|
||||
curl -X PATCH http://localhost:8000/api/ci/<ci_id> \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"status": "maintenance"}'
|
||||
|
||||
# Delete (soft)
|
||||
curl -X DELETE -H "Authorization: Bearer <token>" \
|
||||
http://localhost:8000/api/ci/<ci_id>
|
||||
|
||||
# Export CSV
|
||||
curl -H "Authorization: Bearer <token>" \
|
||||
http://localhost:8000/api/ci/export?format=csv > cmdb_export.csv
|
||||
```
|
||||
|
||||
### Relationships & Graph
|
||||
|
||||
```bash
|
||||
# Add relationship
|
||||
curl -X POST http://localhost:8000/api/ci/<ci_id>/relationships \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"source_ci_id": "<ci_id>",
|
||||
"target_ci_id": "<other_ci_id>",
|
||||
"relationship": "depends_on",
|
||||
"description": "Service depends on server"
|
||||
}'
|
||||
|
||||
# Get relationship graph
|
||||
curl -H "Authorization: Bearer <token>" \
|
||||
"http://localhost:8000/api/ci/graph/visualize?depth=2"
|
||||
```
|
||||
|
||||
### Bulk Import
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/ci/bulk/import \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"items": [
|
||||
{"name": "server-1", "ci_type_name": "PhysicalServer", "status": "active"},
|
||||
{"name": "vm-web", "ci_type_name": "VirtualMachine", "status": "active"}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
### Dashboard
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer <token>" \
|
||||
http://localhost:8000/api/dashboard/stats
|
||||
```
|
||||
|
||||
## Query Parameters (CI List)
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-------------|--------|--------------------------------------|
|
||||
| page | int | Page number (default: 1) |
|
||||
| page_size | int | Items per page (1-100, default: 20) |
|
||||
| search | string | Full-text search on name/desc/serial |
|
||||
| status | string | Filter by status |
|
||||
| ci_type_id | UUID | Filter by CI type |
|
||||
| location_id | UUID | Filter by location |
|
||||
| tag | string | Filter by tag |
|
||||
| owner_id | UUID | Filter by owner |
|
||||
| sort_by | string | Sort field (default: name) |
|
||||
| sort_order | string | asc/desc (default: asc) |
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
### Docker Compose (production)
|
||||
|
||||
```bash
|
||||
# Set secrets
|
||||
export JWT_SECRET=$(openssl rand -hex 32)
|
||||
export POSTGRES_PASSWORD=$(openssl rand -hex 32)
|
||||
|
||||
docker-compose -f docker-compose.yml up -d
|
||||
```
|
||||
|
||||
### Kubernetes
|
||||
|
||||
```bash
|
||||
# Create namespace
|
||||
kubectl create namespace cmdb
|
||||
|
||||
# Create secrets
|
||||
kubectl -n cmdb create secret generic cmdb-secrets \
|
||||
--from-literal=db-user=cmdb \
|
||||
--from-literal=db-password=$(openssl rand -hex 16) \
|
||||
--from-literal=jwt-secret=$(openssl rand -hex 32)
|
||||
|
||||
# Deploy
|
||||
kubectl apply -f k8s/postgres.yaml
|
||||
kubectl apply -f k8s/backend.yaml
|
||||
kubectl apply -f k8s/frontend.yaml
|
||||
|
||||
# Check
|
||||
kubectl -n cmdb get pods
|
||||
```
|
||||
|
||||
## Security Checklist
|
||||
|
||||
- [ ] Change `JWT_SECRET` in production
|
||||
- [ ] Change PostgreSQL password
|
||||
- [ ] Enable SSL/TLS for PostgreSQL (`sslmode=require`)
|
||||
- [ ] Run backend as non-root user
|
||||
- [ ] Configure CORS for production domain only
|
||||
- [ ] Set up `pg_hba.conf` to restrict DB access
|
||||
- [ ] Enable rate limiting (configured: 120 req/min)
|
||||
- [ ] Run `pg_dump` backups daily
|
||||
- [ ] Review audit trail in `changelog` table
|
||||
|
||||
## Database Schema
|
||||
|
||||
### ER Diagram (simplified)
|
||||
|
||||
```
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ ci_classes │────▶│ ci_types │────▶│ cis │
|
||||
└──────────────┘ └──────────────┘ └──────┬───────┘
|
||||
│
|
||||
┌─────────────────────────────┼───────────────────────┐
|
||||
│ │ │ │ │
|
||||
┌─────▼─────┐ ┌────▼─────┐ ┌──────▼──────┐ ┌───▼────┐ ┌──▼──────────┐
|
||||
│ip_addresses│ │ nics │ │ hw_details │ │sw_inst │ │relationships│
|
||||
└───────────┘ └──────────┘ └─────────────┘ └────────┘ └─────────────┘
|
||||
```
|
||||
|
||||
### Key Design Decisions
|
||||
|
||||
1. **Soft delete everywhere** — `deleted_at` column, never hard delete
|
||||
2. **JSONB attributes** — extensible key-value store for class-specific fields
|
||||
3. **Audit trail** — `changelog` table + PostgreSQL triggers
|
||||
4. **Versioning** — CI `version` column incremented on every update
|
||||
5. **UUID primary keys** — safe for distributed/multi-instance
|
||||
6. **INET type** — native PostgreSQL IP address handling
|
||||
|
||||
## Ansible Integration
|
||||
|
||||
```yaml
|
||||
# playbooks/cmdb-import.yml
|
||||
- name: Import Ansible facts into CMDB
|
||||
hosts: all
|
||||
tasks:
|
||||
- name: Get system facts
|
||||
set_fact:
|
||||
ci_data:
|
||||
name: "{{ inventory_hostname }}"
|
||||
status: active
|
||||
attributes:
|
||||
os: "{{ ansible_distribution }} {{ ansible_distribution_version }}"
|
||||
cpu_cores: "{{ ansible_processor_vcpus }}"
|
||||
ram_gb: "{{ (ansible_memtotal_mb / 1024) | round(1) }}"
|
||||
ip: "{{ ansible_default_ipv4.address }}"
|
||||
|
||||
- name: Register in CMDB
|
||||
uri:
|
||||
url: "http://cmdb-host:8000/api/ci"
|
||||
method: POST
|
||||
headers:
|
||||
Authorization: "Bearer {{ cmdb_token }}"
|
||||
body_format: json
|
||||
body: "{{ ci_data }}"
|
||||
status_code: [201, 409]
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
1. **Discovery integration** — nmap, arp-scan, SNMP polling
|
||||
2. **CMDB reconciliation** — compare discovered vs. recorded state
|
||||
3. **Change management** — RFC workflow, approval chain
|
||||
4. **Dependency impact analysis** — cascade failure simulation
|
||||
5. **SSO/LDAP** — corporate directory integration
|
||||
6. **Webhook notifications** — Slack/Teams alerts on CI changes
|
||||
7. **API versioning** — `/api/v2/` with backward compatibility
|
||||
8. **GraphQL** — alternative API layer for complex queries
|
||||
9. **RBAC per CI type** — fine-grained access control
|
||||
10. **Terraform/Pulumi integration** — import IaC resources as CIs
|
||||
8
backend/.env.example
Normal file
8
backend/.env.example
Normal file
@@ -0,0 +1,8 @@
|
||||
# ============================================================
|
||||
# Environment variables for backend
|
||||
# ============================================================
|
||||
DATABASE_URL=postgresql+asyncpg://cmdb:cmdb_secret@localhost:5432/cmdb
|
||||
JWT_SECRET=CHANGE_ME_IN_PRODUCTION
|
||||
CORS_ORIGINS=["http://localhost:3000","http://localhost:5173"]
|
||||
RATE_LIMIT_PER_MINUTE=120
|
||||
DEBUG=false
|
||||
14
backend/Dockerfile
Normal file
14
backend/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends gcc libpq-dev && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
|
||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
29
backend/app/config.py
Normal file
29
backend/app/config.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
APP_NAME: str = "CMDB"
|
||||
APP_VERSION: str = "1.0.0"
|
||||
DEBUG: bool = False
|
||||
|
||||
DATABASE_URL: str = "postgresql+asyncpg://cmdb:cmdb_secret@localhost:5432/cmdb"
|
||||
DATABASE_POOL_SIZE: int = 20
|
||||
DATABASE_MAX_OVERFLOW: int = 10
|
||||
|
||||
JWT_SECRET: str = "CHANGE-ME-IN-PRODUCTION-use-openssl-rand-hex-32"
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
JWT_EXPIRATION_MINUTES: int = 60
|
||||
|
||||
CORS_ORIGINS: list[str] = ["http://localhost:3000", "http://localhost:5173"]
|
||||
|
||||
RATE_LIMIT_PER_MINUTE: int = 120
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
30
backend/app/database.py
Normal file
30
backend/app/database.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
pool_size=settings.DATABASE_POOL_SIZE,
|
||||
max_overflow=settings.DATABASE_MAX_OVERFLOW,
|
||||
echo=settings.DEBUG,
|
||||
)
|
||||
|
||||
AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
async def get_db():
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
35
backend/app/main.py
Normal file
35
backend/app/main.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.config import get_settings
|
||||
from app.routes import auth, ci, reference, dashboard
|
||||
from app.middleware.rate_limit import rate_limit_middleware
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.APP_NAME,
|
||||
version=settings.APP_VERSION,
|
||||
docs_url="/api/docs",
|
||||
redoc_url="/api/redoc",
|
||||
openapi_url="/api/openapi.json",
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.middleware("http")(rate_limit_middleware)
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(ci.router)
|
||||
app.include_router(reference.router)
|
||||
app.include_router(dashboard.router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health_check():
|
||||
return {"status": "healthy", "version": settings.APP_VERSION}
|
||||
0
backend/app/middleware/__init__.py
Normal file
0
backend/app/middleware/__init__.py
Normal file
69
backend/app/middleware/auth.py
Normal file
69
backend/app/middleware/auth.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.config import get_settings
|
||||
from app.database import get_db
|
||||
from app.models.models import User
|
||||
|
||||
settings = get_settings()
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
|
||||
|
||||
def create_access_token(user_id: UUID, role: str, expires_delta: Optional[timedelta] = None) -> str:
|
||||
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=settings.JWT_EXPIRATION_MINUTES))
|
||||
payload = {
|
||||
"sub": str(user_id),
|
||||
"role": role,
|
||||
"exp": expire,
|
||||
}
|
||||
return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict:
|
||||
try:
|
||||
return jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM])
|
||||
except JWTError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
payload = decode_token(credentials.credentials)
|
||||
user_id = payload.get("sub")
|
||||
result = await db.execute(select(User).where(User.id == UUID(user_id)))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user or user.deleted_at:
|
||||
raise HTTPException(status_code=401, detail="User not found")
|
||||
return user
|
||||
|
||||
|
||||
class RequireRole:
|
||||
def __init__(self, *allowed_roles: str):
|
||||
self.allowed_roles = allowed_roles
|
||||
|
||||
async def __call__(self, user: User = Depends(get_current_user)) -> User:
|
||||
if user.role not in self.allowed_roles:
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
return user
|
||||
|
||||
|
||||
require_admin = RequireRole("admin")
|
||||
require_editor = RequireRole("admin", "editor")
|
||||
require_viewer = RequireRole("admin", "editor", "viewer")
|
||||
23
backend/app/middleware/rate_limit.py
Normal file
23
backend/app/middleware/rate_limit.py
Normal file
@@ -0,0 +1,23 @@
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from fastapi import Request, HTTPException
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
_requests: dict[str, list[float]] = defaultdict(list)
|
||||
|
||||
|
||||
async def rate_limit_middleware(request: Request, call_next):
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
now = time.time()
|
||||
window = 60.0
|
||||
|
||||
_requests[client_ip] = [t for t in _requests[client_ip] if now - t < window]
|
||||
|
||||
if len(_requests[client_ip]) >= settings.RATE_LIMIT_PER_MINUTE:
|
||||
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
||||
|
||||
_requests[client_ip].append(now)
|
||||
response = await call_next(request)
|
||||
return response
|
||||
0
backend/app/models/__init__.py
Normal file
0
backend/app/models/__init__.py
Normal file
222
backend/app/models/models.py
Normal file
222
backend/app/models/models.py
Normal file
@@ -0,0 +1,222 @@
|
||||
import uuid
|
||||
from datetime import datetime, date
|
||||
from sqlalchemy import (
|
||||
Column, String, Text, Boolean, Integer, Numeric, Date,
|
||||
DateTime, ForeignKey, Enum as SAEnum, UniqueConstraint, Index
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID, INET, MACADDR, JSONB, ARRAY
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Location(Base):
|
||||
__tablename__ = "locations"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
name = Column(Text, nullable=False, unique=True)
|
||||
description = Column(Text)
|
||||
parent_id = Column(UUID(as_uuid=True), ForeignKey("locations.id"), nullable=True)
|
||||
metadata_ = Column("metadata", JSONB, default={})
|
||||
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
updated_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
username = Column(Text, nullable=False, unique=True)
|
||||
email = Column(Text, nullable=False, unique=True)
|
||||
full_name = Column(Text)
|
||||
role = Column(SAEnum("admin", "editor", "viewer", name="user_role"), nullable=False, default="viewer")
|
||||
team = Column(Text)
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
password_hash = Column(Text, nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
updated_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class CIClass(Base):
|
||||
__tablename__ = "ci_classes"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
name = Column(Text, nullable=False, unique=True)
|
||||
description = Column(Text)
|
||||
parent_id = Column(UUID(as_uuid=True), ForeignKey("ci_classes.id"), nullable=True)
|
||||
icon = Column(Text)
|
||||
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
updated_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class CIType(Base):
|
||||
__tablename__ = "ci_types"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
class_id = Column(UUID(as_uuid=True), ForeignKey("ci_classes.id"), nullable=False)
|
||||
name = Column(Text, nullable=False)
|
||||
description = Column(Text)
|
||||
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
updated_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("class_id", "name"),
|
||||
)
|
||||
|
||||
|
||||
class ConfigurationItem(Base):
|
||||
__tablename__ = "configuration_items"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
ci_type_id = Column(UUID(as_uuid=True), ForeignKey("ci_types.id"), nullable=False)
|
||||
name = Column(Text, nullable=False)
|
||||
description = Column(Text)
|
||||
status = Column(
|
||||
SAEnum("active", "inactive", "maintenance", "deprecated", "planned", name="ci_status"),
|
||||
nullable=False, default="active"
|
||||
)
|
||||
location_id = Column(UUID(as_uuid=True), ForeignKey("locations.id"), nullable=True)
|
||||
serial_number = Column(Text)
|
||||
asset_tag = Column(Text)
|
||||
purchase_date = Column(Date)
|
||||
warranty_expiry = Column(Date)
|
||||
attributes = Column(JSONB, default={})
|
||||
tags = Column(ARRAY(Text), default=[])
|
||||
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"))
|
||||
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
updated_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
version = Column(Integer, nullable=False, default=1)
|
||||
|
||||
ci_type = relationship("CIType", lazy="joined")
|
||||
location = relationship("Location", lazy="joined")
|
||||
created_by_user = relationship("User", lazy="joined")
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_ci_name_type_active", "name", "ci_type_id", unique=True,
|
||||
postgresql_where="deleted_at IS NULL"),
|
||||
)
|
||||
|
||||
|
||||
class IPAddress(Base):
|
||||
__tablename__ = "ip_addresses"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
ci_id = Column(UUID(as_uuid=True), ForeignKey("configuration_items.id"), nullable=False)
|
||||
ip_address = Column(INET, nullable=False)
|
||||
subnet_mask = Column(INET)
|
||||
gateway = Column(INET)
|
||||
dns_servers = Column(ARRAY(INET))
|
||||
is_primary = Column(Boolean, nullable=False, default=False)
|
||||
vlan_id = Column(Integer)
|
||||
dhcp_enabled = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
updated_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class NetworkInterface(Base):
|
||||
__tablename__ = "network_interfaces"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
ci_id = Column(UUID(as_uuid=True), ForeignKey("configuration_items.id"), nullable=False)
|
||||
name = Column(Text, nullable=False)
|
||||
mac_address = Column(MACADDR)
|
||||
speed_mbps = Column(Integer)
|
||||
interface_type = Column(Text, default="ethernet")
|
||||
is_up = Column(Boolean, default=True)
|
||||
ip_address_id = Column(UUID(as_uuid=True), ForeignKey("ip_addresses.id"), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
updated_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class HardwareDetail(Base):
|
||||
__tablename__ = "hardware_details"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
ci_id = Column(UUID(as_uuid=True), ForeignKey("configuration_items.id"), nullable=False)
|
||||
manufacturer = Column(Text)
|
||||
model = Column(Text)
|
||||
cpu_model = Column(Text)
|
||||
cpu_cores = Column(Integer)
|
||||
ram_gb = Column(Numeric(10, 2))
|
||||
storage_gb = Column(Numeric(10, 2))
|
||||
storage_type = Column(Text)
|
||||
form_factor = Column(Text)
|
||||
power_supply = Column(Text)
|
||||
bios_version = Column(Text)
|
||||
serial_number = Column(Text)
|
||||
specs = Column(JSONB, default={})
|
||||
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
updated_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class SoftwareInstance(Base):
|
||||
__tablename__ = "software_instances"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
ci_id = Column(UUID(as_uuid=True), ForeignKey("configuration_items.id"), nullable=False)
|
||||
name = Column(Text, nullable=False)
|
||||
vendor = Column(Text)
|
||||
version = Column(Text)
|
||||
license_key = Column(Text)
|
||||
license_type = Column(Text)
|
||||
install_path = Column(Text)
|
||||
config_path = Column(Text)
|
||||
port = Column(Integer)
|
||||
protocol = Column(Text)
|
||||
start_command = Column(Text)
|
||||
service_user = Column(Text)
|
||||
config = Column(JSONB, default={})
|
||||
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
updated_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class CIRelationship(Base):
|
||||
__tablename__ = "ci_relationships"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
source_ci_id = Column(UUID(as_uuid=True), ForeignKey("configuration_items.id"), nullable=False)
|
||||
target_ci_id = Column(UUID(as_uuid=True), ForeignKey("configuration_items.id"), nullable=False)
|
||||
relationship = Column(
|
||||
SAEnum("depends_on", "connected_to", "hosted_on", "runs_on",
|
||||
"manages", "contains", "part_of", "related_to", name="relationship_type"),
|
||||
nullable=False
|
||||
)
|
||||
description = Column(Text)
|
||||
metadata_ = Column("metadata", JSONB, default={})
|
||||
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
source_ci = relationship("ConfigurationItem", foreign_keys=[source_ci_id], lazy="joined")
|
||||
target_ci = relationship("ConfigurationItem", foreign_keys=[target_ci_id], lazy="joined")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("source_ci_id", "target_ci_id", "relationship"),
|
||||
)
|
||||
|
||||
|
||||
class ChangeLog(Base):
|
||||
__tablename__ = "changelog"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
ci_id = Column(UUID(as_uuid=True), ForeignKey("configuration_items.id"), nullable=False)
|
||||
action = Column(
|
||||
SAEnum("create", "update", "delete", "restore",
|
||||
"relationship_add", "relationship_remove", name="change_action"),
|
||||
nullable=False
|
||||
)
|
||||
changed_by = Column(UUID(as_uuid=True), ForeignKey("users.id"))
|
||||
field_name = Column(Text)
|
||||
old_value = Column(Text)
|
||||
new_value = Column(Text)
|
||||
snapshot = Column(JSONB)
|
||||
version = Column(Integer, nullable=False)
|
||||
comment = Column(Text)
|
||||
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
0
backend/app/routes/__init__.py
Normal file
0
backend/app/routes/__init__.py
Normal file
48
backend/app/routes/auth.py
Normal file
48
backend/app/routes/auth.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.database import get_db
|
||||
from app.models.models import User
|
||||
from app.schemas.schemas import LoginRequest, Token, UserCreate, UserResponse
|
||||
from app.middleware.auth import hash_password, verify_password, create_access_token, get_current_user, require_admin
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(User).where(User.username == body.username))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user or not verify_password(body.password, user.password_hash):
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
token = create_access_token(user.id, user.role)
|
||||
return Token(access_token=token)
|
||||
|
||||
|
||||
@router.post("/users", response_model=UserResponse)
|
||||
async def create_user(
|
||||
body: UserCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_admin: User = Depends(require_admin),
|
||||
):
|
||||
existing = await db.execute(select(User).where(User.username == body.username))
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=409, detail="Username already exists")
|
||||
|
||||
user = User(
|
||||
username=body.username,
|
||||
email=body.email,
|
||||
full_name=body.full_name,
|
||||
role=body.role,
|
||||
team=body.team,
|
||||
password_hash=hash_password(body.password),
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_me(user: User = Depends(get_current_user)):
|
||||
return user
|
||||
391
backend/app/routes/ci.py
Normal file
391
backend/app/routes/ci.py
Normal file
@@ -0,0 +1,391 @@
|
||||
import math
|
||||
import io
|
||||
import csv
|
||||
import json
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, or_
|
||||
from sqlalchemy.orm import selectinload
|
||||
from app.database import get_db
|
||||
from app.models.models import (
|
||||
ConfigurationItem, CIType, CIClass, Location, User, IPAddress,
|
||||
NetworkInterface, HardwareDetail, SoftwareInstance, CIRelationship, Owner
|
||||
)
|
||||
from app.schemas.schemas import (
|
||||
CICreate, CIUpdate, CIResponse, CIDetailResponse, PaginatedResponse,
|
||||
IPAddressCreate, IPAddressResponse, RelationshipCreate, RelationshipResponse,
|
||||
GraphData, GraphNode, GraphEdge, BulkImportRequest, BulkImportItem, OwnerCreate, OwnerResponse
|
||||
)
|
||||
from app.middleware.auth import get_current_user, require_editor, require_viewer, require_admin
|
||||
|
||||
router = APIRouter(prefix="/api/ci", tags=["ci"])
|
||||
|
||||
|
||||
def build_ci_query(filters: dict):
|
||||
q = select(ConfigurationItem).where(ConfigurationItem.deleted_at.is_(None))
|
||||
|
||||
if filters.get("search"):
|
||||
term = f"%{filters['search']}%"
|
||||
q = q.where(or_(
|
||||
ConfigurationItem.name.ilike(term),
|
||||
ConfigurationItem.description.ilike(term),
|
||||
ConfigurationItem.serial_number.ilike(term),
|
||||
ConfigurationItem.asset_tag.ilike(term),
|
||||
))
|
||||
|
||||
if filters.get("status"):
|
||||
q = q.where(ConfigurationItem.status == filters["status"])
|
||||
|
||||
if filters.get("ci_type_id"):
|
||||
q = q.where(ConfigurationItem.ci_type_id == UUID(filters["ci_type_id"]))
|
||||
|
||||
if filters.get("location_id"):
|
||||
q = q.where(ConfigurationItem.location_id == UUID(filters["location_id"]))
|
||||
|
||||
if filters.get("tag"):
|
||||
q = q.where(ConfigurationItem.tags.contains([filters["tag"]]))
|
||||
|
||||
if filters.get("owner_id"):
|
||||
owner_sub = select(Owner.ci_id).where(Owner.user_id == UUID(filters["owner_id"]))
|
||||
q = q.where(ConfigurationItem.id.in_(owner_sub))
|
||||
|
||||
return q
|
||||
|
||||
|
||||
@router.get("", response_model=PaginatedResponse)
|
||||
async def list_cis(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
search: str = Query(None),
|
||||
status: str = Query(None),
|
||||
ci_type_id: str = Query(None),
|
||||
location_id: str = Query(None),
|
||||
tag: str = Query(None),
|
||||
owner_id: str = Query(None),
|
||||
sort_by: str = Query("name"),
|
||||
sort_order: str = Query("asc"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(require_viewer),
|
||||
):
|
||||
filters = {
|
||||
"search": search, "status": status, "ci_type_id": ci_type_id,
|
||||
"location_id": location_id, "tag": tag, "owner_id": owner_id,
|
||||
}
|
||||
|
||||
q = build_ci_query(filters)
|
||||
|
||||
# Sorting
|
||||
sort_col = getattr(ConfigurationItem, sort_by, ConfigurationItem.name)
|
||||
if sort_order == "desc":
|
||||
q = q.order_by(sort_col.desc())
|
||||
else:
|
||||
q = q.order_by(sort_col.asc())
|
||||
|
||||
# Count
|
||||
count_q = select(func.count()).select_from(
|
||||
build_ci_query(filters).subquery()
|
||||
)
|
||||
total = (await db.execute(count_q)).scalar()
|
||||
|
||||
# Paginate
|
||||
q = q.offset((page - 1) * page_size).limit(page_size)
|
||||
q = q.options(
|
||||
selectinload(ConfigurationItem.ci_type),
|
||||
selectinload(ConfigurationItem.location),
|
||||
)
|
||||
result = await db.execute(q)
|
||||
items = result.scalars().all()
|
||||
|
||||
return PaginatedResponse(
|
||||
items=[CIResponse.model_validate(i) for i in items],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
pages=math.ceil(total / page_size) if total else 0,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
async def export_cis(
|
||||
format: str = Query("csv"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(require_viewer),
|
||||
):
|
||||
q = select(ConfigurationItem).where(ConfigurationItem.deleted_at.is_(None))
|
||||
result = await db.execute(q)
|
||||
items = result.scalars().all()
|
||||
|
||||
if format == "csv":
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(["name", "type", "status", "location", "serial_number", "tags", "attributes"])
|
||||
for ci in items:
|
||||
writer.writerow([
|
||||
ci.name, str(ci.ci_type_id), ci.status, str(ci.location_id),
|
||||
ci.serial_number or "", ",".join(ci.tags or []), json.dumps(ci.attributes or {})
|
||||
])
|
||||
output.seek(0)
|
||||
return StreamingResponse(io.BytesIO(output.getvalue().encode()),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=cmdb_export.csv"})
|
||||
else:
|
||||
data = [
|
||||
{"name": ci.name, "status": ci.status, "attributes": ci.attributes, "tags": ci.tags}
|
||||
for ci in items
|
||||
]
|
||||
return json.dumps(data, indent=2)
|
||||
|
||||
|
||||
@router.get("/{ci_id}", response_model=CIDetailResponse)
|
||||
async def get_ci(ci_id: UUID, db: AsyncSession = Depends(get_db), _user: User = Depends(require_viewer)):
|
||||
q = (
|
||||
select(ConfigurationItem)
|
||||
.where(ConfigurationItem.id == ci_id, ConfigurationItem.deleted_at.is_(None))
|
||||
.options(
|
||||
selectinload(ConfigurationItem.ci_type),
|
||||
selectinload(ConfigurationItem.location),
|
||||
)
|
||||
)
|
||||
result = await db.execute(q)
|
||||
ci = result.scalar_one_or_none()
|
||||
if not ci:
|
||||
raise HTTPException(status_code=404, detail="CI not found")
|
||||
|
||||
# Load related data
|
||||
ips = (await db.execute(
|
||||
select(IPAddress).where(IPAddress.ci_id == ci_id, IPAddress.deleted_at.is_(None))
|
||||
)).scalars().all()
|
||||
|
||||
nics = (await db.execute(
|
||||
select(NetworkInterface).where(NetworkInterface.ci_id == ci_id, NetworkInterface.deleted_at.is_(None))
|
||||
)).scalars().all()
|
||||
|
||||
hw = (await db.execute(
|
||||
select(HardwareDetail).where(HardwareDetail.ci_id == ci_id, HardwareDetail.deleted_at.is_(None))
|
||||
)).scalar_one_or_none()
|
||||
|
||||
sw = (await db.execute(
|
||||
select(SoftwareInstance).where(SoftwareInstance.ci_id == ci_id, SoftwareInstance.deleted_at.is_(None))
|
||||
)).scalars().all()
|
||||
|
||||
rels_out = (await db.execute(
|
||||
select(CIRelationship).where(CIRelationship.source_ci_id == ci_id, CIRelationship.deleted_at.is_(None))
|
||||
)).scalars().all()
|
||||
|
||||
rels_in = (await db.execute(
|
||||
select(CIRelationship).where(CIRelationship.target_ci_id == ci_id, CIRelationship.deleted_at.is_(None))
|
||||
)).scalars().all()
|
||||
|
||||
owners = (await db.execute(
|
||||
select(Owner).where(Owner.ci_id == ci_id)
|
||||
)).scalars().all()
|
||||
|
||||
resp = CIDetailResponse.model_validate(ci)
|
||||
resp.ip_addresses = [IPAddressResponse.model_validate(ip) for ip in ips]
|
||||
resp.network_interfaces = [NetworkInterfaceResponse.model_validate(n) for n in nics]
|
||||
resp.hardware_detail = HardwareDetailResponse.model_validate(hw) if hw else None
|
||||
resp.software_instances = [SoftwareInstanceResponse.model_validate(s) for s in sw]
|
||||
resp.relationships_out = [RelationshipResponse.model_validate(r) for r in rels_out]
|
||||
resp.relationships_in = [RelationshipResponse.model_validate(r) for r in rels_in]
|
||||
resp.owners = [OwnerResponse.model_validate(o) for o in owners]
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("", response_model=CIResponse, status_code=201)
|
||||
async def create_ci(body: CICreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_editor)):
|
||||
ci = ConfigurationItem(
|
||||
ci_type_id=body.ci_type_id,
|
||||
name=body.name,
|
||||
description=body.description,
|
||||
status=body.status,
|
||||
location_id=body.location_id,
|
||||
serial_number=body.serial_number,
|
||||
asset_tag=body.asset_tag,
|
||||
purchase_date=body.purchase_date,
|
||||
warranty_expiry=body.warranty_expiry,
|
||||
attributes=body.attributes,
|
||||
tags=body.tags,
|
||||
created_by=user.id,
|
||||
)
|
||||
db.add(ci)
|
||||
await db.flush()
|
||||
await db.refresh(ci)
|
||||
return CIResponse.model_validate(ci)
|
||||
|
||||
|
||||
@router.patch("/{ci_id}", response_model=CIResponse)
|
||||
async def update_ci(ci_id: UUID, body: CIUpdate, db: AsyncSession = Depends(get_db), user: User = Depends(require_editor)):
|
||||
result = await db.execute(
|
||||
select(ConfigurationItem).where(ConfigurationItem.id == ci_id, ConfigurationItem.deleted_at.is_(None))
|
||||
)
|
||||
ci = result.scalar_one_or_none()
|
||||
if not ci:
|
||||
raise HTTPException(status_code=404, detail="CI not found")
|
||||
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(ci, field, value)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(ci)
|
||||
return CIResponse.model_validate(ci)
|
||||
|
||||
|
||||
@router.delete("/{ci_id}", status_code=204)
|
||||
async def delete_ci(ci_id: UUID, db: AsyncSession = Depends(get_db), user: User = Depends(require_editor)):
|
||||
result = await db.execute(
|
||||
select(ConfigurationItem).where(ConfigurationItem.id == ci_id, ConfigurationItem.deleted_at.is_(None))
|
||||
)
|
||||
ci = result.scalar_one_or_none()
|
||||
if not ci:
|
||||
raise HTTPException(status_code=404, detail="CI not found")
|
||||
|
||||
from datetime import datetime
|
||||
ci.deleted_at = datetime.utcnow()
|
||||
await db.flush()
|
||||
|
||||
|
||||
@router.post("/{ci_id}/restore", response_model=CIResponse)
|
||||
async def restore_ci(ci_id: UUID, db: AsyncSession = Depends(get_db), user: User = Depends(require_editor)):
|
||||
result = await db.execute(
|
||||
select(ConfigurationItem).where(ConfigurationItem.id == ci_id, ConfigurationItem.deleted_at.is_(None) == False)
|
||||
)
|
||||
ci = result.scalar_one_or_none()
|
||||
if not ci:
|
||||
raise HTTPException(status_code=404, detail="CI not found")
|
||||
ci.deleted_at = None
|
||||
await db.flush()
|
||||
await db.refresh(ci)
|
||||
return CIResponse.model_validate(ci)
|
||||
|
||||
|
||||
# ── Relationships ───────────────────────────────────────
|
||||
@router.post("/{ci_id}/relationships", response_model=RelationshipResponse, status_code=201)
|
||||
async def add_relationship(ci_id: UUID, body: RelationshipCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_editor)):
|
||||
rel = CIRelationship(
|
||||
source_ci_id=ci_id,
|
||||
target_ci_id=body.target_ci_id,
|
||||
relationship=body.relationship,
|
||||
description=body.description,
|
||||
metadata_=body.metadata,
|
||||
)
|
||||
db.add(rel)
|
||||
await db.flush()
|
||||
await db.refresh(rel)
|
||||
return RelationshipResponse.model_validate(rel)
|
||||
|
||||
|
||||
@router.delete("/{ci_id}/relationships/{rel_id}", status_code=204)
|
||||
async def remove_relationship(ci_id: UUID, rel_id: UUID, db: AsyncSession = Depends(get_db), user: User = Depends(require_editor)):
|
||||
result = await db.execute(
|
||||
select(CIRelationship).where(CIRelationship.id == rel_id, CIRelationship.deleted_at.is_(None))
|
||||
)
|
||||
rel = result.scalar_one_or_none()
|
||||
if not rel:
|
||||
raise HTTPException(status_code=404, detail="Relationship not found")
|
||||
from datetime import datetime
|
||||
rel.deleted_at = datetime.utcnow()
|
||||
await db.flush()
|
||||
|
||||
|
||||
# ── Graph ───────────────────────────────────────────────
|
||||
@router.get("/graph/visualize", response_model=GraphData)
|
||||
async def get_graph(
|
||||
depth: int = Query(2, ge=1, le=5),
|
||||
ci_id: UUID = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(require_viewer),
|
||||
):
|
||||
visited = set()
|
||||
nodes = []
|
||||
edges = []
|
||||
|
||||
async def traverse(current_id: UUID, current_depth: int):
|
||||
if current_depth > depth or str(current_id) in visited:
|
||||
return
|
||||
visited.add(str(current_id))
|
||||
|
||||
ci_result = await db.execute(
|
||||
select(ConfigurationItem).where(
|
||||
ConfigurationItem.id == current_id,
|
||||
ConfigurationItem.deleted_at.is_(None)
|
||||
).options(selectinload(ConfigurationItem.ci_type))
|
||||
)
|
||||
ci = ci_result.scalar_one_or_none()
|
||||
if not ci:
|
||||
return
|
||||
|
||||
class_name = ci.ci_type.name if ci.ci_type else "Unknown"
|
||||
nodes.append(GraphNode(
|
||||
id=str(ci.id), label=ci.name, group=class_name, status=ci.status
|
||||
))
|
||||
|
||||
rels = (await db.execute(
|
||||
select(CIRelationship).where(
|
||||
or_(CIRelationship.source_ci_id == current_id, CIRelationship.target_ci_id == current_id),
|
||||
CIRelationship.deleted_at.is_(None)
|
||||
)
|
||||
)).scalars().all()
|
||||
|
||||
for rel in rels:
|
||||
neighbor_id = rel.target_ci_id if rel.source_ci_id == current_id else rel.source_ci_id
|
||||
edges.append(GraphEdge(
|
||||
source=str(rel.source_ci_id), target=str(rel.target_ci_id),
|
||||
label=rel.relationship
|
||||
))
|
||||
await traverse(neighbor_id, current_depth + 1)
|
||||
|
||||
if ci_id:
|
||||
await traverse(ci_id, 0)
|
||||
else:
|
||||
# Start from first 50 CIs
|
||||
all_cis = (await db.execute(
|
||||
select(ConfigurationItem).where(ConfigurationItem.deleted_at.is_(None)).limit(50)
|
||||
)).scalars().all()
|
||||
for ci in all_cis:
|
||||
await traverse(ci.id, 0)
|
||||
|
||||
return GraphData(nodes=nodes, edges=edges)
|
||||
|
||||
|
||||
# ── Bulk Import ─────────────────────────────────────────
|
||||
@router.post("/bulk/import", status_code=201)
|
||||
async def bulk_import(body: BulkImportRequest, db: AsyncSession = Depends(get_db), user: User = Depends(require_editor)):
|
||||
created = 0
|
||||
errors = []
|
||||
|
||||
for idx, item in enumerate(body.items):
|
||||
try:
|
||||
type_result = await db.execute(
|
||||
select(CIType).join(CIClass).where(CIType.name == item.ci_type_name)
|
||||
)
|
||||
ci_type = type_result.scalar_one_or_none()
|
||||
if not ci_type:
|
||||
errors.append({"index": idx, "error": f"Type '{item.ci_type_name}' not found"})
|
||||
continue
|
||||
|
||||
location_id = None
|
||||
if item.location_name:
|
||||
loc_result = await db.execute(select(Location).where(Location.name == item.location_name))
|
||||
loc = loc_result.scalar_one_or_none()
|
||||
if loc:
|
||||
location_id = loc.id
|
||||
|
||||
ci = ConfigurationItem(
|
||||
ci_type_id=ci_type.id,
|
||||
name=item.name,
|
||||
description=item.description,
|
||||
status=item.status,
|
||||
location_id=location_id,
|
||||
attributes=item.attributes,
|
||||
tags=item.tags,
|
||||
created_by=user.id,
|
||||
)
|
||||
db.add(ci)
|
||||
created += 1
|
||||
except Exception as e:
|
||||
errors.append({"index": idx, "error": str(e)})
|
||||
|
||||
return {"created": created, "errors": errors}
|
||||
51
backend/app/routes/dashboard.py
Normal file
51
backend/app/routes/dashboard.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from app.database import get_db
|
||||
from app.models.models import ConfigurationItem, CIRelationship, Location, User, CIClass, CIType
|
||||
from app.middleware.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_stats(db: AsyncSession = Depends(get_db), _user: User = Depends(get_current_user)):
|
||||
total = (await db.execute(
|
||||
select(func.count()).select_from(ConfigurationItem.__table__).where(
|
||||
ConfigurationItem.deleted_at.is_(None)
|
||||
)
|
||||
)).scalar()
|
||||
|
||||
by_status = (await db.execute(
|
||||
select(ConfigurationItem.status, func.count())
|
||||
.where(ConfigurationItem.deleted_at.is_(None))
|
||||
.group_by(ConfigurationItem.status)
|
||||
)).all()
|
||||
|
||||
by_class = (await db.execute(
|
||||
select(CIClass.name, func.count(ConfigurationItem.id))
|
||||
.join(CIType, CIType.class_id == CIClass.id)
|
||||
.join(ConfigurationItem, ConfigurationItem.ci_type_id == CIType.id)
|
||||
.where(ConfigurationItem.deleted_at.is_(None))
|
||||
.group_by(CIClass.name)
|
||||
)).all()
|
||||
|
||||
total_relationships = (await db.execute(
|
||||
select(func.count()).select_from(CIRelationship.__table__).where(
|
||||
CIRelationship.deleted_at.is_(None)
|
||||
)
|
||||
)).scalar()
|
||||
|
||||
total_locations = (await db.execute(
|
||||
select(func.count()).select_from(Location.__table__).where(
|
||||
Location.deleted_at.is_(None)
|
||||
)
|
||||
)).scalar()
|
||||
|
||||
return {
|
||||
"total_cis": total,
|
||||
"by_status": {s: c for s, c in by_status},
|
||||
"by_class": {n: c for n, c in by_class},
|
||||
"total_relationships": total_relationships,
|
||||
"total_locations": total_locations,
|
||||
}
|
||||
60
backend/app/routes/reference.py
Normal file
60
backend/app/routes/reference.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.database import get_db
|
||||
from app.models.models import CIClass, CIType, Location, User
|
||||
from app.schemas.schemas import (
|
||||
CIClassCreate, CIClassResponse, CITypeCreate, CITypeResponse,
|
||||
LocationCreate, LocationResponse
|
||||
)
|
||||
from app.middleware.auth import require_viewer, require_editor
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["reference"])
|
||||
|
||||
|
||||
@router.get("/classes", response_model=list[CIClassResponse])
|
||||
async def list_classes(db: AsyncSession = Depends(get_db), _user: User = Depends(require_viewer)):
|
||||
result = await db.execute(select(CIClass).where(CIClass.deleted_at.is_(None)).order_by(CIClass.name))
|
||||
return [CIClassResponse.model_validate(c) for c in result.scalars().all()]
|
||||
|
||||
|
||||
@router.post("/classes", response_model=CIClassResponse, status_code=201)
|
||||
async def create_class(body: CIClassCreate, db: AsyncSession = Depends(get_db), _user: User = Depends(require_editor)):
|
||||
cls = CIClass(**body.model_dump())
|
||||
db.add(cls)
|
||||
await db.flush()
|
||||
await db.refresh(cls)
|
||||
return CIClassResponse.model_validate(cls)
|
||||
|
||||
|
||||
@router.get("/types", response_model=list[CITypeResponse])
|
||||
async def list_types(class_id: str = None, db: AsyncSession = Depends(get_db), _user: User = Depends(require_viewer)):
|
||||
q = select(CIType).where(CIType.deleted_at.is_(None))
|
||||
if class_id:
|
||||
q = q.where(CIType.class_id == class_id)
|
||||
result = await db.execute(q.order_by(CIType.name))
|
||||
return [CITypeResponse.model_validate(t) for t in result.scalars().all()]
|
||||
|
||||
|
||||
@router.post("/types", response_model=CITypeResponse, status_code=201)
|
||||
async def create_type(body: CITypeCreate, db: AsyncSession = Depends(get_db), _user: User = Depends(require_editor)):
|
||||
ct = CIType(**body.model_dump())
|
||||
db.add(ct)
|
||||
await db.flush()
|
||||
await db.refresh(ct)
|
||||
return CITypeResponse.model_validate(ct)
|
||||
|
||||
|
||||
@router.get("/locations", response_model=list[LocationResponse])
|
||||
async def list_locations(db: AsyncSession = Depends(get_db), _user: User = Depends(require_viewer)):
|
||||
result = await db.execute(select(Location).where(Location.deleted_at.is_(None)).order_by(Location.name))
|
||||
return [LocationResponse.model_validate(l) for l in result.scalars().all()]
|
||||
|
||||
|
||||
@router.post("/locations", response_model=LocationResponse, status_code=201)
|
||||
async def create_location(body: LocationCreate, db: AsyncSession = Depends(get_db), _user: User = Depends(require_editor)):
|
||||
loc = Location(**body.model_dump())
|
||||
db.add(loc)
|
||||
await db.flush()
|
||||
await db.refresh(loc)
|
||||
return LocationResponse.model_validate(loc)
|
||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
314
backend/app/schemas/schemas.py
Normal file
314
backend/app/schemas/schemas.py
Normal file
@@ -0,0 +1,314 @@
|
||||
from __future__ import annotations
|
||||
from datetime import date, datetime
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# ── Auth ────────────────────────────────────────────────
|
||||
class UserCreate(BaseModel):
|
||||
username: str
|
||||
email: str
|
||||
full_name: Optional[str] = None
|
||||
role: str = "viewer"
|
||||
team: Optional[str] = None
|
||||
password: str
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
username: str
|
||||
email: str
|
||||
full_name: Optional[str]
|
||||
role: str
|
||||
team: Optional[str]
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
# ── Locations ───────────────────────────────────────────
|
||||
class LocationCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
parent_id: Optional[UUID] = None
|
||||
metadata: dict = {}
|
||||
|
||||
|
||||
class LocationResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
name: str
|
||||
description: Optional[str]
|
||||
parent_id: Optional[UUID]
|
||||
metadata: dict
|
||||
created_at: datetime
|
||||
|
||||
|
||||
# ── CI Classes / Types ──────────────────────────────────
|
||||
class CIClassCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
parent_id: Optional[UUID] = None
|
||||
icon: Optional[str] = None
|
||||
|
||||
|
||||
class CIClassResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
name: str
|
||||
description: Optional[str]
|
||||
parent_id: Optional[UUID]
|
||||
icon: Optional[str]
|
||||
|
||||
|
||||
class CITypeCreate(BaseModel):
|
||||
class_id: UUID
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class CITypeResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
class_id: UUID
|
||||
name: str
|
||||
description: Optional[str]
|
||||
|
||||
|
||||
# ── Configuration Item ──────────────────────────────────
|
||||
class CICreate(BaseModel):
|
||||
ci_type_id: UUID
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
status: str = "active"
|
||||
location_id: Optional[UUID] = None
|
||||
serial_number: Optional[str] = None
|
||||
asset_tag: Optional[str] = None
|
||||
purchase_date: Optional[date] = None
|
||||
warranty_expiry: Optional[date] = None
|
||||
attributes: dict = {}
|
||||
tags: list[str] = []
|
||||
|
||||
|
||||
class CIUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
location_id: Optional[UUID] = None
|
||||
serial_number: Optional[str] = None
|
||||
asset_tag: Optional[str] = None
|
||||
purchase_date: Optional[date] = None
|
||||
warranty_expiry: Optional[date] = None
|
||||
attributes: Optional[dict] = None
|
||||
tags: Optional[list[str]] = None
|
||||
|
||||
|
||||
class CIResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
ci_type_id: UUID
|
||||
ci_type: Optional[CITypeResponse] = None
|
||||
name: str
|
||||
description: Optional[str]
|
||||
status: str
|
||||
location_id: Optional[UUID]
|
||||
location: Optional[LocationResponse] = None
|
||||
serial_number: Optional[str]
|
||||
asset_tag: Optional[str]
|
||||
purchase_date: Optional[date]
|
||||
warranty_expiry: Optional[date]
|
||||
attributes: dict
|
||||
tags: list[str]
|
||||
version: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class CIDetailResponse(CIResponse):
|
||||
ip_addresses: list[IPAddressResponse] = []
|
||||
network_interfaces: list[NetworkInterfaceResponse] = []
|
||||
hardware_detail: Optional[HardwareDetailResponse] = None
|
||||
software_instances: list[SoftwareInstanceResponse] = []
|
||||
relationships_out: list[RelationshipResponse] = []
|
||||
relationships_in: list[RelationshipResponse] = []
|
||||
owners: list[OwnerResponse] = []
|
||||
|
||||
|
||||
# ── IP Address ──────────────────────────────────────────
|
||||
class IPAddressCreate(BaseModel):
|
||||
ip_address: str
|
||||
subnet_mask: Optional[str] = None
|
||||
gateway: Optional[str] = None
|
||||
is_primary: bool = False
|
||||
vlan_id: Optional[int] = None
|
||||
|
||||
|
||||
class IPAddressResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
ci_id: UUID
|
||||
ip_address: str
|
||||
subnet_mask: Optional[str]
|
||||
gateway: Optional[str]
|
||||
is_primary: bool
|
||||
vlan_id: Optional[int]
|
||||
|
||||
|
||||
# ── Network Interface ───────────────────────────────────
|
||||
class NetworkInterfaceCreate(BaseModel):
|
||||
name: str
|
||||
mac_address: Optional[str] = None
|
||||
speed_mbps: Optional[int] = None
|
||||
interface_type: str = "ethernet"
|
||||
is_up: bool = True
|
||||
ip_address_id: Optional[UUID] = None
|
||||
|
||||
|
||||
class NetworkInterfaceResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
ci_id: UUID
|
||||
name: str
|
||||
mac_address: Optional[str]
|
||||
speed_mbps: Optional[int]
|
||||
interface_type: str
|
||||
is_up: bool
|
||||
ip_address_id: Optional[UUID]
|
||||
|
||||
|
||||
# ── Hardware Detail ─────────────────────────────────────
|
||||
class HardwareDetailCreate(BaseModel):
|
||||
manufacturer: Optional[str] = None
|
||||
model: Optional[str] = None
|
||||
cpu_model: Optional[str] = None
|
||||
cpu_cores: Optional[int] = None
|
||||
ram_gb: Optional[float] = None
|
||||
storage_gb: Optional[float] = None
|
||||
storage_type: Optional[str] = None
|
||||
form_factor: Optional[str] = None
|
||||
specs: dict = {}
|
||||
|
||||
|
||||
class HardwareDetailResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
ci_id: UUID
|
||||
manufacturer: Optional[str]
|
||||
model: Optional[str]
|
||||
cpu_model: Optional[str]
|
||||
cpu_cores: Optional[int]
|
||||
ram_gb: Optional[float]
|
||||
storage_gb: Optional[float]
|
||||
storage_type: Optional[str]
|
||||
form_factor: Optional[str]
|
||||
specs: dict
|
||||
|
||||
|
||||
# ── Software Instance ───────────────────────────────────
|
||||
class SoftwareInstanceCreate(BaseModel):
|
||||
name: str
|
||||
vendor: Optional[str] = None
|
||||
version: Optional[str] = None
|
||||
port: Optional[int] = None
|
||||
protocol: Optional[str] = None
|
||||
config: dict = {}
|
||||
|
||||
|
||||
class SoftwareInstanceResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
ci_id: UUID
|
||||
name: str
|
||||
vendor: Optional[str]
|
||||
version: Optional[str]
|
||||
port: Optional[int]
|
||||
protocol: Optional[str]
|
||||
config: dict
|
||||
|
||||
|
||||
# ── Relationships ───────────────────────────────────────
|
||||
class RelationshipCreate(BaseModel):
|
||||
source_ci_id: UUID
|
||||
target_ci_id: UUID
|
||||
relationship: str
|
||||
description: Optional[str] = None
|
||||
metadata: dict = {}
|
||||
|
||||
|
||||
class RelationshipResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
source_ci_id: UUID
|
||||
target_ci_id: UUID
|
||||
relationship: str
|
||||
description: Optional[str]
|
||||
metadata: dict
|
||||
created_at: datetime
|
||||
|
||||
|
||||
# ── Owner ───────────────────────────────────────────────
|
||||
class OwnerCreate(BaseModel):
|
||||
user_id: UUID
|
||||
role: str = "owner"
|
||||
|
||||
|
||||
class OwnerResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
ci_id: UUID
|
||||
user_id: UUID
|
||||
role: str
|
||||
|
||||
|
||||
# ── Pagination ──────────────────────────────────────────
|
||||
class PaginatedResponse(BaseModel):
|
||||
items: list
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
pages: int
|
||||
|
||||
|
||||
# ── Graph ───────────────────────────────────────────────
|
||||
class GraphNode(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
group: str
|
||||
status: str
|
||||
|
||||
|
||||
class GraphEdge(BaseModel):
|
||||
source: str
|
||||
target: str
|
||||
label: str
|
||||
|
||||
|
||||
class GraphData(BaseModel):
|
||||
nodes: list[GraphNode]
|
||||
edges: list[GraphEdge]
|
||||
|
||||
|
||||
# ── Bulk Import ─────────────────────────────────────────
|
||||
class BulkImportItem(BaseModel):
|
||||
name: str
|
||||
ci_type_name: str
|
||||
description: Optional[str] = None
|
||||
status: str = "active"
|
||||
location_name: Optional[str] = None
|
||||
attributes: dict = {}
|
||||
tags: list[str] = []
|
||||
|
||||
|
||||
class BulkImportRequest(BaseModel):
|
||||
items: list[BulkImportItem]
|
||||
387
backend/migrations/001_initial_schema.sql
Normal file
387
backend/migrations/001_initial_schema.sql
Normal file
@@ -0,0 +1,387 @@
|
||||
-- ============================================================
|
||||
-- CMDB Schema v1.0 — PostgreSQL
|
||||
-- ============================================================
|
||||
-- Strategy: soft delete everywhere, audit trail via triggers,
|
||||
-- JSONB for extensible attributes, full indexing.
|
||||
-- ============================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- 0. Extensions
|
||||
-- -----------------------------------------------------------
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- 1. Custom ENUM types
|
||||
-- -----------------------------------------------------------
|
||||
CREATE TYPE ci_status AS ENUM (
|
||||
'active', 'inactive', 'maintenance', 'deprecated', 'planned'
|
||||
);
|
||||
|
||||
CREATE TYPE relationship_type AS ENUM (
|
||||
'depends_on', 'connected_to', 'hosted_on', 'runs_on',
|
||||
'manages', 'contains', 'part_of', 'related_to'
|
||||
);
|
||||
|
||||
CREATE TYPE change_action AS ENUM (
|
||||
'create', 'update', 'delete', 'restore', 'relationship_add', 'relationship_remove'
|
||||
);
|
||||
|
||||
CREATE TYPE user_role AS ENUM ('admin', 'editor', 'viewer');
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- 2. Locations
|
||||
-- -----------------------------------------------------------
|
||||
CREATE TABLE locations (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
parent_id UUID REFERENCES locations(id) ON DELETE SET NULL,
|
||||
metadata JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX idx_locations_parent ON locations(parent_id) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_locations_metadata ON locations USING gin(metadata);
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- 3. Users / Teams (owners)
|
||||
-- -----------------------------------------------------------
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
full_name TEXT,
|
||||
role user_role NOT NULL DEFAULT 'viewer',
|
||||
team TEXT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX idx_users_role ON users(role) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_users_team ON users(team) WHERE deleted_at IS NULL AND team IS NOT NULL;
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- 4. CI Classes (taxonomy)
|
||||
-- -----------------------------------------------------------
|
||||
CREATE TABLE ci_classes (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name TEXT NOT NULL UNIQUE, -- e.g. 'Server', 'NetworkDevice', 'Service'
|
||||
description TEXT,
|
||||
parent_id UUID REFERENCES ci_classes(id) ON DELETE SET NULL,
|
||||
icon TEXT, -- optional icon name for UI
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX idx_ci_classes_parent ON ci_classes(parent_id) WHERE deleted_at IS NULL;
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- 5. CI Types (subtypes within a class)
|
||||
-- -----------------------------------------------------------
|
||||
CREATE TABLE ci_types (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
class_id UUID NOT NULL REFERENCES ci_classes(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL, -- e.g. 'PhysicalServer', 'VirtualMachine', 'Switch'
|
||||
description TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
UNIQUE(class_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_ci_types_class ON ci_types(class_id) WHERE deleted_at IS NULL;
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- 6. Attribute definitions (template per class)
|
||||
-- -----------------------------------------------------------
|
||||
CREATE TABLE attributes (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
ci_class_id UUID NOT NULL REFERENCES ci_classes(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL, -- e.g. 'cpu_cores', 'ram_gb'
|
||||
label TEXT NOT NULL, -- display label
|
||||
data_type TEXT NOT NULL DEFAULT 'text', -- text, integer, float, boolean, date, ip, json
|
||||
is_required BOOLEAN NOT NULL DEFAULT false,
|
||||
is_unique BOOLEAN NOT NULL DEFAULT false,
|
||||
default_value TEXT,
|
||||
validation_regex TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
UNIQUE(ci_class_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_attributes_class ON attributes(ci_class_id) WHERE deleted_at IS NULL;
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- 7. Owners (many-to-many: CI ↔ User)
|
||||
-- -----------------------------------------------------------
|
||||
CREATE TABLE owners (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
ci_id UUID NOT NULL,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL DEFAULT 'owner', -- owner, admin, responsible
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(ci_id, user_id, role)
|
||||
);
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- 8. Configuration Items (the core entity)
|
||||
-- -----------------------------------------------------------
|
||||
CREATE TABLE configuration_items (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
ci_type_id UUID NOT NULL REFERENCES ci_types(id) ON DELETE RESTRICT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status ci_status NOT NULL DEFAULT 'active',
|
||||
location_id UUID REFERENCES locations(id) ON DELETE SET NULL,
|
||||
serial_number TEXT,
|
||||
asset_tag TEXT,
|
||||
purchase_date DATE,
|
||||
warranty_expiry DATE,
|
||||
attributes JSONB DEFAULT '{}', -- extensible key-value store
|
||||
tags TEXT[] DEFAULT '{}',
|
||||
created_by UUID REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
version INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE INDEX idx_ci_type ON configuration_items(ci_type_id) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_ci_status ON configuration_items(status) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_ci_location ON configuration_items(location_id) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_ci_name ON configuration_items(name) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_ci_serial ON configuration_items(serial_number) WHERE deleted_at IS NULL AND serial_number IS NOT NULL;
|
||||
CREATE INDEX idx_ci_asset_tag ON configuration_items(asset_tag) WHERE deleted_at IS NULL AND asset_tag IS NOT NULL;
|
||||
CREATE INDEX idx_ci_tags ON configuration_items USING gin(tags) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_ci_attributes ON configuration_items USING gin(attributes) WHERE deleted_at IS NULL;
|
||||
|
||||
-- Unique constraint on name + type for active items
|
||||
CREATE UNIQUE INDEX idx_ci_name_type_active
|
||||
ON configuration_items(name, ci_type_id)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- 9. IP Addresses
|
||||
-- -----------------------------------------------------------
|
||||
CREATE TABLE ip_addresses (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
ci_id UUID NOT NULL REFERENCES configuration_items(id) ON DELETE CASCADE,
|
||||
ip_address INET NOT NULL,
|
||||
subnet_mask INET,
|
||||
gateway INET,
|
||||
dns_servers INET[],
|
||||
is_primary BOOLEAN NOT NULL DEFAULT false,
|
||||
vlan_id INTEGER,
|
||||
dhcp_enabled BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX idx_ip_address ON ip_addresses(ip_address) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_ip_ci ON ip_addresses(ci_id) WHERE deleted_at IS NULL;
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- 10. Network Interfaces
|
||||
-- -----------------------------------------------------------
|
||||
CREATE TABLE network_interfaces (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
ci_id UUID NOT NULL REFERENCES configuration_items(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL, -- eth0, ens192, bond0
|
||||
mac_address MACADDR,
|
||||
speed_mbps INTEGER,
|
||||
interface_type TEXT DEFAULT 'ethernet', -- ethernet, bond, bridge, vlan
|
||||
is_up BOOLEAN DEFAULT true,
|
||||
ip_address_id UUID REFERENCES ip_addresses(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX idx_nic_ci ON network_interfaces(ci_id) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_nic_mac ON network_interfaces(mac_address) WHERE deleted_at IS NULL AND mac_address IS NOT NULL;
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- 11. Hardware Details
|
||||
-- -----------------------------------------------------------
|
||||
CREATE TABLE hardware_details (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
ci_id UUID NOT NULL REFERENCES configuration_items(id) ON DELETE CASCADE,
|
||||
manufacturer TEXT,
|
||||
model TEXT,
|
||||
cpu_model TEXT,
|
||||
cpu_cores INTEGER,
|
||||
ram_gb NUMERIC(10,2),
|
||||
storage_gb NUMERIC(10,2),
|
||||
storage_type TEXT, -- SSD, HDD, NVMe
|
||||
form_factor TEXT, -- 1U, 2U,塔式, 刀片
|
||||
power_supply TEXT,
|
||||
bios_version TEXT,
|
||||
serial_number TEXT,
|
||||
specs JSONB DEFAULT '{}', -- extra hardware specs
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_hw_ci ON hardware_details(ci_id) WHERE deleted_at IS NULL;
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- 12. Software Instances
|
||||
-- -----------------------------------------------------------
|
||||
CREATE TABLE software_instances (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
ci_id UUID NOT NULL REFERENCES configuration_items(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
vendor TEXT,
|
||||
version TEXT,
|
||||
license_key TEXT,
|
||||
license_type TEXT, -- open_source, commercial, trial
|
||||
install_path TEXT,
|
||||
config_path TEXT,
|
||||
port INTEGER,
|
||||
protocol TEXT, -- tcp, udp
|
||||
start_command TEXT,
|
||||
service_user TEXT,
|
||||
config JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX idx_sw_ci ON software_instances(ci_id) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_sw_name ON software_instances(name) WHERE deleted_at IS NULL;
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- 13. CI Relationships (graph edges)
|
||||
-- -----------------------------------------------------------
|
||||
CREATE TABLE ci_relationships (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
source_ci_id UUID NOT NULL REFERENCES configuration_items(id) ON DELETE CASCADE,
|
||||
target_ci_id UUID NOT NULL REFERENCES configuration_items(id) ON DELETE CASCADE,
|
||||
relationship relationship_type NOT NULL,
|
||||
description TEXT,
|
||||
metadata JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
UNIQUE(source_ci_id, target_ci_id, relationship)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_rel_source ON ci_relationships(source_ci_id) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_rel_target ON ci_relationships(target_ci_id) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_rel_type ON ci_relationships(relationship) WHERE deleted_at IS NULL;
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- 14. Change Log (audit trail / versioning)
|
||||
-- -----------------------------------------------------------
|
||||
CREATE TABLE changelog (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
ci_id UUID NOT NULL REFERENCES configuration_items(id) ON DELETE CASCADE,
|
||||
action change_action NOT NULL,
|
||||
changed_by UUID REFERENCES users(id),
|
||||
field_name TEXT,
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
snapshot JSONB, -- full CI snapshot at time of change
|
||||
version INTEGER NOT NULL,
|
||||
comment TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_changelog_ci ON changelog(ci_id);
|
||||
CREATE INDEX idx_changelog_action ON changelog(action);
|
||||
CREATE INDEX idx_changelog_created ON changelog(created_at);
|
||||
CREATE INDEX idx_changelog_version ON changelog(ci_id, version);
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- 15. Audit Trigger Function
|
||||
-- -----------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION audit_trigger_func()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'UPDATE' THEN
|
||||
INSERT INTO changelog (ci_id, action, field_name, old_value, new_value, version, snapshot)
|
||||
VALUES (
|
||||
NEW.id,
|
||||
'update',
|
||||
'general',
|
||||
row_to_json(OLD)::text,
|
||||
row_to_json(NEW)::text,
|
||||
NEW.version,
|
||||
to_jsonb(NEW)
|
||||
);
|
||||
NEW.updated_at = now();
|
||||
NEW.version = OLD.version + 1;
|
||||
RETURN NEW;
|
||||
ELSIF TG_OP = 'DELETE' THEN
|
||||
INSERT INTO changelog (ci_id, action, snapshot, version)
|
||||
VALUES (
|
||||
OLD.id,
|
||||
'delete',
|
||||
to_jsonb(OLD),
|
||||
OLD.version
|
||||
);
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_ci_audit
|
||||
AFTER UPDATE OR DELETE ON configuration_items
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION audit_trigger_func();
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- 16. Updated_at auto-trigger
|
||||
-- -----------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION update_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_locations_updated
|
||||
BEFORE UPDATE ON locations
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
CREATE TRIGGER trg_users_updated
|
||||
BEFORE UPDATE ON users
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
CREATE TRIGGER trg_ci_classes_updated
|
||||
BEFORE UPDATE ON ci_classes
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
CREATE TRIGGER trg_ci_types_updated
|
||||
BEFORE UPDATE ON ci_types
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
CREATE TRIGGER trg_ip_updated
|
||||
BEFORE UPDATE ON ip_addresses
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
CREATE TRIGGER trg_nic_updated
|
||||
BEFORE UPDATE ON network_interfaces
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
CREATE TRIGGER trg_hw_updated
|
||||
BEFORE UPDATE ON hardware_details
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
CREATE TRIGGER trg_sw_updated
|
||||
BEFORE UPDATE ON software_instances
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
COMMIT;
|
||||
178
backend/migrations/002_seed_data.sql
Normal file
178
backend/migrations/002_seed_data.sql
Normal file
@@ -0,0 +1,178 @@
|
||||
-- ============================================================
|
||||
-- CMDB Seed Data — Homelab Scenario
|
||||
-- 5 hosts, 10 VMs, 3 switches, 1 NAS, 5 services
|
||||
-- ============================================================
|
||||
BEGIN;
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- Users
|
||||
-- -----------------------------------------------------------
|
||||
INSERT INTO users (id, username, email, full_name, role, team, password_hash) VALUES
|
||||
('a0000000-0000-0000-0000-000000000001', 'admin', 'admin@homelab.local', 'Admin User', 'admin', 'IT', crypt('admin123', gen_salt('bf'))),
|
||||
('a0000000-0000-0000-0000-000000000002', 'operator','ops@homelab.local', 'Ops Engineer', 'editor', 'Operations', crypt('ops123', gen_salt('bf'))),
|
||||
('a0000000-0000-0000-0000-000000000003', 'viewer', 'viewer@homelab.local', 'Read Only', 'viewer', 'External', crypt('view123', gen_salt('bf')));
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- Locations
|
||||
-- -----------------------------------------------------------
|
||||
INSERT INTO locations (id, name, description) VALUES
|
||||
('b0000000-0000-0000-0000-000000000001', 'Homelab Rack 1', 'Main 42U rack in basement'),
|
||||
('b0000000-0000-0000-0000-000000000002', 'Office Desk', 'Home office desk area'),
|
||||
('b0000000-0000-0000-0000-000000000003', 'Cloud — Hetzner', 'Hetzner CX31 VPS');
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- CI Classes & Types
|
||||
-- -----------------------------------------------------------
|
||||
INSERT INTO ci_classes (id, name, description, icon) VALUES
|
||||
('c0000000-0000-0000-0000-000000000001', 'Hardware', 'Physical hardware devices', 'server'),
|
||||
('c0000000-0000-0000-0000-000000000002', 'NetworkDevice', 'Network infrastructure', 'router'),
|
||||
('c0000000-0000-0000-0000-000000000003', 'Software', 'Software and services', 'software'),
|
||||
('c0000000-0000-0000-0000-000000000004', 'Storage', 'Storage systems', 'storage');
|
||||
|
||||
INSERT INTO ci_types (id, class_id, name, description) VALUES
|
||||
('d0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000001', 'PhysicalServer', 'Bare-metal server'),
|
||||
('d0000000-0000-0000-0000-000000000002', 'c0000000-0000-0000-0000-000000000001', 'VirtualMachine', 'Virtual machine'),
|
||||
('d0000000-0000-0000-0000-000000000003', 'c0000000-0000-0000-0000-000000000002', 'Switch', 'Network switch'),
|
||||
('d0000000-0000-0000-0000-000000000004', 'c0000000-0000-0000-0000-000000000002', 'Router', 'Router / firewall'),
|
||||
('d0000000-0000-0000-0000-000000000005', 'c0000000-0000-0000-0000-000000000003', 'Application', 'Application / service'),
|
||||
('d0000000-0000-0000-0000-000000000006', 'c0000000-0000-0000-0000-000000000004', 'NAS', 'Network attached storage');
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- Physical Servers (5)
|
||||
-- -----------------------------------------------------------
|
||||
INSERT INTO configuration_items (id, ci_type_id, name, description, status, location_id, attributes, tags) VALUES
|
||||
('e0000000-0000-0000-0000-000000000001', 'd0000000-0000-0000-0000-000000000001', 'proxmox-01', 'Primary Proxmox VE host', 'active', 'b0000000-0000-0000-0000-000000000001', '{"os": "Proxmox VE 8.1", "cpu": "Intel Xeon E-2278G", "ram_gb": 64, "storage_tb": 2.0}', ARRAY['proxmox','production']),
|
||||
('e0000000-0000-0000-0000-000000000002', 'd0000000-0000-0000-0000-000000000001', 'proxmox-02', 'Secondary Proxmox VE host', 'active', 'b0000000-0000-0000-0000-000000000001', '{"os": "Proxmox VE 8.1", "cpu": "Intel Xeon E-2278G", "ram_gb": 64, "storage_tb": 4.0}', ARRAY['proxmox','production']),
|
||||
('e0000000-0000-0000-0000-000000000003', 'd0000000-0000-0000-0000-000000000001', 'esxi-01', 'VMware ESXi host (legacy)', 'maintenance', 'b0000000-0000-0000-0000-000000000001', '{"os": "ESXi 7.0U3", "cpu": "AMD EPYC 7302P", "ram_gb": 128, "storage_tb": 8.0}', ARRAY['vmware','legacy']),
|
||||
('e0000000-0000-0000-0000-000000000004', 'd0000000-0000-0000-0000-000000000001', 'backup-nas', 'Synology NAS for backups', 'active', 'b0000000-0000-0000-0000-000000000001', '{"os": "DSM 7.2", "cpu": "Intel Celeron J4125", "ram_gb": 8, "storage_tb": 16.0}', ARRAY['synology','backup']),
|
||||
('e0000000-0000-0000-0000-000000000005', 'd0000000-0000-0000-0000-000000000001', 'hetzner-01', 'Hetzner Cloud CX31', 'active', 'b0000000-0000-0000-0000-000000000003', '{"os": "Ubuntu 22.04", "cpu": "AMD EPYC (shared 4 vCPU)", "ram_gb": 8, "storage_gb": 160}', ARRAY['cloud','hetzner']);
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- Virtual Machines (10)
|
||||
-- -----------------------------------------------------------
|
||||
INSERT INTO configuration_items (id, ci_type_id, name, description, status, location_id, attributes, tags) VALUES
|
||||
('e0000000-0000-0000-0000-000000000010', 'd0000000-0000-0000-0000-000000000002', 'vm-plex', 'Plex Media Server', 'active', 'b0000000-0000-0000-0000-000000000001', '{"os": "Ubuntu 22.04", "cpu_cores": 4, "ram_gb": 16, "disk_gb": 500}', ARRAY['plex','media']),
|
||||
('e0000000-0000-0000-0000-000000000011', 'd0000000-0000-0000-0000-000000000002', 'vm-nextcloud', 'Nextcloud file sync', 'active', 'b0000000-0000-0000-0000-000000000001', '{"os": "Ubuntu 22.04", "cpu_cores": 2, "ram_gb": 4, "disk_gb": 100}', ARRAY['nextcloud','files']),
|
||||
('e0000000-0000-0000-0000-000000000012', 'd0000000-0000-0000-0000-000000000002', 'vm-gitlab', 'GitLab CE instance', 'active', 'b0000000-0000-0000-0000-000000000001', '{"os": "Ubuntu 22.04", "cpu_cores": 4, "ram_gb": 8, "disk_gb": 200}', ARRAY['gitlab','dev']),
|
||||
('e0000000-0000-0000-0000-000000000013', 'd0000000-0000-0000-0000-000000000002', 'vm-monitoring', 'Prometheus + Grafana', 'active', 'b0000000-0000-0000-0000-000000000001', '{"os": "Ubuntu 22.04", "cpu_cores": 2, "ram_gb": 8, "disk_gb": 100}', ARRAY['monitoring','observability']),
|
||||
('e0000000-0000-0000-0000-000000000014', 'd0000000-0000-0000-0000-000000000002', 'vm-dns', 'Pi-hole / AdGuard DNS', 'active', 'b0000000-0000-0000-0000-000000000001', '{"os": "Debian 12", "cpu_cores": 1, "ram_gb": 2, "disk_gb": 20}', ARRAY['dns','network']),
|
||||
('e0000000-0000-0000-0000-000000000015', 'd0000000-0000-0000-0000-000000000002', 'vm-docker', 'Docker workload host', 'active', 'b0000000-0000-0000-0000-000000000001', '{"os": "Ubuntu 22.04", "cpu_cores": 4, "ram_gb": 16, "disk_gb": 250}', ARRAY['docker','containers']),
|
||||
('e0000000-0000-0000-0000-000000000016', 'd0000000-0000-0000-0000-000000000002', 'vm-homeassistant','Home Assistant', 'active', 'b0000000-0000-0000-0000-000000000001', '{"os": "HAOS", "cpu_cores": 2, "ram_gb": 4, "disk_gb": 60}', ARRAY['homeassistant','iot']),
|
||||
('e0000000-0000-0000-0000-000000000017', 'd0000000-0000-0000-0000-000000000002', 'vm-gaming', 'Windows gaming VM', 'inactive', 'b0000000-0000-0000-0000-000000000001', '{"os": "Windows 11", "cpu_cores": 8, "ram_gb": 32, "disk_gb": 500}', ARRAY['windows','gaming']),
|
||||
('e0000000-0000-0000-0000-000000000018', 'd0000000-0000-0000-0000-000000000002', 'vm-jenkins', 'CI/CD Jenkins', 'active', 'b0000000-0000-0000-0000-000000000001', '{"os": "Ubuntu 22.04", "cpu_cores": 2, "ram_gb": 4, "disk_gb": 50}', ARRAY['jenkins','cicd']),
|
||||
('e0000000-0000-0000-0000-000000000019', 'd0000000-0000-0000-0000-000000000002', 'vm-vaultwarden', 'Vaultwarden password mgr', 'active', 'b0000000-0000-0000-0000-000000000001', '{"os": "Debian 12", "cpu_cores": 1, "ram_gb": 2, "disk_gb": 10}', ARRAY['vaultwarden','security']);
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- Network Devices (3 — Mikrotik + 1 switch)
|
||||
-- -----------------------------------------------------------
|
||||
INSERT INTO configuration_items (id, ci_type_id, name, description, status, location_id, attributes, tags) VALUES
|
||||
('e0000000-0000-0000-0000-000000000020', 'd0000000-0000-0000-0000-000000000004', 'mikrotik-core', 'MikroTik CCR1036 core router', 'active', 'b0000000-0000-0000-0000-000000000001', '{"firmware": "RouterOS 7.12", "ports": 36, "throughput_gbps": 36}', ARRAY['mikrotik','router']),
|
||||
('e0000000-0000-0000-0000-000000000021', 'd0000000-0000-0000-0000-000000000003', 'mikrotik-access', 'MikroTik CRS328 access switch', 'active', 'b0000000-0000-0000-0000-000000000001', '{"firmware": "RouterOS 7.12", "ports": 28, "type": "managed"}', ARRAY['mikrotik','switch']),
|
||||
('e0000000-0000-0000-0000-000000000022', 'd0000000-0000-0000-0000-000000000004', 'mikrotik-ap', 'MikroTik hAP ac² WiFi AP', 'active', 'b0000000-0000-0000-0000-000000000002', '{"firmware": "RouterOS 7.12", "wifi_standard": "802.11ac"}', ARRAY['mikrotik','wifi']);
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- NAS
|
||||
-- -----------------------------------------------------------
|
||||
INSERT INTO configuration_items (id, ci_type_id, name, description, status, location_id, attributes, tags) VALUES
|
||||
('e0000000-0000-0000-0000-000000000030', 'd0000000-0000-0000-0000-000000000006', 'synology-ds920', 'Synology DS920+ NAS', 'active', 'b0000000-0000-0000-0000-000000000001', '{"model": "DS920+", "disks": 4, "total_tb": 16, "raid": "SHR-2"}', ARRAY['synology','nas']);
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- Software Instances (5 services)
|
||||
-- -----------------------------------------------------------
|
||||
INSERT INTO configuration_items (id, ci_type_id, name, description, status, attributes, tags) VALUES
|
||||
('e0000000-0000-0000-0000-000000000040', 'd0000000-0000-0000-0000-000000000005', 'svc-plex', 'Plex Media Server', 'active', '{"port": 32400, "protocol": "tcp", "version": "1.40.0"}', ARRAY['plex','media']),
|
||||
('e0000000-0000-0000-0000-000000000041', 'd0000000-0000-0000-0000-000000000005', 'svc-nextcloud', 'Nextcloud WebDAV', 'active', '{"port": 443, "protocol": "tcp", "version": "28.0"}', ARRAY['nextcloud','files']),
|
||||
('e0000000-0000-0000-0000-000000000042', 'd0000000-0000-0000-0000-000000000005', 'svc-gitlab', 'GitLab CE', 'active', '{"port": 443, "protocol": "tcp", "version": "16.8"}', ARRAY['gitlab','dev']),
|
||||
('e0000000-0000-0000-0000-000000000043', 'd0000000-0000-0000-0000-000000000005', 'svc-prometheus', 'Prometheus monitoring', 'active', '{"port": 9090, "protocol": "tcp", "version": "2.49"}', ARRAY['prometheus','monitoring']),
|
||||
('e0000000-0000-0000-0000-000000000044', 'd0000000-0000-0000-0000-000000000005', 'svc-grafana', 'Grafana dashboards', 'active', '{"port": 3000, "protocol": "tcp", "version": "10.3"}', ARRAY['grafana','monitoring']);
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- IP Addresses
|
||||
-- -----------------------------------------------------------
|
||||
INSERT INTO ip_addresses (ci_id, ip_address, subnet_mask, gateway, is_primary, vlan_id) VALUES
|
||||
('e0000000-0000-0000-0000-000000000001', '10.0.0.11/24', '255.255.255.0', '10.0.0.1', true, 10),
|
||||
('e0000000-0000-0000-0000-000000000002', '10.0.0.12/24', '255.255.255.0', '10.0.0.1', true, 10),
|
||||
('e0000000-0000-0000-0000-000000000003', '10.0.0.13/24', '255.255.255.0', '10.0.0.1', true, 10),
|
||||
('e0000000-0000-0000-0000-000000000004', '10.0.0.14/24', '255.255.255.0', '10.0.0.1', true, 10),
|
||||
('e0000000-0000-0000-0000-000000000005', '203.0.113.50/24','255.255.255.0', '203.0.113.1', true, NULL),
|
||||
('e0000000-0000-0000-0000-000000000020', '10.0.0.1/24', '255.255.255.0', NULL, true, 10),
|
||||
('e0000000-0000-0000-0000-000000000021', '10.0.0.2/24', '255.255.255.0', '10.0.0.1', true, 10),
|
||||
('e0000000-0000-0000-0000-000000000022', '10.0.0.3/24', '255.255.255.0', '10.0.0.1', true, 20),
|
||||
('e0000000-0000-0000-0000-000000000030', '10.0.0.15/24', '255.255.255.0', '10.0.0.1', true, 10);
|
||||
|
||||
-- VMs get DHCP from bridge
|
||||
INSERT INTO ip_addresses (ci_id, ip_address, is_primary, vlan_id) VALUES
|
||||
('e0000000-0000-0000-0000-000000000010', '10.0.0.101/24', true, 10),
|
||||
('e0000000-0000-0000-0000-000000000011', '10.0.0.102/24', true, 10),
|
||||
('e0000000-0000-0000-0000-000000000012', '10.0.0.103/24', true, 10),
|
||||
('e0000000-0000-0000-0000-000000000013', '10.0.0.104/24', true, 10),
|
||||
('e0000000-0000-0000-0000-000000000014', '10.0.0.105/24', true, 10),
|
||||
('e0000000-0000-0000-0000-000000000015', '10.0.0.106/24', true, 10),
|
||||
('e0000000-0000-0000-0000-000000000016', '10.0.0.107/24', true, 10),
|
||||
('e0000000-0000-0000-0000-000000000017', '10.0.0.108/24', true, 10),
|
||||
('e0000000-0000-0000-0000-000000000018', '10.0.0.109/24', true, 10),
|
||||
('e0000000-0000-0000-0000-000000000019', '10.0.0.110/24', true, 10);
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- Hardware Details (physical servers + NAS)
|
||||
-- -----------------------------------------------------------
|
||||
INSERT INTO hardware_details (ci_id, manufacturer, model, cpu_model, cpu_cores, ram_gb, storage_gb, storage_type, form_factor, specs) VALUES
|
||||
('e0000000-0000-0000-0000-000000000001', 'SuperMicro', 'X11SCL-IF', 'Intel Xeon E-2278G', 8, 64, 2000, 'NVMe', '1U', '{"ipmi": true, "gbe_ports": 2}'),
|
||||
('e0000000-0000-0000-0000-000000000002', 'SuperMicro', 'X11SCL-IF', 'Intel Xeon E-2278G', 8, 64, 4000, 'NVMe', '1U', '{"ipmi": true, "gbe_ports": 2}'),
|
||||
('e0000000-0000-0000-0000-000000000003', 'Dell', 'R740xd', 'AMD EPYC 7302P', 16, 128, 8000, 'SSD', '2U', '{"idrac": true, "gbe_ports": 4}'),
|
||||
('e0000000-0000-0000-0000-000000000030', 'Synology', 'DS920+', 'Intel Celeron J4125', 4, 8, 16000, 'HDD', 'Desktop', '{"expandable": true, "nvme_cache": true}');
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- Network Interfaces (sample for hosts)
|
||||
-- -----------------------------------------------------------
|
||||
INSERT INTO network_interfaces (ci_id, name, mac_address, speed_mbps, interface_type, is_up) VALUES
|
||||
('e0000000-0000-0000-0000-000000000001', 'enp1s0', 'AA:BB:CC:01:00:01', 1000, 'ethernet', true),
|
||||
('e0000000-0000-0000-0000-000000000001', 'enp2s0', 'AA:BB:CC:01:00:02', 1000, 'ethernet', true),
|
||||
('e0000000-0000-0000-0000-000000000002', 'enp1s0', 'AA:BB:CC:02:00:01', 1000, 'ethernet', true),
|
||||
('e0000000-0000-0000-0000-000000000020', 'ether1', 'CC:DD:EE:01:00:01', 10000, 'ethernet', true),
|
||||
('e0000000-0000-0000-0000-000000000020', 'bond1', 'CC:DD:EE:01:00:02', 20000, 'bond', true);
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- CI Relationships
|
||||
-- -----------------------------------------------------------
|
||||
-- VMs hosted on physical hosts
|
||||
INSERT INTO ci_relationships (source_ci_id, target_ci_id, relationship, description) VALUES
|
||||
('e0000000-0000-0000-0000-000000000010', 'e0000000-0000-0000-0000-000000000001', 'hosted_on', 'Plex runs on proxmox-01'),
|
||||
('e0000000-0000-0000-0000-000000000011', 'e0000000-0000-0000-0000-000000000001', 'hosted_on', 'Nextcloud on proxmox-01'),
|
||||
('e0000000-0000-0000-0000-000000000012', 'e0000000-0000-0000-0000-000000000001', 'hosted_on', 'GitLab on proxmox-01'),
|
||||
('e0000000-0000-0000-0000-000000000013', 'e0000000-0000-0000-0000-000000000002', 'hosted_on', 'Monitoring on proxmox-02'),
|
||||
('e0000000-0000-0000-0000-000000000014', 'e0000000-0000-0000-0000-000000000001', 'hosted_on', 'DNS on proxmox-01'),
|
||||
('e0000000-0000-0000-0000-000000000015', 'e0000000-0000-0000-0000-000000000002', 'hosted_on', 'Docker host on proxmox-02'),
|
||||
('e0000000-0000-0000-0000-000000000016', 'e0000000-0000-0000-0000-000000000001', 'hosted_on', 'HA on proxmox-01'),
|
||||
('e0000000-0000-0000-0000-000000000017', 'e0000000-0000-0000-0000-000000000003', 'hosted_on', 'Gaming VM on esxi-01'),
|
||||
('e0000000-0000-0000-0000-000000000018', 'e0000000-0000-0000-0000-000000000002', 'hosted_on', 'Jenkins on proxmox-02'),
|
||||
('e0000000-0000-0000-0000-000000000019', 'e0000000-0000-0000-0000-000000000002', 'hosted_on', 'Vaultwarden on proxmox-02');
|
||||
|
||||
-- Services depend on VMs
|
||||
INSERT INTO ci_relationships (source_ci_id, target_ci_id, relationship, description) VALUES
|
||||
('e0000000-0000-0000-0000-000000000040', 'e0000000-0000-0000-0000-000000000010', 'runs_on', 'Plex service on vm-plex'),
|
||||
('e0000000-0000-0000-0000-000000000041', 'e0000000-0000-0000-0000-000000000011', 'runs_on', 'Nextcloud on vm-nextcloud'),
|
||||
('e0000000-0000-0000-0000-000000000042', 'e0000000-0000-0000-0000-000000000012', 'runs_on', 'GitLab on vm-gitlab'),
|
||||
('e0000000-0000-0000-0000-000000000043', 'e0000000-0000-0000-0000-000000000013', 'runs_on', 'Prometheus on vm-monitoring'),
|
||||
('e0000000-0000-0000-0000-000000000044', 'e0000000-0000-0000-0000-000000000013', 'runs_on', 'Grafana on vm-monitoring');
|
||||
|
||||
-- Network relationships
|
||||
INSERT INTO ci_relationships (source_ci_id, target_ci_id, relationship, description) VALUES
|
||||
('e0000000-0000-0000-0000-000000000001', 'e0000000-0000-0000-0000-000000000020', 'connected_to', 'proxmox-01 to core router'),
|
||||
('e0000000-0000-0000-0000-000000000002', 'e0000000-0000-0000-0000-000000000020', 'connected_to', 'proxmox-02 to core router'),
|
||||
('e0000000-0000-0000-0000-000000000020', 'e0000000-0000-0000-0000-000000000021', 'connected_to', 'Core router to access switch'),
|
||||
('e0000000-0000-0000-0000-000000000021', 'e0000000-0000-0000-0000-000000000022', 'connected_to', 'Access switch to WiFi AP');
|
||||
|
||||
-- Backup dependencies
|
||||
INSERT INTO ci_relationships (source_ci_id, target_ci_id, relationship, description) VALUES
|
||||
('e0000000-0000-0000-0000-000000000004', 'e0000000-0000-0000-0000-000000000001', 'depends_on', 'NAS backs up proxmox-01'),
|
||||
('e0000000-0000-0000-0000-000000000004', 'e0000000-0000-0000-0000-000000000002', 'depends_on', 'NAS backs up proxmox-02');
|
||||
|
||||
-- Owners
|
||||
INSERT INTO owners (ci_id, user_id, role) VALUES
|
||||
('e0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', 'owner'),
|
||||
('e0000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000001', 'owner'),
|
||||
('e0000000-0000-0000-0000-000000000043', 'a0000000-0000-0000-0000-000000000002', 'responsible'),
|
||||
('e0000000-0000-0000-0000-000000000044', 'a0000000-0000-0000-0000-000000000002', 'responsible');
|
||||
|
||||
COMMIT;
|
||||
74
backend/migrations/003_audit_triggers.sql
Normal file
74
backend/migrations/003_audit_triggers.sql
Normal file
@@ -0,0 +1,74 @@
|
||||
-- ============================================================
|
||||
-- Audit Trigger Migration — Run on production after 001
|
||||
-- Adds per-field audit triggers to configuration_items
|
||||
-- ============================================================
|
||||
BEGIN;
|
||||
|
||||
-- Fine-grained audit: capture individual field changes
|
||||
CREATE OR REPLACE FUNCTION audit_ci_fields_func()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
col TEXT;
|
||||
old_val TEXT;
|
||||
new_val TEXT;
|
||||
BEGIN
|
||||
IF TG_OP = 'UPDATE' THEN
|
||||
FOR col IN SELECT unnest(ARRAY[
|
||||
'name', 'description', 'status', 'location_id',
|
||||
'serial_number', 'asset_tag', 'purchase_date', 'warranty_expiry',
|
||||
'tags'
|
||||
])
|
||||
LOOP
|
||||
old_val := row_to_json(OLD) ->> col;
|
||||
new_val := row_to_json(NEW) ->> col;
|
||||
IF old_val IS DISTINCT FROM new_val THEN
|
||||
INSERT INTO changelog (ci_id, action, field_name, old_value, new_value, version, snapshot)
|
||||
VALUES (NEW.id, 'update', col, old_val, NEW.version, NEW.version, to_jsonb(NEW));
|
||||
END IF;
|
||||
END LOOP;
|
||||
|
||||
-- JSONB attributes changes
|
||||
IF OLD.attributes IS DISTINCT FROM NEW.attributes THEN
|
||||
INSERT INTO changelog (ci_id, action, field_name, old_value, new_value, version, snapshot)
|
||||
VALUES (NEW.id, 'update', 'attributes', OLD.attributes::text, NEW.attributes::text, NEW.version, to_jsonb(NEW));
|
||||
END IF;
|
||||
|
||||
NEW.updated_at = now();
|
||||
NEW.version = OLD.version + 1;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_ci_audit ON configuration_items;
|
||||
CREATE TRIGGER trg_ci_field_audit
|
||||
AFTER UPDATE ON configuration_items
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION audit_ci_fields_func();
|
||||
|
||||
-- Relationship audit
|
||||
CREATE OR REPLACE FUNCTION audit_relationship_func()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
INSERT INTO changelog (ci_id, action, field_name, new_value, version, snapshot)
|
||||
VALUES (NEW.source_ci_id, 'relationship_add', NEW.relationship::text,
|
||||
NEW.target_ci_id::text, 0, to_jsonb(NEW));
|
||||
RETURN NEW;
|
||||
ELSIF TG_OP = 'DELETE' THEN
|
||||
INSERT INTO changelog (ci_id, action, field_name, old_value, version, snapshot)
|
||||
VALUES (OLD.source_ci_id, 'relationship_remove', OLD.relationship::text,
|
||||
OLD.target_ci_id::text, 0, to_jsonb(OLD));
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_relationship_audit
|
||||
AFTER INSERT OR DELETE ON ci_relationships
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION audit_relationship_func();
|
||||
|
||||
COMMIT;
|
||||
3
backend/pyproject.toml
Normal file
3
backend/pyproject.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
14
backend/requirements.txt
Normal file
14
backend/requirements.txt
Normal file
@@ -0,0 +1,14 @@
|
||||
fastapi==0.109.0
|
||||
uvicorn[standard]==0.27.0
|
||||
sqlalchemy[asyncio]==2.0.25
|
||||
asyncpg==0.29.0
|
||||
alembic==1.13.1
|
||||
pydantic==2.5.3
|
||||
pydantic-settings==2.1.0
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
python-multipart==0.0.6
|
||||
httpx==0.26.0
|
||||
pytest==7.4.4
|
||||
pytest-asyncio==0.23.3
|
||||
pytest-httpx==0.27.0
|
||||
199
backend/tests/test_api.py
Normal file
199
backend/tests/test_api.py
Normal file
@@ -0,0 +1,199 @@
|
||||
import pytest
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_token(client: AsyncClient):
|
||||
resp = await client.post("/api/auth/login", json={"username": "admin", "password": "admin123"})
|
||||
return resp.json()["access_token"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def auth_headers(admin_token: str):
|
||||
return {"Authorization": f"Bearer {admin_token}"}
|
||||
|
||||
|
||||
async def test_health(client: AsyncClient):
|
||||
resp = await client.get("/api/health")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "healthy"
|
||||
|
||||
|
||||
async def test_login_success(client: AsyncClient):
|
||||
resp = await client.post("/api/auth/login", json={"username": "admin", "password": "admin123"})
|
||||
assert resp.status_code == 200
|
||||
assert "access_token" in resp.json()
|
||||
|
||||
|
||||
async def test_login_invalid(client: AsyncClient):
|
||||
resp = await client.post("/api/auth/login", json={"username": "admin", "password": "wrong"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
async def test_get_me(client: AsyncClient, auth_headers: dict):
|
||||
resp = await client.get("/api/auth/me", headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["username"] == "admin"
|
||||
|
||||
|
||||
async def test_list_classes(client: AsyncClient, auth_headers: dict):
|
||||
resp = await client.get("/api/classes", headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) >= 4 # Hardware, NetworkDevice, Software, Storage
|
||||
|
||||
|
||||
async def test_list_types(client: AsyncClient, auth_headers: dict):
|
||||
resp = await client.get("/api/types", headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) >= 6
|
||||
|
||||
|
||||
async def test_list_locations(client: AsyncClient, auth_headers: dict):
|
||||
resp = await client.get("/api/locations", headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) >= 3
|
||||
|
||||
|
||||
async def test_list_cis(client: AsyncClient, auth_headers: dict):
|
||||
resp = await client.get("/api/ci", headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] >= 25
|
||||
assert data["page"] == 1
|
||||
|
||||
|
||||
async def test_list_cis_pagination(client: AsyncClient, auth_headers: dict):
|
||||
resp = await client.get("/api/ci", headers=auth_headers, params={"page": 1, "page_size": 5})
|
||||
data = resp.json()
|
||||
assert len(data["items"]) == 5
|
||||
assert data["page_size"] == 5
|
||||
|
||||
|
||||
async def test_list_cis_search(client: AsyncClient, auth_headers: dict):
|
||||
resp = await client.get("/api/ci", headers=auth_headers, params={"search": "proxmox"})
|
||||
data = resp.json()
|
||||
assert data["total"] >= 2
|
||||
assert all("proxmox" in i["name"].lower() for i in data["items"])
|
||||
|
||||
|
||||
async def test_list_cis_filter_status(client: AsyncClient, auth_headers: dict):
|
||||
resp = await client.get("/api/ci", headers=auth_headers, params={"status": "active"})
|
||||
data = resp.json()
|
||||
assert all(i["status"] == "active" for i in data["items"])
|
||||
|
||||
|
||||
async def test_get_ci_detail(client: AsyncClient, auth_headers: dict):
|
||||
list_resp = await client.get("/api/ci", headers=auth_headers)
|
||||
ci_id = list_resp.json()["items"][0]["id"]
|
||||
resp = await client.get(f"/api/ci/{ci_id}", headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
ci = resp.json()
|
||||
assert "ip_addresses" in ci
|
||||
assert "relationships_out" in ci
|
||||
assert "relationships_in" in ci
|
||||
|
||||
|
||||
async def test_create_ci(client: AsyncClient, auth_headers: dict):
|
||||
types_resp = await client.get("/api/types", headers=auth_headers)
|
||||
type_id = types_resp.json()[0]["id"]
|
||||
resp = await client.post("/api/ci", headers=auth_headers, json={
|
||||
"name": "test-ci-auto",
|
||||
"ci_type_id": type_id,
|
||||
"description": "Auto-created for testing",
|
||||
"status": "active",
|
||||
"tags": ["test", "automated"],
|
||||
"attributes": {"cpu_cores": 2, "ram_gb": 4},
|
||||
})
|
||||
assert resp.status_code == 201
|
||||
ci = resp.json()
|
||||
assert ci["name"] == "test-ci-auto"
|
||||
assert ci["version"] == 1
|
||||
|
||||
|
||||
async def test_update_ci(client: AsyncClient, auth_headers: dict):
|
||||
list_resp = await client.get("/api/ci", headers=auth_headers)
|
||||
ci_id = list_resp.json()["items"][0]["id"]
|
||||
resp = await client.patch(f"/api/ci/{ci_id}", headers=auth_headers, json={
|
||||
"description": "Updated description"
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["description"] == "Updated description"
|
||||
|
||||
|
||||
async def test_delete_ci_soft(client: AsyncClient, auth_headers: dict):
|
||||
# Create then delete
|
||||
types_resp = await client.get("/api/types", headers=auth_headers)
|
||||
type_id = types_resp.json()[0]["id"]
|
||||
create_resp = await client.post("/api/ci", headers=auth_headers, json={
|
||||
"name": "to-be-deleted",
|
||||
"ci_type_id": type_id,
|
||||
"status": "active",
|
||||
})
|
||||
ci_id = create_resp.json()["id"]
|
||||
del_resp = await client.delete(f"/api/ci/{ci_id}", headers=auth_headers)
|
||||
assert del_resp.status_code == 204
|
||||
# Should not appear in list
|
||||
list_resp = await client.get("/api/ci", headers=auth_headers, params={"search": "to-be-deleted"})
|
||||
assert list_resp.json()["total"] == 0
|
||||
|
||||
|
||||
async def test_get_nonexistent_ci(client: AsyncClient, auth_headers: dict):
|
||||
resp = await client.get("/api/ci/00000000-0000-0000-0000-000000000000", headers=auth_headers)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
async def test_dashboard_stats(client: AsyncClient, auth_headers: dict):
|
||||
resp = await client.get("/api/dashboard/stats", headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
stats = resp.json()
|
||||
assert stats["total_cis"] > 0
|
||||
assert stats["total_relationships"] > 0
|
||||
|
||||
|
||||
async def test_graph(client: AsyncClient, auth_headers: dict):
|
||||
resp = await client.get("/api/ci/graph/visualize", headers=auth_headers, params={"depth": 1})
|
||||
assert resp.status_code == 200
|
||||
graph = resp.json()
|
||||
assert "nodes" in graph
|
||||
assert "edges" in graph
|
||||
assert len(graph["nodes"]) > 0
|
||||
|
||||
|
||||
async def test_bulk_import(client: AsyncClient, auth_headers: dict):
|
||||
types_resp = await client.get("/api/types", headers=auth_headers)
|
||||
type_name = types_resp.json()[0]["name"]
|
||||
resp = await client.post("/api/ci/bulk/import", headers=auth_headers, json={
|
||||
"items": [
|
||||
{"name": "bulk-1", "ci_type_name": type_name, "status": "active"},
|
||||
{"name": "bulk-2", "ci_type_name": type_name, "status": "active"},
|
||||
]
|
||||
})
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["created"] == 2
|
||||
|
||||
|
||||
async def test_viewer_cannot_create(client: AsyncClient):
|
||||
resp = await client.post("/api/auth/login", json={"username": "viewer", "password": "view123"})
|
||||
token = resp.json()["access_token"]
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
types_resp = await client.get("/api/types", headers=headers)
|
||||
type_id = types_resp.json()[0]["id"]
|
||||
resp = await client.post("/api/ci", headers=headers, json={
|
||||
"name": "should-fail",
|
||||
"ci_type_id": type_id,
|
||||
})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
async def test_unauthorized_access(client: AsyncClient):
|
||||
resp = await client.get("/api/ci")
|
||||
assert resp.status_code == 403 # No auth header → HTTPBearer returns 403
|
||||
69
docker-compose.prod.yml
Normal file
69
docker-compose.prod.yml
Normal file
@@ -0,0 +1,69 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: cmdb-postgres
|
||||
environment:
|
||||
POSTGRES_DB: cmdb
|
||||
POSTGRES_USER: cmdb
|
||||
POSTGRES_PASSWORD: cmdb_secret
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- ./backend/migrations/001_initial_schema.sql:/docker-entrypoint-initdb.d/001.sql
|
||||
- ./backend/migrations/002_seed_data.sql:/docker-entrypoint-initdb.d/002.sql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U cmdb"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: cmdb-redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: cmdb-backend
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://cmdb:cmdb_secret@postgres:5432/cmdb
|
||||
JWT_SECRET: ${JWT_SECRET:-super-secret-change-me}
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
CORS_ORIGINS: '["http://localhost:3000","http://localhost:5173"]'
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: cmdb-frontend
|
||||
ports:
|
||||
- "3000:80"
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: cmdb-nginx
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
depends_on:
|
||||
- backend
|
||||
- frontend
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
69
docker-compose.yml
Normal file
69
docker-compose.yml
Normal file
@@ -0,0 +1,69 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: cmdb-postgres
|
||||
environment:
|
||||
POSTGRES_DB: cmdb
|
||||
POSTGRES_USER: cmdb
|
||||
POSTGRES_PASSWORD: cmdb_secret
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- ./backend/migrations/001_initial_schema.sql:/docker-entrypoint-initdb.d/001.sql
|
||||
- ./backend/migrations/002_seed_data.sql:/docker-entrypoint-initdb.d/002.sql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U cmdb"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: cmdb-redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: cmdb-backend
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://cmdb:cmdb_secret@postgres:5432/cmdb
|
||||
JWT_SECRET: ${JWT_SECRET:-super-secret-change-me}
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
CORS_ORIGINS: '["http://localhost:3000","http://localhost:5173"]'
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: cmdb-frontend
|
||||
ports:
|
||||
- "3000:80"
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: cmdb-nginx
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
depends_on:
|
||||
- backend
|
||||
- frontend
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
91
docs/ansible-integration.md
Normal file
91
docs/ansible-integration.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# Ansible + CMDB Integration Guide
|
||||
|
||||
## Overview
|
||||
|
||||
Ansible can auto-discover infrastructure and register it as CIs in the CMDB.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Install `ansible` and `requests` on your control node
|
||||
2. Get a CMDB API token: `curl -X POST .../api/auth/login -d '{"username":"admin","password":"..."}'`
|
||||
3. Set `CMDB_TOKEN` and `CMDB_URL` in your inventory
|
||||
|
||||
## Playbook: Import Hosts
|
||||
|
||||
```yaml
|
||||
# playbooks/cmdb-sync.yml
|
||||
---
|
||||
- name: Sync Ansible inventory to CMDB
|
||||
hosts: all
|
||||
gather_facts: true
|
||||
vars:
|
||||
cmdb_url: "http://cmdb-host:8000/api"
|
||||
cmdb_token: "{{ lookup('env', 'CMDB_TOKEN') }}"
|
||||
|
||||
tasks:
|
||||
- name: Register host as CI
|
||||
uri:
|
||||
url: "{{ cmdb_url }}/ci"
|
||||
method: POST
|
||||
headers:
|
||||
Authorization: "Bearer {{ cmdb_token }}"
|
||||
Content-Type: "application/json"
|
||||
body_format: json
|
||||
body:
|
||||
name: "{{ inventory_hostname }}"
|
||||
ci_type_name: "{% if 'proxmox' in inventory_hostname %}PhysicalServer{% elif 'vm-' in inventory_hostname %}VirtualMachine{% else %}Application{% endif %}"
|
||||
status: active
|
||||
tags: "{{ group_names }}"
|
||||
attributes:
|
||||
os: "{{ ansible_distribution }} {{ ansible_distribution_version }}"
|
||||
cpu_cores: "{{ ansible_processor_vcpus }}"
|
||||
ram_mb: "{{ ansible_memtotal_mb }}"
|
||||
ip: "{{ ansible_default_ipv4.address | default('N/A') }}"
|
||||
kernel: "{{ ansible_kernel }}"
|
||||
hostname: "{{ ansible_hostname }}"
|
||||
status_code: [201, 409]
|
||||
register: cmdb_result
|
||||
|
||||
- name: Show result
|
||||
debug:
|
||||
msg: "{{ inventory_hostname }} → {{ cmdb_result.status }}"
|
||||
```
|
||||
|
||||
## Playbook: Update Software Versions
|
||||
|
||||
```yaml
|
||||
- name: Update software instances in CMDB
|
||||
hosts: all
|
||||
tasks:
|
||||
- name: Get installed packages
|
||||
package_facts:
|
||||
become: true
|
||||
|
||||
- name: Report key packages to CMDB
|
||||
uri:
|
||||
url: "{{ cmdb_url }}/ci/bulk/import"
|
||||
method: POST
|
||||
headers:
|
||||
Authorization: "Bearer {{ cmdb_token }}"
|
||||
body_format: json
|
||||
body:
|
||||
items: >-
|
||||
{{
|
||||
package_facts.packages.keys() | select('search', 'nginx|docker|postgresql|redis|prometheus') |
|
||||
map(attribute='-', {
|
||||
'name': item,
|
||||
'ci_type_name': 'Application',
|
||||
'attributes': {'version': package_facts.packages[item].version}
|
||||
}) | list
|
||||
}}
|
||||
status_code: [201]
|
||||
when: "'nginx' in package_facts.packages or 'docker-ce' in package_facts.packages"
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Idempotent registration** — use 409 handling to update existing CIs
|
||||
2. **Tag with inventory groups** — auto-tag CIs with Ansible group names
|
||||
3. **Schedule via cron** — `0 */6 * * * ansible-playbook cmdb-sync.yml`
|
||||
4. **Use lookup plugins** — `uri` plugin for API calls
|
||||
5. **Store token securely** — use Ansible Vault for `CMDB_TOKEN`
|
||||
132
docs/security.md
Normal file
132
docs/security.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Security & Operations Guide
|
||||
|
||||
## PostgreSQL Security
|
||||
|
||||
### User Privileges
|
||||
```sql
|
||||
-- Create limited user for the application
|
||||
CREATE USER cmdb_app WITH PASSWORD 'strong_password';
|
||||
GRANT CONNECT ON DATABASE cmdb TO cmdb_app;
|
||||
GRANT USAGE ON SCHEMA public TO cmdb_app;
|
||||
GRANT SELECT, INSERT, UPDATE ON ALL TABLES IN SCHEMA public TO cmdb_app;
|
||||
GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO cmdb_app;
|
||||
|
||||
-- Read-only user for reporting
|
||||
CREATE USER cmdb_reader WITH PASSWORD 'reader_password';
|
||||
GRANT CONNECT ON DATABASE cmdb TO cmdb_reader;
|
||||
GRANT USAGE ON SCHEMA public TO cmdb_reader;
|
||||
GRANT SELECT ON ALL TABLES IN SCHEMA public TO cmdb_reader;
|
||||
```
|
||||
|
||||
### SSL/TLS
|
||||
```ini
|
||||
# postgresql.conf
|
||||
ssl = on
|
||||
ssl_cert_file = '/etc/ssl/certs/server.crt'
|
||||
ssl_key_file = '/etc/ssl/private/server.key'
|
||||
ssl_min_protocol_version = 'TLSv1.2'
|
||||
```
|
||||
|
||||
```bash
|
||||
# pg_hba.conf — force SSL for remote connections
|
||||
hostssl cmdb cmdb_app 10.0.0.0/24 scram-sha-256
|
||||
```
|
||||
|
||||
## Backup Strategy
|
||||
|
||||
### pg_dump (logical backup)
|
||||
```bash
|
||||
# Daily backup script
|
||||
#!/bin/bash
|
||||
BACKUP_DIR="/var/backups/cmdb"
|
||||
DATE=$(date +%Y%m%d_%H%M%S)
|
||||
pg_dump -h localhost -U cmdb cmdb | gzip > "$BACKUP_DIR/cmdb_$DATE.sql.gz"
|
||||
# Keep 30 days
|
||||
find $BACKUP_DIR -name "*.sql.gz" -mtime +30 -delete
|
||||
```
|
||||
|
||||
### pg_basebackup (physical backup)
|
||||
```bash
|
||||
# For PITR (Point-in-Time Recovery)
|
||||
pg_basebackup -h localhost -U replicator -D /var/lib/cmdb-backup \
|
||||
-Fp -Xs -P -R
|
||||
```
|
||||
|
||||
### pgBackRest (enterprise)
|
||||
```ini
|
||||
# pgbackrest.conf
|
||||
[cmdb]
|
||||
pg1-path=/var/lib/postgresql/data
|
||||
repo1-path=/var/lib/pgbackrest
|
||||
repo1-retention-full=2
|
||||
repo1-retention-diff=7
|
||||
```
|
||||
|
||||
### Recovery
|
||||
```bash
|
||||
# From pg_dump
|
||||
dropdb cmdb && createdb cmdb
|
||||
zcat /var/backups/cmdb/cmdb_20240101_030000.sql.gz | psql -U cmdb cmdb
|
||||
|
||||
# From WAL replay
|
||||
restore_command = 'cp /var/lib/pgbackrest/archive/cmdb/%f %p'
|
||||
recovery_target_time = '2024-01-01 12:00:00'
|
||||
```
|
||||
|
||||
## API Security
|
||||
|
||||
### Rate Limiting
|
||||
Configured in `backend/.env`: `RATE_LIMIT_PER_MINUTE=120`
|
||||
|
||||
### CORS
|
||||
```python
|
||||
# Restrict in production
|
||||
CORS_ORIGINS=["https://cmdb.yourdomain.com"]
|
||||
```
|
||||
|
||||
### JWT Best Practices
|
||||
- Rotate secrets quarterly
|
||||
- Short expiration (60 min default)
|
||||
- Store in HTTP-only cookies for web clients
|
||||
- Validate `exp`, `iss`, `aud` claims
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Prometheus Metrics (add to FastAPI)
|
||||
```python
|
||||
from prometheus_client import Counter, Histogram
|
||||
|
||||
REQUEST_COUNT = Counter('cmdb_requests_total', 'Total requests', ['method', 'endpoint'])
|
||||
REQUEST_LATENCY = Histogram('cmdb_request_latency_seconds', 'Request latency', ['endpoint'])
|
||||
```
|
||||
|
||||
### Health Checks
|
||||
```bash
|
||||
# Backend
|
||||
curl http://localhost:8000/api/health
|
||||
# → {"status": "healthy", "version": "1.0.0"}
|
||||
|
||||
# PostgreSQL
|
||||
pg_isready -h localhost -p 5432 -U cmdb
|
||||
```
|
||||
|
||||
### Log Aggregation
|
||||
```yaml
|
||||
# docker-compose logging
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
```
|
||||
|
||||
## Hardening Checklist
|
||||
|
||||
- [ ] Run containers as non-root
|
||||
- [ ] Use read-only filesystem mounts where possible
|
||||
- [ ] Enable seccomp/AppArmor profiles
|
||||
- [ ] Scan images with Trivy/Snyk
|
||||
- [ ] Rotate database passwords regularly
|
||||
- [ ] Audit changelog table weekly
|
||||
- [ ] Monitor for failed login attempts
|
||||
- [ ] Set up alerting for 5xx errors
|
||||
12
frontend/Dockerfile
Normal file
12
frontend/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CMDB</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
30
frontend/package.json
Normal file
30
frontend/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "cmdb-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.3",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/icons-material": "^5.15.4",
|
||||
"@mui/material": "^5.15.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.21.3",
|
||||
"axios": "^1.6.5",
|
||||
"react-force-graph": "^2.1.0",
|
||||
"notistack": "^3.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.12"
|
||||
}
|
||||
}
|
||||
48
frontend/src/App.tsx
Normal file
48
frontend/src/App.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { ThemeProvider, createTheme, CssBaseline } from '@mui/material';
|
||||
import { SnackbarProvider } from 'notistack';
|
||||
import Layout from './components/Layout';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import CIListPage from './pages/CIListPage';
|
||||
import CIDetailPage from './pages/CIDetailPage';
|
||||
import GraphPage from './pages/GraphPage';
|
||||
|
||||
const darkTheme = createTheme({
|
||||
palette: {
|
||||
mode: 'dark',
|
||||
primary: { main: '#4fc3f7' },
|
||||
secondary: { main: '#ff8a65' },
|
||||
background: { default: '#0d1117', paper: '#161b22' },
|
||||
},
|
||||
typography: {
|
||||
fontFamily: '"JetBrains Mono", "Fira Code", monospace',
|
||||
},
|
||||
});
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return <Navigate to="/login" />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ThemeProvider theme={darkTheme}>
|
||||
<CssBaseline />
|
||||
<SnackbarProvider maxSnack={3}>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/" element={<ProtectedRoute><Layout /></ProtectedRoute>}>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="ci" element={<CIListPage />} />
|
||||
<Route path="ci/:id" element={<CIDetailPage />} />
|
||||
<Route path="graph" element={<GraphPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</SnackbarProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
72
frontend/src/components/Layout.tsx
Normal file
72
frontend/src/components/Layout.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useState } from 'react';
|
||||
import { Outlet, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
AppBar, Toolbar, Typography, Drawer, List, ListItemButton,
|
||||
ListItemIcon, ListItemText, Box, IconButton, Avatar, Menu, MenuItem,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Dashboard as DashboardIcon, Server as ServerIcon,
|
||||
AccountTree as GraphIcon, Menu as MenuIcon, Logout as LogoutIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
const DRAWER_WIDTH = 240;
|
||||
|
||||
const menuItems = [
|
||||
{ text: 'Dashboard', icon: <DashboardIcon />, path: '/' },
|
||||
{ text: 'Configuration Items', icon: <ServerIcon />, path: '/ci' },
|
||||
{ text: 'Relationship Graph', icon: <GraphIcon />, path: '/graph' },
|
||||
];
|
||||
|
||||
export default function Layout() {
|
||||
const navigate = useNavigate();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('token');
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<AppBar position="fixed" sx={{ zIndex: (t) => t.zIndex.drawer + 1 }}>
|
||||
<Toolbar>
|
||||
<IconButton color="inherit" edge="start" onClick={() => setMobileOpen(!mobileOpen)} sx={{ mr: 2 }}>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<Typography variant="h6" noWrap sx={{ flexGrow: 1 }}>CMDB</Typography>
|
||||
<IconButton color="inherit" onClick={(e) => setAnchorEl(e.currentTarget)}>
|
||||
<Avatar sx={{ width: 32, height: 32 }}>A</Avatar>
|
||||
</IconButton>
|
||||
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={() => setAnchorEl(null)}>
|
||||
<MenuItem onClick={handleLogout}><LogoutIcon sx={{ mr: 1 }} /> Logout</MenuItem>
|
||||
</Menu>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
sx={{
|
||||
width: DRAWER_WIDTH,
|
||||
flexShrink: 0,
|
||||
'& .MuiDrawer-paper': { width: DRAWER_WIDTH, boxSizing: 'border-box', bgcolor: '#161b22' },
|
||||
}}
|
||||
>
|
||||
<Toolbar />
|
||||
<List>
|
||||
{menuItems.map((item) => (
|
||||
<ListItemButton key={item.text} onClick={() => navigate(item.path)}>
|
||||
<ListItemIcon sx={{ color: '#4fc3f7' }}>{item.icon}</ListItemIcon>
|
||||
<ListItemText primary={item.text} />
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
</Drawer>
|
||||
|
||||
<Box component="main" sx={{ flexGrow: 1, p: 3, ml: `${DRAWER_WIDTH}px` }}>
|
||||
<Toolbar />
|
||||
<Outlet />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
9
frontend/src/main.tsx
Normal file
9
frontend/src/main.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
351
frontend/src/pages/CIDetailPage.tsx
Normal file
351
frontend/src/pages/CIDetailPage.tsx
Normal file
@@ -0,0 +1,351 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Box, Typography, Card, CardContent, Grid, Chip, Button, TextField,
|
||||
Dialog, DialogTitle, DialogContent, DialogActions, Tabs, Tab, Table,
|
||||
TableBody, TableCell, TableContainer, TableHead, TableRow, Paper,
|
||||
IconButton, Tooltip, Alert, CircularProgress, Divider, Select,
|
||||
MenuItem, FormControl, InputLabel,
|
||||
} from '@mui/material';
|
||||
import { ArrowBack, Edit, Save, Link as LinkIcon, Hub } from '@mui/icons-material';
|
||||
import { ciApi, CI } from '../services/api';
|
||||
import { useSnackbar } from 'notistack';
|
||||
|
||||
interface CIDetail extends CI {
|
||||
ip_addresses: any[];
|
||||
network_interfaces: any[];
|
||||
hardware_detail: any;
|
||||
software_instances: any[];
|
||||
relationships_out: any[];
|
||||
relationships_in: any[];
|
||||
owners: any[];
|
||||
}
|
||||
|
||||
export default function CIDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const [ci, setCI] = useState<CIDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editData, setEditData] = useState<Partial<CI>>({});
|
||||
const [tab, setTab] = useState(0);
|
||||
const [relDialogOpen, setRelDialogOpen] = useState(false);
|
||||
const [newRel, setNewRel] = useState({ target_ci_id: '', relationship: 'depends_on', description: '' });
|
||||
|
||||
const fetchCI = async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
const res = await ciApi.get(id);
|
||||
setCI(res.data as CIDetail);
|
||||
} catch {
|
||||
enqueueSnackbar('CI not found', { variant: 'error' });
|
||||
navigate('/ci');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { fetchCI(); }, [id]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
await ciApi.update(id, editData);
|
||||
enqueueSnackbar('CI updated', { variant: 'success' });
|
||||
setEditing(false);
|
||||
fetchCI();
|
||||
} catch (err: any) {
|
||||
enqueueSnackbar(err.response?.data?.detail || 'Update failed', { variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddRelationship = async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
await ciApi.addRelationship(id, { ...newRel, source_ci_id: id });
|
||||
enqueueSnackbar('Relationship added', { variant: 'success' });
|
||||
setRelDialogOpen(false);
|
||||
setNewRel({ target_ci_id: '', relationship: 'depends_on', description: '' });
|
||||
fetchCI();
|
||||
} catch (err: any) {
|
||||
enqueueSnackbar(err.response?.data?.detail || 'Failed', { variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <CircularProgress />;
|
||||
if (!ci) return null;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3, gap: 2 }}>
|
||||
<IconButton onClick={() => navigate('/ci')}><ArrowBack /></IconButton>
|
||||
<Typography variant="h4" sx={{ color: '#4fc3f7' }}>{ci.name}</Typography>
|
||||
<Chip label={ci.status} color={ci.status === 'active' ? 'success' : 'default'} />
|
||||
<Typography variant="body2" color="text.secondary">v{ci.version}</Typography>
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
{editing ? (
|
||||
<>
|
||||
<Button startIcon={<Save />} variant="contained" onClick={handleSave}>Save</Button>
|
||||
<Button onClick={() => setEditing(false)}>Cancel</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button startIcon={<Edit />} variant="outlined" onClick={() => { setEditData(ci); setEditing(true); }}>
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={8}>
|
||||
<Card sx={{ bgcolor: '#161b22' }}>
|
||||
<CardContent>
|
||||
<Tabs value={tab} onChange={(_, v) => setTab(v)}>
|
||||
<Tab label="Details" />
|
||||
<Tab label="IP Addresses" />
|
||||
<Tab label="Network" />
|
||||
<Tab label="Hardware" />
|
||||
<Tab label="Software" />
|
||||
<Tab label={`Relationships (${ci.relationships_out?.length + ci.relationships_in?.length || 0})`} />
|
||||
</Tabs>
|
||||
|
||||
{tab === 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
{editing ? (
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<TextField fullWidth label="Name" value={editData.name || ''}
|
||||
onChange={(e) => setEditData({ ...editData, name: e.target.value })} />
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField fullWidth label="Description" multiline rows={2}
|
||||
value={editData.description || ''}
|
||||
onChange={(e) => setEditData({ ...editData, description: e.target.value })} />
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<TextField fullWidth label="Serial Number" value={editData.serial_number || ''}
|
||||
onChange={(e) => setEditData({ ...editData, serial_number: e.target.value })} />
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<TextField fullWidth label="Asset Tag" value={editData.asset_tag || ''}
|
||||
onChange={(e) => setEditData({ ...editData, asset_tag: e.target.value })} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
) : (
|
||||
<>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>{ci.description || 'No description'}</Typography>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={6}><strong>Type:</strong> {ci.ci_type?.name}</Grid>
|
||||
<Grid item xs={6}><strong>Location:</strong> {ci.location?.name || '—'}</Grid>
|
||||
<Grid item xs={6}><strong>Serial:</strong> {ci.serial_number || '—'}</Grid>
|
||||
<Grid item xs={6}><strong>Asset Tag:</strong> {ci.asset_tag || '—'}</Grid>
|
||||
<Grid item xs={6}><strong>Created:</strong> {new Date(ci.created_at).toLocaleString()}</Grid>
|
||||
<Grid item xs={6}><strong>Updated:</strong> {new Date(ci.updated_at).toLocaleString()}</Grid>
|
||||
</Grid>
|
||||
{ci.attributes && Object.keys(ci.attributes).length > 0 && (
|
||||
<>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography variant="subtitle2" gutterBottom>Attributes (JSONB)</Typography>
|
||||
<Paper sx={{ p: 2, bgcolor: '#0d1117' }}>
|
||||
<pre style={{ margin: 0, fontSize: '0.85rem', color: '#81c784' }}>
|
||||
{JSON.stringify(ci.attributes, null, 2)}
|
||||
</pre>
|
||||
</Paper>
|
||||
</>
|
||||
)}
|
||||
{ci.tags?.length > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
{ci.tags.map((t) => <Chip key={t} label={t} size="small" sx={{ mr: 0.5 }} />)}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{tab === 1 && (
|
||||
<TableContainer sx={{ mt: 2 }}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>IP Address</TableCell>
|
||||
<TableCell>Subnet</TableCell>
|
||||
<TableCell>Gateway</TableCell>
|
||||
<TableCell>VLAN</TableCell>
|
||||
<TableCell>Primary</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{ci.ip_addresses?.map((ip: any) => (
|
||||
<TableRow key={ip.id}>
|
||||
<TableCell sx={{ fontFamily: 'monospace' }}>{ip.ip_address}</TableCell>
|
||||
<TableCell>{ip.subnet_mask || '—'}</TableCell>
|
||||
<TableCell>{ip.gateway || '—'}</TableCell>
|
||||
<TableCell>{ip.vlan_id || '—'}</TableCell>
|
||||
<TableCell>{ip.is_primary ? '✓' : ''}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
{tab === 2 && (
|
||||
<TableContainer sx={{ mt: 2 }}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>MAC</TableCell>
|
||||
<TableCell>Speed</TableCell>
|
||||
<TableCell>Type</TableCell>
|
||||
<TableCell>Up</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{ci.network_interfaces?.map((n: any) => (
|
||||
<TableRow key={n.id}>
|
||||
<TableCell>{n.name}</TableCell>
|
||||
<TableCell sx={{ fontFamily: 'monospace' }}>{n.mac_address || '—'}</TableCell>
|
||||
<TableCell>{n.speed_mbps ? `${n.speed_mbps} Mbps` : '—'}</TableCell>
|
||||
<TableCell>{n.interface_type}</TableCell>
|
||||
<TableCell>{n.is_up ? '🟢' : '🔴'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
{tab === 3 && ci.hardware_detail && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Grid container spacing={2}>
|
||||
{Object.entries(ci.hardware_detail).filter(([k]) => !['id','ci_id','created_at','updated_at','deleted_at'].includes(k)).map(([k, v]) => (
|
||||
<Grid item xs={6} sm={4} key={k}>
|
||||
<Typography variant="caption" color="text.secondary">{k.replace(/_/g, ' ')}</Typography>
|
||||
<Typography>{String(v ?? '—')}</Typography>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{tab === 4 && (
|
||||
<TableContainer sx={{ mt: 2 }}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Version</TableCell>
|
||||
<TableCell>Port</TableCell>
|
||||
<TableCell>Protocol</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{ci.software_instances?.map((s: any) => (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell>{s.name}</TableCell>
|
||||
<TableCell>{s.version || '—'}</TableCell>
|
||||
<TableCell>{s.port || '—'}</TableCell>
|
||||
<TableCell>{s.protocol || '—'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
{tab === 5 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Button startIcon={<LinkIcon />} onClick={() => setRelDialogOpen(true)} sx={{ mb: 2 }}>
|
||||
Add Relationship
|
||||
</Button>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Direction</TableCell>
|
||||
<TableCell>Type</TableCell>
|
||||
<TableCell>Target</TableCell>
|
||||
<TableCell>Description</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{ci.relationships_out?.map((r: any) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell><Chip label="→ outgoing" size="small" color="primary" /></TableCell>
|
||||
<TableCell>{r.relationship}</TableCell>
|
||||
<TableCell>{r.target_ci_id}</TableCell>
|
||||
<TableCell>{r.description || '—'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{ci.relationships_in?.map((r: any) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell><Chip label="← incoming" size="small" color="secondary" /></TableCell>
|
||||
<TableCell>{r.relationship}</TableCell>
|
||||
<TableCell>{r.source_ci_id}</TableCell>
|
||||
<TableCell>{r.description || '—'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={4}>
|
||||
<Card sx={{ bgcolor: '#161b22', mb: 2 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>Quick Info</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Box><strong>Type:</strong> {ci.ci_type?.name}</Box>
|
||||
<Box><strong>Status:</strong> {ci.status}</Box>
|
||||
<Box><strong>Location:</strong> {ci.location?.name || 'N/A'}</Box>
|
||||
<Box><strong>IPs:</strong> {ci.ip_addresses?.length || 0}</Box>
|
||||
<Box><strong>NICs:</strong> {ci.network_interfaces?.length || 0}</Box>
|
||||
<Box><strong>Relationships:</strong> {(ci.relationships_out?.length || 0) + (ci.relationships_in?.length || 0)}</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card sx={{ bgcolor: '#161b22' }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>Tags</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{ci.tags?.map((t) => <Chip key={t} label={t} size="small" />) || 'No tags'}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Dialog open={relDialogOpen} onClose={() => setRelDialogOpen(false)}>
|
||||
<DialogTitle>Add Relationship</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField fullWidth label="Target CI ID" margin="normal" value={newRel.target_ci_id}
|
||||
onChange={(e) => setNewRel({ ...newRel, target_ci_id: e.target.value })} />
|
||||
<FormControl fullWidth margin="normal">
|
||||
<InputLabel>Type</InputLabel>
|
||||
<Select value={newRel.relationship} label="Type"
|
||||
onChange={(e) => setNewRel({ ...newRel, relationship: e.target.value })}>
|
||||
{['depends_on', 'connected_to', 'hosted_on', 'runs_on', 'manages', 'contains', 'part_of', 'related_to'].map(r => (
|
||||
<MenuItem key={r} value={r}>{r}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField fullWidth label="Description" margin="normal" value={newRel.description}
|
||||
onChange={(e) => setNewRel({ ...newRel, description: e.target.value })} />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setRelDialogOpen(false)}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleAddRelationship}
|
||||
disabled={!newRel.target_ci_id}>Add</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
241
frontend/src/pages/CIListPage.tsx
Normal file
241
frontend/src/pages/CIListPage.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Box, Typography, Table, TableBody, TableCell, TableContainer,
|
||||
TableHead, TableRow, Paper, Chip, IconButton, TextField, Select,
|
||||
MenuItem, FormControl, InputLabel, TablePagination, Button,
|
||||
Dialog, DialogTitle, DialogContent, DialogActions, Alert,
|
||||
Tooltip, CircularProgress,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Refresh as RefreshIcon, Add as AddIcon, Delete as DeleteIcon,
|
||||
Edit as EditIcon, Search as SearchIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { ciApi, refApi, CI, PaginatedResponse } from '../services/api';
|
||||
import { useSnackbar } from 'notistack';
|
||||
|
||||
const STATUS_COLORS: Record<string, 'success' | 'warning' | 'error' | 'default' | 'info'> = {
|
||||
active: 'success', inactive: 'default', maintenance: 'warning',
|
||||
deprecated: 'error', planned: 'info',
|
||||
};
|
||||
|
||||
export default function CIListPage() {
|
||||
const navigate = useNavigate();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const [data, setData] = useState<PaginatedResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(20);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState('');
|
||||
const [sortBy, setSortBy] = useState('name');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [newCI, setNewCI] = useState({ name: '', ci_type_id: '', description: '', status: 'active' });
|
||||
const [types, setTypes] = useState<any[]>([]);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await ciApi.list({
|
||||
page: page + 1, page_size: rowsPerPage,
|
||||
search: search || undefined, status: statusFilter || undefined,
|
||||
ci_type_id: typeFilter || undefined, sort_by: sortBy, sort_order: sortOrder,
|
||||
});
|
||||
setData(res.data);
|
||||
} catch (err: any) {
|
||||
enqueueSnackbar(err.response?.data?.detail || 'Failed to load CIs', { variant: 'error' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, rowsPerPage, search, statusFilter, typeFilter, sortBy, sortOrder]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
useEffect(() => { refApi.types().then(r => setTypes(r.data)); }, []);
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
await ciApi.create(newCI);
|
||||
enqueueSnackbar('CI created', { variant: 'success' });
|
||||
setCreateOpen(false);
|
||||
setNewCI({ name: '', ci_type_id: '', description: '', status: 'active' });
|
||||
fetchData();
|
||||
} catch (err: any) {
|
||||
enqueueSnackbar(err.response?.data?.detail || 'Create failed', { variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await ciApi.delete(id);
|
||||
enqueueSnackbar('CI deleted', { variant: 'success' });
|
||||
setDeleteConfirm(null);
|
||||
fetchData();
|
||||
} catch (err: any) {
|
||||
enqueueSnackbar(err.response?.data?.detail || 'Delete failed', { variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="h4">Configuration Items</Typography>
|
||||
<Box>
|
||||
<Button startIcon={<RefreshIcon />} onClick={fetchData} sx={{ mr: 1 }}>Refresh</Button>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}
|
||||
sx={{ bgcolor: '#4fc3f7', '&:hover': { bgcolor: '#29b6f6' } }}>
|
||||
New CI
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Paper sx={{ p: 2, mb: 2, bgcolor: '#161b22' }}>
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||
<TextField
|
||||
size="small" placeholder="Search CIs..." value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||
InputProps={{ startAdornment: <SearchIcon sx={{ mr: 1, opacity: 0.5 }} /> }}
|
||||
sx={{ minWidth: 250 }}
|
||||
/>
|
||||
<FormControl size="small" sx={{ minWidth: 120 }}>
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select value={statusFilter} label="Status"
|
||||
onChange={(e) => { setStatusFilter(e.target.value); setPage(0); }}>
|
||||
<MenuItem value="">All</MenuItem>
|
||||
{['active', 'inactive', 'maintenance', 'deprecated', 'planned'].map(s => (
|
||||
<MenuItem key={s} value={s}>{s}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small" sx={{ minWidth: 150 }}>
|
||||
<InputLabel>Type</InputLabel>
|
||||
<Select value={typeFilter} label="Type"
|
||||
onChange={(e) => { setTypeFilter(e.target.value); setPage(0); }}>
|
||||
<MenuItem value="">All</MenuItem>
|
||||
{types.map((t: any) => (
|
||||
<MenuItem key={t.id} value={t.id}>{t.name}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<TableContainer component={Paper} sx={{ bgcolor: '#161b22' }}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Type</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Location</TableCell>
|
||||
<TableCell>Tags</TableCell>
|
||||
<TableCell>Version</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow><TableCell colSpan={7} align="center"><CircularProgress /></TableCell></TableRow>
|
||||
) : data?.items.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={7} align="center">No CIs found</TableCell></TableRow>
|
||||
) : (
|
||||
data?.items.map((ci) => (
|
||||
<TableRow key={ci.id} hover sx={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/ci/${ci.id}`)}>
|
||||
<TableCell>
|
||||
<Typography sx={{ fontWeight: 600, color: '#4fc3f7' }}>{ci.name}</Typography>
|
||||
{ci.serial_number && (
|
||||
<Typography variant="caption" color="text.secondary">SN: {ci.serial_number}</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{ci.ci_type?.name || '—'}</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={ci.status} size="small" color={STATUS_COLORS[ci.status] || 'default'} />
|
||||
</TableCell>
|
||||
<TableCell>{ci.location?.name || '—'}</TableCell>
|
||||
<TableCell>
|
||||
{ci.tags?.slice(0, 3).map((tag) => (
|
||||
<Chip key={tag} label={tag} size="small" variant="outlined" sx={{ mr: 0.5 }} />
|
||||
))}
|
||||
</TableCell>
|
||||
<TableCell>v{ci.version}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="Edit">
|
||||
<IconButton size="small" onClick={(e) => { e.stopPropagation(); navigate(`/ci/${ci.id}`); }}>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete">
|
||||
<IconButton size="small" color="error"
|
||||
onClick={(e) => { e.stopPropagation(); setDeleteConfirm(ci.id); }}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<TablePagination
|
||||
component="div" count={data?.total || 0} page={page}
|
||||
rowsPerPage={rowsPerPage}
|
||||
onPageChange={(_, p) => setPage(p)}
|
||||
onRowsPerPageChange={(e) => { setRowsPerPage(parseInt(e.target.value)); setPage(0); }}
|
||||
rowsPerPageOptions={[10, 20, 50]}
|
||||
/>
|
||||
</TableContainer>
|
||||
|
||||
{/* Create Dialog */}
|
||||
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Create Configuration Item</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField fullWidth label="Name" margin="normal" value={newCI.name}
|
||||
onChange={(e) => setNewCI({ ...newCI, name: e.target.value })} />
|
||||
<FormControl fullWidth margin="normal">
|
||||
<InputLabel>Type</InputLabel>
|
||||
<Select value={newCI.ci_type_id} label="Type"
|
||||
onChange={(e) => setNewCI({ ...newCI, ci_type_id: e.target.value })}>
|
||||
{types.map((t: any) => (
|
||||
<MenuItem key={t.id} value={t.id}>{t.name}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField fullWidth label="Description" margin="normal" multiline rows={2}
|
||||
value={newCI.description}
|
||||
onChange={(e) => setNewCI({ ...newCI, description: e.target.value })} />
|
||||
<FormControl fullWidth margin="normal">
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select value={newCI.status} label="Status"
|
||||
onChange={(e) => setNewCI({ ...newCI, status: e.target.value })}>
|
||||
{['active', 'inactive', 'maintenance', 'deprecated', 'planned'].map(s => (
|
||||
<MenuItem key={s} value={s}>{s}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleCreate}
|
||||
disabled={!newCI.name || !newCI.ci_type_id}>Create</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirm */}
|
||||
<Dialog open={!!deleteConfirm} onClose={() => setDeleteConfirm(null)}>
|
||||
<DialogTitle>Delete CI?</DialogTitle>
|
||||
<DialogContent>
|
||||
<Alert severity="warning">This will soft-delete the CI. It can be restored later.</Alert>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteConfirm(null)}>Cancel</Button>
|
||||
<Button color="error" variant="contained" onClick={() => deleteConfirm && handleDelete(deleteConfirm)}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
84
frontend/src/pages/DashboardPage.tsx
Normal file
84
frontend/src/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Box, Card, CardContent, Typography, Grid } from '@mui/material';
|
||||
import { Server, Hub, LocationOn, TrendingUp } from '@mui/icons-material';
|
||||
import { dashboardApi } from '../services/api';
|
||||
|
||||
interface Stats {
|
||||
total_cis: number;
|
||||
by_status: Record<string, number>;
|
||||
by_class: Record<string, number>;
|
||||
total_relationships: number;
|
||||
total_locations: number;
|
||||
}
|
||||
|
||||
const StatCard = ({ title, value, icon, color }: { title: string; value: number | string; icon: React.ReactNode; color: string }) => (
|
||||
<Card sx={{ bgcolor: '#161b22', borderLeft: `4px solid ${color}` }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary">{title}</Typography>
|
||||
<Typography variant="h4" sx={{ color, mt: 1 }}>{value}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ color, opacity: 0.3 }}>{icon}</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
dashboardApi.stats().then((r) => setStats(r.data));
|
||||
}, []);
|
||||
|
||||
if (!stats) return <Typography>Loading...</Typography>;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>Dashboard</Typography>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<StatCard title="Total CIs" value={stats.total_cis} icon={<Server fontSize="large" />} color="#4fc3f7" />
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<StatCard title="Relationships" value={stats.total_relationships} icon={<Hub fontSize="large" />} color="#ff8a65" />
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<StatCard title="Locations" value={stats.total_locations} icon={<LocationOn fontSize="large" />} color="#81c784" />
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<StatCard title="Active" value={stats.by_status?.active || 0} icon={<TrendingUp fontSize="large" />} color="#ba68c8" />
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card sx={{ bgcolor: '#161b22' }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>By Status</Typography>
|
||||
{Object.entries(stats.by_status || {}).map(([status, count]) => (
|
||||
<Box key={status} sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography>{status}</Typography>
|
||||
<Typography sx={{ color: '#4fc3f7' }}>{count}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card sx={{ bgcolor: '#161b22' }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>By Class</Typography>
|
||||
{Object.entries(stats.by_class || {}).map(([cls, count]) => (
|
||||
<Box key={cls} sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography>{cls}</Typography>
|
||||
<Typography sx={{ color: '#ff8a65' }}>{count}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
195
frontend/src/pages/GraphPage.tsx
Normal file
195
frontend/src/pages/GraphPage.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { Box, Typography, Card, CardContent, Slider, Button, CircularProgress, Alert } from '@mui/material';
|
||||
import { ciApi, GraphData } from '../services/api';
|
||||
import { useSnackbar } from 'notistack';
|
||||
|
||||
export default function GraphPage() {
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [graph, setGraph] = useState<GraphData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [depth, setDepth] = useState(2);
|
||||
const [selectedNode, setSelectedNode] = useState<string | null>(null);
|
||||
|
||||
const fetchGraph = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await ciApi.graph({ depth });
|
||||
setGraph(res.data);
|
||||
} catch {
|
||||
enqueueSnackbar('Failed to load graph', { variant: 'error' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { fetchGraph(); }, [depth]);
|
||||
|
||||
// Simple force-directed layout on canvas
|
||||
useEffect(() => {
|
||||
if (!graph || !canvasRef.current) return;
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const W = canvas.width = canvas.parentElement?.clientWidth || 800;
|
||||
const H = canvas.height = 600;
|
||||
|
||||
const nodeMap = new Map<string, { x: number; y: number; vx: number; vy: number }>();
|
||||
graph.nodes.forEach((n, i) => {
|
||||
const angle = (2 * Math.PI * i) / graph.nodes.length;
|
||||
nodeMap.set(n.id, {
|
||||
x: W / 2 + Math.cos(angle) * 200,
|
||||
y: H / 2 + Math.sin(angle) * 200,
|
||||
vx: 0, vy: 0,
|
||||
});
|
||||
});
|
||||
|
||||
const GROUP_COLORS: Record<string, string> = {
|
||||
PhysicalServer: '#4fc3f7', VirtualMachine: '#81c784', Switch: '#ff8a65',
|
||||
Router: '#ba68c8', Application: '#ffd54f', NAS: '#f48fb1',
|
||||
};
|
||||
|
||||
let animFrame: number;
|
||||
let iterations = 0;
|
||||
|
||||
const simulate = () => {
|
||||
iterations++;
|
||||
const alpha = Math.max(0.01, 1 - iterations / 300);
|
||||
|
||||
// Repulsion
|
||||
for (let i = 0; i < graph.nodes.length; i++) {
|
||||
for (let j = i + 1; j < graph.nodes.length; j++) {
|
||||
const a = nodeMap.get(graph.nodes[i].id)!;
|
||||
const b = nodeMap.get(graph.nodes[j].id)!;
|
||||
let dx = b.x - a.x, dy = b.y - a.y;
|
||||
let dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
let force = 800 / (dist * dist);
|
||||
force *= alpha;
|
||||
a.vx -= (dx / dist) * force;
|
||||
a.vy -= (dy / dist) * force;
|
||||
b.vx += (dx / dist) * force;
|
||||
b.vy += (dy / dist) * force;
|
||||
}
|
||||
}
|
||||
|
||||
// Attraction along edges
|
||||
graph.edges.forEach((e) => {
|
||||
const a = nodeMap.get(e.source);
|
||||
const b = nodeMap.get(e.target);
|
||||
if (!a || !b) return;
|
||||
let dx = b.x - a.x, dy = b.y - a.y;
|
||||
let dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
let force = (dist - 100) * 0.01 * alpha;
|
||||
a.vx += (dx / dist) * force;
|
||||
a.vy += (dy / dist) * force;
|
||||
b.vx -= (dx / dist) * force;
|
||||
b.vy -= (dy / dist) * force;
|
||||
});
|
||||
|
||||
// Center gravity
|
||||
nodeMap.forEach((n) => {
|
||||
n.vx += (W / 2 - n.x) * 0.001 * alpha;
|
||||
n.vy += (H / 2 - n.y) * 0.001 * alpha;
|
||||
n.vx *= 0.9;
|
||||
n.vy *= 0.9;
|
||||
n.x += n.vx;
|
||||
n.y += n.vy;
|
||||
n.x = Math.max(30, Math.min(W - 30, n.x));
|
||||
n.y = Math.max(30, Math.min(H - 30, n.y));
|
||||
});
|
||||
|
||||
// Draw
|
||||
ctx.fillStyle = '#0d1117';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
// Edges
|
||||
ctx.strokeStyle = 'rgba(79, 195, 247, 0.2)';
|
||||
ctx.lineWidth = 1;
|
||||
graph.edges.forEach((e) => {
|
||||
const a = nodeMap.get(e.source);
|
||||
const b = nodeMap.get(e.target);
|
||||
if (!a || !b) return;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(a.x, a.y);
|
||||
ctx.lineTo(b.x, b.y);
|
||||
ctx.stroke();
|
||||
});
|
||||
|
||||
// Nodes
|
||||
graph.nodes.forEach((n) => {
|
||||
const pos = nodeMap.get(n.id)!;
|
||||
const color = GROUP_COLORS[n.group] || '#ffffff';
|
||||
const radius = n.id === selectedNode ? 14 : 10;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(pos.x, pos.y, radius, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#ffffff';
|
||||
ctx.lineWidth = n.id === selectedNode ? 3 : 1;
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = '11px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(n.label, pos.x, pos.y - radius - 5);
|
||||
});
|
||||
|
||||
if (iterations < 300) {
|
||||
animFrame = requestAnimationFrame(simulate);
|
||||
}
|
||||
};
|
||||
|
||||
simulate();
|
||||
return () => cancelAnimationFrame(animFrame);
|
||||
}, [graph, selectedNode]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>Relationship Graph</Typography>
|
||||
<Card sx={{ bgcolor: '#161b22', mb: 2 }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3 }}>
|
||||
<Typography>Depth:</Typography>
|
||||
<Slider value={depth} onChange={(_, v) => setDepth(v as number)}
|
||||
min={1} max={5} step={1} marks sx={{ width: 200 }} />
|
||||
<Button variant="contained" onClick={fetchGraph} disabled={loading}>
|
||||
Reload
|
||||
</Button>
|
||||
{graph && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{graph.nodes.length} nodes, {graph.edges.length} edges
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}><CircularProgress /></Box>
|
||||
) : graph?.nodes.length === 0 ? (
|
||||
<Alert severity="info">No data to display. Add CIs and relationships first.</Alert>
|
||||
) : (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ width: '100%', borderRadius: 8, cursor: 'pointer' }}
|
||||
onClick={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
// Find closest node (simplified)
|
||||
let closest = null, minDist = 20;
|
||||
graph?.nodes.forEach((n) => {
|
||||
const pos = (canvasRef.current as any)?._nodePositions?.get(n.id);
|
||||
if (!pos) return;
|
||||
const d = Math.sqrt((pos.x - x) ** 2 + (pos.y - y) ** 2);
|
||||
if (d < minDist) { closest = n.id; minDist = d; }
|
||||
});
|
||||
if (closest) setSelectedNode(closest);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
56
frontend/src/pages/LoginPage.tsx
Normal file
56
frontend/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Box, Card, CardContent, TextField, Button, Typography, Alert } from '@mui/material';
|
||||
import { authApi } from '../services/api';
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await authApi.login(username, password);
|
||||
localStorage.setItem('token', res.data.access_token);
|
||||
navigate('/');
|
||||
} catch {
|
||||
setError('Invalid credentials');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh', bgcolor: '#0d1117' }}>
|
||||
<Card sx={{ width: 400, bgcolor: '#161b22' }}>
|
||||
<CardContent sx={{ p: 4 }}>
|
||||
<Typography variant="h4" align="center" gutterBottom sx={{ color: '#4fc3f7' }}>
|
||||
CMDB Login
|
||||
</Typography>
|
||||
<form onSubmit={handleLogin}>
|
||||
<TextField
|
||||
fullWidth label="Username" margin="normal"
|
||||
value={username} onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth label="Password" type="password" margin="normal"
|
||||
value={password} onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
{error && <Alert severity="error" sx={{ mt: 2 }}>{error}</Alert>}
|
||||
<Button
|
||||
fullWidth variant="contained" type="submit" disabled={loading}
|
||||
sx={{ mt: 3, bgcolor: '#4fc3f7', '&:hover': { bgcolor: '#29b6f6' } }}
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign In'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
89
frontend/src/services/api.ts
Normal file
89
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export interface CI {
|
||||
id: string;
|
||||
ci_type_id: string;
|
||||
ci_type?: { id: string; name: string };
|
||||
name: string;
|
||||
description?: string;
|
||||
status: string;
|
||||
location_id?: string;
|
||||
location?: { id: string; name: string };
|
||||
serial_number?: string;
|
||||
asset_tag?: string;
|
||||
attributes: Record<string, any>;
|
||||
tags: string[];
|
||||
version: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse {
|
||||
items: CI[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
pages: number;
|
||||
}
|
||||
|
||||
export interface GraphData {
|
||||
nodes: { id: string; label: string; group: string; status: string }[];
|
||||
edges: { source: string; target: string; label: string }[];
|
||||
}
|
||||
|
||||
export const ciApi = {
|
||||
list: (params: Record<string, any>) =>
|
||||
api.get<PaginatedResponse>('/ci', { params }),
|
||||
get: (id: string) => api.get<CI>(`/ci/${id}`),
|
||||
create: (data: any) => api.post<CI>('/ci', data),
|
||||
update: (id: string, data: any) => api.patch<CI>(`/ci/${id}`, data),
|
||||
delete: (id: string) => api.delete(`/ci/${id}`),
|
||||
graph: (params?: Record<string, any>) =>
|
||||
api.get<GraphData>('/ci/graph/visualize', { params }),
|
||||
exportData: (format: string = 'csv') =>
|
||||
api.get('/ci/export', { params: { format }, responseType: 'blob' }),
|
||||
bulkImport: (items: any[]) => api.post('/ci/bulk/import', { items }),
|
||||
};
|
||||
|
||||
export const refApi = {
|
||||
classes: () => api.get('/classes'),
|
||||
types: (classId?: string) =>
|
||||
api.get('/types', { params: classId ? { class_id: classId } : {} }),
|
||||
locations: () => api.get('/locations'),
|
||||
};
|
||||
|
||||
export const authApi = {
|
||||
login: (username: string, password: string) =>
|
||||
api.post('/auth/login', { username, password }),
|
||||
me: () => api.get('/auth/me'),
|
||||
};
|
||||
|
||||
export const dashboardApi = {
|
||||
stats: () => api.get('/dashboard/stats'),
|
||||
};
|
||||
|
||||
export default api;
|
||||
20
frontend/tsconfig.json
Normal file
20
frontend/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
15
frontend/vite.config.ts
Normal file
15
frontend/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
79
k8s/backend.yaml
Normal file
79
k8s/backend.yaml
Normal file
@@ -0,0 +1,79 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: cmdb-backend
|
||||
labels:
|
||||
app: cmdb-backend
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: cmdb-backend
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: cmdb-backend
|
||||
spec:
|
||||
containers:
|
||||
- name: backend
|
||||
image: cmdb-backend:latest
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
env:
|
||||
- name: DATABASE_URL
|
||||
value: postgresql+asyncpg://$(CMDB_USER):$(CMDB_PASS)@cmdb-postgres:5432/cmdb
|
||||
- name: JWT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: cmdb-secrets
|
||||
key: jwt-secret
|
||||
- name: CMDB_USER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: cmdb-secrets
|
||||
key: db-user
|
||||
- name: CMDB_PASS
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: cmdb-secrets
|
||||
key: db-password
|
||||
resources:
|
||||
requests:
|
||||
memory: 128Mi
|
||||
cpu: 100m
|
||||
limits:
|
||||
memory: 512Mi
|
||||
cpu: 500m
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 8000
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 30
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 8000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: cmdb-backend
|
||||
spec:
|
||||
selector:
|
||||
app: cmdb-backend
|
||||
ports:
|
||||
- port: 8000
|
||||
targetPort: 8000
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: cmdb-secrets
|
||||
type: Opaque
|
||||
stringData:
|
||||
db-user: cmdb
|
||||
db-password: CHANGE_ME_IN_PRODUCTION
|
||||
jwt-secret: CHANGE_ME_USE_OPENSSL_RAND_HEX_32
|
||||
65
k8s/frontend.yaml
Normal file
65
k8s/frontend.yaml
Normal file
@@ -0,0 +1,65 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: cmdb-frontend
|
||||
labels:
|
||||
app: cmdb-frontend
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: cmdb-frontend
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: cmdb-frontend
|
||||
spec:
|
||||
containers:
|
||||
- name: frontend
|
||||
image: cmdb-frontend:latest
|
||||
ports:
|
||||
- containerPort: 80
|
||||
resources:
|
||||
requests:
|
||||
memory: 64Mi
|
||||
cpu: 50m
|
||||
limits:
|
||||
memory: 128Mi
|
||||
cpu: 200m
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: cmdb-frontend
|
||||
spec:
|
||||
selector:
|
||||
app: cmdb-frontend
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 80
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: cmdb-ingress
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/rewrite-target: /
|
||||
spec:
|
||||
rules:
|
||||
- host: cmdb.homelab.local
|
||||
http:
|
||||
paths:
|
||||
- path: /api
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: cmdb-backend
|
||||
port:
|
||||
number: 8000
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: cmdb-frontend
|
||||
port:
|
||||
number: 80
|
||||
70
k8s/postgres.yaml
Normal file
70
k8s/postgres.yaml
Normal file
@@ -0,0 +1,70 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: cmdb-postgres
|
||||
labels:
|
||||
app: cmdb-postgres
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: cmdb-postgres
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: cmdb-postgres
|
||||
spec:
|
||||
containers:
|
||||
- name: postgres
|
||||
image: postgres:16-alpine
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
env:
|
||||
- name: POSTGRES_DB
|
||||
value: cmdb
|
||||
- name: POSTGRES_USER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: cmdb-secrets
|
||||
key: db-user
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: cmdb-secrets
|
||||
key: db-password
|
||||
volumeMounts:
|
||||
- name: pgdata
|
||||
mountPath: /var/lib/postgresql/data
|
||||
resources:
|
||||
requests:
|
||||
memory: 256Mi
|
||||
cpu: 250m
|
||||
limits:
|
||||
memory: 1Gi
|
||||
cpu: 1000m
|
||||
volumes:
|
||||
- name: pgdata
|
||||
persistentVolumeClaim:
|
||||
claimName: cmdb-pgdata
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: cmdb-pgdata
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: cmdb-postgres
|
||||
spec:
|
||||
selector:
|
||||
app: cmdb-postgres
|
||||
ports:
|
||||
- port: 5432
|
||||
targetPort: 5432
|
||||
18
nginx.conf
Normal file
18
nginx.conf
Normal file
@@ -0,0 +1,18 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://frontend:80;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user